From 5251cd4512202804f6900c9a901b86edab77cc15 Mon Sep 17 00:00:00 2001 From: Salah Date: Tue, 1 Sep 2026 03:15:03 +0400 Subject: [PATCH 01/14] feat(tsagentspec): add LangGraph adapter for the TypeScript SDK 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). --- tsagentspec/README.md | 89 + tsagentspec/examples/09-langgraph-adapter.ts | 191 ++ tsagentspec/examples/README.md | 1 + tsagentspec/examples/tsconfig.json | 3 +- tsagentspec/package-lock.json | 1703 ++++++++++++++++- tsagentspec/package.json | 46 +- .../src/adapters/common/agentspec-exporter.ts | 188 ++ .../src/adapters/common/agentspec-loader.ts | 232 +++ .../src/adapters/common/component-policy.ts | 262 +++ tsagentspec/src/adapters/common/converters.ts | 35 + tsagentspec/src/adapters/common/index.ts | 49 + .../src/adapters/common/json-schema.ts | 202 ++ tsagentspec/src/adapters/common/templating.ts | 104 + .../src/adapters/common/tools-common.ts | 213 +++ .../src/adapters/common/url-validation.ts | 125 ++ .../langgraph/agentspec-converter-flow.ts | 595 ++++++ .../adapters/langgraph/agentspec-converter.ts | 421 ++++ .../adapters/langgraph/agentspec-exporter.ts | 22 + .../adapters/langgraph/agentspec-loader.ts | 90 + tsagentspec/src/adapters/langgraph/index.ts | 30 + .../adapters/langgraph/langgraph-converter.ts | 932 +++++++++ tsagentspec/src/adapters/langgraph/llm.ts | 198 ++ .../src/adapters/langgraph/manager-workers.ts | 513 +++++ tsagentspec/src/adapters/langgraph/mcp.ts | 311 +++ .../src/adapters/langgraph/node-execution.ts | 1249 ++++++++++++ tsagentspec/src/adapters/langgraph/tools.ts | 368 ++++ tsagentspec/src/adapters/langgraph/tracing.ts | 55 + tsagentspec/src/adapters/langgraph/types.ts | 54 + tsagentspec/src/serialization/index.ts | 5 +- tsagentspec/src/serialization/serializer.ts | 50 +- .../adapters/common/component-policy.test.ts | 200 ++ .../tests/adapters/common/json-schema.test.ts | 215 +++ .../tests/adapters/common/templating.test.ts | 153 ++ .../adapters/common/url-validation.test.ts | 213 +++ .../tests/adapters/langgraph/exporter.test.ts | 1030 ++++++++++ .../adapters/langgraph/flow-nodes.test.ts | 1434 ++++++++++++++ .../adapters/langgraph/flow-state.test.ts | 378 ++++ .../tests/adapters/langgraph/llm.test.ts | 307 +++ .../adapters/langgraph/loader-agent.test.ts | 639 +++++++ .../langgraph/manager-workers.test.ts | 735 +++++++ .../tests/adapters/langgraph/mcp.test.ts | 497 +++++ .../adapters/langgraph/remote-tools.test.ts | 703 +++++++ .../tests/adapters/langgraph/swarm.test.ts | 207 ++ .../tests/adapters/langgraph/test-helpers.ts | 358 ++++ tsagentspec/tsup.config.ts | 5 +- 45 files changed, 15371 insertions(+), 39 deletions(-) create mode 100644 tsagentspec/examples/09-langgraph-adapter.ts create mode 100644 tsagentspec/src/adapters/common/agentspec-exporter.ts create mode 100644 tsagentspec/src/adapters/common/agentspec-loader.ts create mode 100644 tsagentspec/src/adapters/common/component-policy.ts create mode 100644 tsagentspec/src/adapters/common/converters.ts create mode 100644 tsagentspec/src/adapters/common/index.ts create mode 100644 tsagentspec/src/adapters/common/json-schema.ts create mode 100644 tsagentspec/src/adapters/common/templating.ts create mode 100644 tsagentspec/src/adapters/common/tools-common.ts create mode 100644 tsagentspec/src/adapters/common/url-validation.ts create mode 100644 tsagentspec/src/adapters/langgraph/agentspec-converter-flow.ts create mode 100644 tsagentspec/src/adapters/langgraph/agentspec-converter.ts create mode 100644 tsagentspec/src/adapters/langgraph/agentspec-exporter.ts create mode 100644 tsagentspec/src/adapters/langgraph/agentspec-loader.ts create mode 100644 tsagentspec/src/adapters/langgraph/index.ts create mode 100644 tsagentspec/src/adapters/langgraph/langgraph-converter.ts create mode 100644 tsagentspec/src/adapters/langgraph/llm.ts create mode 100644 tsagentspec/src/adapters/langgraph/manager-workers.ts create mode 100644 tsagentspec/src/adapters/langgraph/mcp.ts create mode 100644 tsagentspec/src/adapters/langgraph/node-execution.ts create mode 100644 tsagentspec/src/adapters/langgraph/tools.ts create mode 100644 tsagentspec/src/adapters/langgraph/tracing.ts create mode 100644 tsagentspec/src/adapters/langgraph/types.ts create mode 100644 tsagentspec/tests/adapters/common/component-policy.test.ts create mode 100644 tsagentspec/tests/adapters/common/json-schema.test.ts create mode 100644 tsagentspec/tests/adapters/common/templating.test.ts create mode 100644 tsagentspec/tests/adapters/common/url-validation.test.ts create mode 100644 tsagentspec/tests/adapters/langgraph/exporter.test.ts create mode 100644 tsagentspec/tests/adapters/langgraph/flow-nodes.test.ts create mode 100644 tsagentspec/tests/adapters/langgraph/flow-state.test.ts create mode 100644 tsagentspec/tests/adapters/langgraph/llm.test.ts create mode 100644 tsagentspec/tests/adapters/langgraph/loader-agent.test.ts create mode 100644 tsagentspec/tests/adapters/langgraph/manager-workers.test.ts create mode 100644 tsagentspec/tests/adapters/langgraph/mcp.test.ts create mode 100644 tsagentspec/tests/adapters/langgraph/remote-tools.test.ts create mode 100644 tsagentspec/tests/adapters/langgraph/swarm.test.ts create mode 100644 tsagentspec/tests/adapters/langgraph/test-helpers.ts diff --git a/tsagentspec/README.md b/tsagentspec/README.md index e5f14e21..d467ab1b 100644 --- a/tsagentspec/README.md +++ b/tsagentspec/README.md @@ -41,6 +41,95 @@ To consume it from another local project, add it as a local path dependency in t See the [examples](./examples/README.md) directory. +## LangGraph adapter + +The `agentspec/adapters/langgraph` subpath converts Agent Spec configurations into runnable [LangGraph JS](https://langchain-ai.github.io/langgraphjs/) objects and back, mirroring the Python `pyagentspec.adapters.langgraph` adapter. + +### Installation + +The LangChain packages are optional peer dependencies of this SDK; install the ones your configurations need: + +| Packages | Needed for | +|---|---| +| `langchain`, `@langchain/langgraph`, `@langchain/core` | always (loader/exporter core) | +| `@langchain/openai` | `OpenAiConfig`, `OpenAiCompatibleConfig`, `VllmConfig` | +| `@langchain/ollama` | `OllamaConfig` | +| `@langchain/mcp-adapters` | `MCPTool`, `MCPToolBox` | +| `@langchain/langgraph-swarm` | `Swarm` | + +```bash +npm install langchain @langchain/langgraph @langchain/core +# plus, depending on the components your specs use: +npm install @langchain/openai @langchain/ollama @langchain/mcp-adapters @langchain/langgraph-swarm +``` + +### Quickstart + +Load an Agent Spec configuration and invoke the resulting LangGraph object: + +```ts +import { AgentSpecLoader } from "agentspec/adapters/langgraph"; + +const loader = new AgentSpecLoader({ + toolRegistry: { + // ServerTool implementations, keyed by tool name. + get_weather: (input: unknown) => + `It is sunny in ${(input as { city: string }).city}.`, + }, +}); + +const agent = (await loader.loadYaml(yamlText)) as { + invoke(input: unknown): Promise>; +}; +const result = await agent.invoke({ + messages: [{ role: "user", content: "What is the weather in Agadir?" }], +}); +``` + +All load methods (`loadYaml`, `loadJson`, `loadDict`, `loadComponent`) are async. `AgentSpecLoader` also accepts a `checkpointer` (required for `ClientTool` and `requiresConfirmation` interrupts), a `config` (RunnableConfig), agent `middleware`, deserialization `plugins`, and an `allowedComponents`/`blockedComponents` load policy (`StdioTransport` is blocked by default). + +### Exporter + +Convert LangGraph objects back into Agent Spec configurations: + +```ts +import { AgentSpecExporter } from "agentspec/adapters/langgraph"; + +const exporter = new AgentSpecExporter(); +const yaml = exporter.toYaml(compiledGraph) as string; // also: toJson, toDict, toComponent +``` + +`createAgent(...)` agents export as `Agent`, compiled `@langchain/langgraph-swarm` graphs as `Swarm`, LangChain structured tools as `ServerTool`, `ChatOpenAI`/`ChatOllama` models as LLM configs, and any other `StateGraph` (compiled or not) as a `Flow`. + +### Supported components + +| Agent Spec | LangGraph runtime | +|---|---| +| `Agent` | `createAgent` react agent (structured outputs via the tool strategy) | +| `Swarm` | `@langchain/langgraph-swarm` `createSwarm` with handoff tools | +| `ManagerWorkers` | hierarchical `StateGraph` (`__manager__` node plus delegation tools) | +| `Flow` | `StateGraph` supporting `StartNode`, `EndNode`, `LlmNode`, `ToolNode`, `AgentNode`, `BranchingNode`, `ApiNode`, `FlowNode`, `CatchExceptionNode`, `InputMessageNode`, `OutputMessageNode`, `MapNode` | +| `ServerTool` | tool implementation resolved from the `toolRegistry` | +| `ClientTool` | LangGraph interrupt (`client_tool_request` payload) | +| `RemoteTool` | `fetch`-based HTTP tool | +| `MCPTool`, `MCPToolBox` | `@langchain/mcp-adapters` tools (SSE and Streamable HTTP transports) | +| `OpenAiConfig`, `OpenAiCompatibleConfig`, `VllmConfig` | `ChatOpenAI` | +| `OllamaConfig` | `ChatOllama` | + +`ParallelMapNode` and `ParallelFlowNode` are not supported and raise an error. + +### Divergences from the Python adapter + +- The loader and converter APIs are async (`Promise`-based); Python is sync-first. +- The TypeScript SDK has no `RetryPolicy` component yet, so `RemoteTool` performs a single `fetch` without the Python retry machinery. `RemoteTool`/`ApiNode` requests do not follow redirects and time out after a fixed 5 seconds (`DEFAULT_HTTP_REQUEST_TIMEOUT_MS`), matching httpx's defaults; there is no per-tool timeout override yet. +- When exporting a LangGraph graph whose conditional edge collides with a real node literally named `condition`, the synthetic conditional/branching node names are suffixed (`condition_1`, ...) so the real node keeps its edges; the Python-style names are used otherwise. +- The TypeScript SDK has no `urlAllowList` field on `RemoteTool`/`ApiNode` yet, so URL allow-list enforcement is not available (the warning about templated URLs without an allow list still fires). +- `OciGenAiConfig` is not supported (no `langchain-oci` package for JS). +- The MCP mTLS transports (`SSEmTLSTransport`, `StreamableHTTPmTLSTransport`) are not supported. +- Tracing is a no-op seam only; no execution spans or events are emitted yet. + +See [examples/09-langgraph-adapter.ts](./examples/09-langgraph-adapter.ts) for a complete offline round trip. + ## License UPL-1.0 or Apache-2.0 — see [LICENSE-UPL.txt](../LICENSE-UPL.txt) and [LICENSE-APACHE.txt](../LICENSE-APACHE.txt). diff --git a/tsagentspec/examples/09-langgraph-adapter.ts b/tsagentspec/examples/09-langgraph-adapter.ts new file mode 100644 index 00000000..b9b58dad --- /dev/null +++ b/tsagentspec/examples/09-langgraph-adapter.ts @@ -0,0 +1,191 @@ +/** + * Example 9: LangGraph adapter + * + * Demonstrates the two directions of the LangGraph adapter: + * - AgentSpecLoader: Agent Spec YAML -> runnable LangGraph agent + * - AgentSpecExporter: hand-built LangGraph StateGraph -> Agent Spec YAML + * + * The example runs fully offline: instead of a real LLM provider it injects a + * fake tool-calling chat model through the loader's `convertedComponents` + * conversion seam (pre-seeded runtime components, keyed by component id). + */ +import { + createAgent, + createServerTool, + createVllmConfig, + stringProperty, + AgentSpecSerializer, + type ComponentBase, +} from "agentspec"; +import { + AgentSpecExporter, + AgentSpecLoader, + type AgentSpecLoaderOptions, +} from "agentspec/adapters/langgraph"; +import { + BaseChatModel, + type BindToolsInput, +} from "@langchain/core/language_models/chat_models"; +import { AIMessage, type BaseMessage } from "@langchain/core/messages"; +import type { ChatResult } from "@langchain/core/outputs"; +import { Annotation, StateGraph, START, END } from "@langchain/langgraph"; + +// ============================================= +// Build an Agent spec with a ServerTool +// ============================================= + +const weatherTool = createServerTool({ + name: "get_weather", + description: "Returns the current weather for a city", + inputs: [stringProperty({ title: "city" })], + outputs: [stringProperty({ title: "weather" })], +}); + +const llmConfig = createVllmConfig({ + name: "vllm-model", + url: "http://localhost:8000", + modelId: "llama-3-70b", +}); + +const agentSpec = createAgent({ + name: "weather_agent", + llmConfig, + systemPrompt: "You are a helpful weather assistant.", + tools: [weatherTool], +}); + +// Serialize to YAML — this is the portable Agent Spec configuration. +const yaml = new AgentSpecSerializer().toYaml(agentSpec) as string; +console.log("--- Agent Spec (YAML) ---"); +console.log(yaml); + +// ============================================= +// A minimal fake tool-calling chat model +// ============================================= + +// Returns queued AIMessages one per model call; `bindTools` returns `this` so +// the model flows through `createAgent`'s tool-binding. Offline stand-in for +// ChatOpenAI/ChatOllama — real runs would skip this and let the adapter build +// the chat model from the LLM config. + +class FakeChatModel extends BaseChatModel { + private index = 0; + + constructor(private readonly responses: AIMessage[]) { + super({}); + } + + _llmType(): string { + return "fake-chat-model"; + } + + bindTools(_tools: BindToolsInput[]): this { + return this; + } + + async _generate(_messages: BaseMessage[]): Promise { + const message = + this.responses[Math.min(this.index, this.responses.length - 1)]!; + this.index += 1; + return { generations: [{ text: "", message }], llmOutput: {} }; + } +} + +const fakeModel = new FakeChatModel([ + // First model turn: call the tool. + new AIMessage({ + content: "", + tool_calls: [ + { + name: "get_weather", + args: { city: "Agadir" }, + id: "call_1", + type: "tool_call", + }, + ], + }), + // Second model turn: final answer. + new AIMessage("It is sunny in Agadir — enjoy the beach!"), +]); + +// ============================================= +// Load the YAML into a runnable LangGraph agent +// ============================================= + +// `convertedComponents` maps component ids to already-converted runtime +// objects; pre-seeding the LLM config's id substitutes the fake model. + +class OfflineAgentSpecLoader extends AgentSpecLoader { + constructor( + private readonly prebuilt: ReadonlyMap, + options?: AgentSpecLoaderOptions, + ) { + super(options); + } + + override async loadComponent(component: ComponentBase): Promise { + this.componentLoadPolicy.validateComponentTree(component); + return this.agentspecToRuntimeConverter.convert( + component, + this.toolRegistry, + { + convertedComponents: new Map(this.prebuilt), + checkpointer: this.checkpointer, + config: this.config, + }, + ); + } +} + +const loader = new OfflineAgentSpecLoader( + new Map([[llmConfig.id, fakeModel]]), + { + // ServerTool implementations are looked up here by tool name. + toolRegistry: { + get_weather: (input: unknown) => + `The weather in ${(input as { city: string }).city} is sunny.`, + }, + }, +); + +const agent = (await loader.loadYaml(yaml)) as { + invoke(input: unknown): Promise>; +}; + +const result = await agent.invoke({ + messages: [{ role: "user", content: "What is the weather in Agadir?" }], +}); + +console.log("--- Conversation ---"); +for (const message of result["messages"] as BaseMessage[]) { + const toolCalls = + message instanceof AIMessage ? (message.tool_calls ?? []) : []; + const text = + toolCalls.length > 0 + ? toolCalls + .map((call) => `tool call: ${call.name}(${JSON.stringify(call.args)})`) + .join(", ") + : String(message.content); + console.log(`[${message.getType()}] ${text}`); +} + +// ============================================= +// Export a hand-built StateGraph to Agent Spec +// ============================================= + +const SummaryState = Annotation.Root({ + text: Annotation, + summary: Annotation, +}); + +const graph = new StateGraph(SummaryState) + .addNode("summarize", (state: typeof SummaryState.State) => ({ + summary: `Summary of: ${state.text}`, + })) + .addEdge(START, "summarize") + .addEdge("summarize", END) + .compile({ name: "Summarizer" }); + +const exporter = new AgentSpecExporter(); +console.log("--- Exported StateGraph (YAML) ---"); +console.log(exporter.toYaml(graph) as string); diff --git a/tsagentspec/examples/README.md b/tsagentspec/examples/README.md index b3ba7a5a..2173950a 100644 --- a/tsagentspec/examples/README.md +++ b/tsagentspec/examples/README.md @@ -14,6 +14,7 @@ These examples demonstrate how to use the `agentspec` TypeScript SDK to define A | 6 | [06-serialization.ts](./06-serialization.ts) | JSON/YAML serialization, camelCase, disaggregated components | | 7 | [07-a2a-agent.ts](./07-a2a-agent.ts) | A2A (Agent-to-Agent) protocol and remote agents | | 8 | [08-datastores.ts](./08-datastores.ts) | In-memory, Oracle DB, and PostgreSQL datastores | +| 9 | [09-langgraph-adapter.ts](./09-langgraph-adapter.ts) | LangGraph adapter: load a spec into a runnable agent, export a StateGraph | ## Running diff --git a/tsagentspec/examples/tsconfig.json b/tsagentspec/examples/tsconfig.json index 4d78b75a..0f4f2cc7 100644 --- a/tsagentspec/examples/tsconfig.json +++ b/tsagentspec/examples/tsconfig.json @@ -13,7 +13,8 @@ "resolveJsonModule": true, "types": ["node"], "paths": { - "agentspec": ["../src/index.ts"] + "agentspec": ["../src/index.ts"], + "agentspec/adapters/langgraph": ["../src/adapters/langgraph/index.ts"] }, "noEmit": true }, diff --git a/tsagentspec/package-lock.json b/tsagentspec/package-lock.json index 5fc5a3a1..0c85b836 100644 --- a/tsagentspec/package-lock.json +++ b/tsagentspec/package-lock.json @@ -14,9 +14,16 @@ }, "devDependencies": { "@eslint/js": "^9.39.2", + "@langchain/core": "^1.2.9", + "@langchain/langgraph": "^1.4.13", + "@langchain/langgraph-swarm": "^1.0.2", + "@langchain/mcp-adapters": "^1.1.4", + "@langchain/ollama": "^1.3.0", + "@langchain/openai": "^1.5.10", "@types/node": "^25.2.3", "@vitest/coverage-v8": "^4.1.4", "eslint": "^9.39.2", + "langchain": "^1.5.10", "tsup": "^8.0.0", "tsx": "^4.19.2", "typescript": "^5.5.0", @@ -25,6 +32,38 @@ }, "engines": { "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@langchain/core": "^1.2.9", + "@langchain/langgraph": "^1.4.13", + "@langchain/langgraph-swarm": "^1.0.2", + "@langchain/mcp-adapters": "^1.1.4", + "@langchain/ollama": "^1.3.0", + "@langchain/openai": "^1.5.10", + "langchain": "^1.5.10" + }, + "peerDependenciesMeta": { + "@langchain/core": { + "optional": true + }, + "@langchain/langgraph": { + "optional": true + }, + "@langchain/langgraph-swarm": { + "optional": true + }, + "@langchain/mcp-adapters": { + "optional": true + }, + "@langchain/ollama": { + "optional": true + }, + "@langchain/openai": { + "optional": true + }, + "langchain": { + "optional": true + } } }, "node_modules/@babel/helper-string-parser": { @@ -87,6 +126,13 @@ "node": ">=18" } }, + "node_modules/@cfworker/json-schema": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", + "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", + "dev": true, + "license": "MIT" + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -755,6 +801,19 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@hono/node-server": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -846,6 +905,281 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@langchain/core": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.2.9.tgz", + "integrity": "sha512-conzSEj9Zu1AyXJLXsSbgrtxtxinmI1yGqQ5CIJZSoV5rvv+yvQE/vgBnoySpBQ/bl3YPgj2FL/gbDjWykLSfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cfworker/json-schema": "^4.0.2", + "@standard-schema/spec": "^1.1.0", + "js-tiktoken": "^1.0.12", + "langsmith": ">=0.5.0 <1.0.0", + "mustache": "^4.2.0", + "p-queue": "^6.6.2", + "zod": "^3.25.76 || ^4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@langchain/langgraph": { + "version": "1.4.13", + "resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.4.13.tgz", + "integrity": "sha512-LO1ak6jNQ9jR13tm7Ay4Yh2/otrH7LNVUwWTAI7WJigVdW5Fb6LuYSZUzVn4S7sVSiyVFfnrUcDTd8c7eAzPrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@langchain/langgraph-checkpoint": "^1.1.5", + "@langchain/langgraph-sdk": "~1.10.0", + "@langchain/protocol": "^0.0.18", + "@standard-schema/spec": "1.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@langchain/core": "^1.1.48", + "zod": "^3.25.32 || ^4.2.0" + } + }, + "node_modules/@langchain/langgraph-checkpoint": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.1.5.tgz", + "integrity": "sha512-BwDwl5VeTOh6CVuiIPgsUgfK51vTJDMSbFcSCUfjJWsl8/DPdK/mbv+ejxJstkSk/BlSPMP4JfXWcN6jD2ea2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@langchain/core": "^1.1.48" + } + }, + "node_modules/@langchain/langgraph-sdk": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.10.0.tgz", + "integrity": "sha512-cPPkh+hMNgeOaGtJRrqs1AjZde45cG2+Ma9Sc10wz2RyvT8SKToCKS+VvkS18SsLajnmq6/FKVmthq6rnUVYOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@langchain/protocol": "^0.0.19", + "@types/json-schema": "^7.0.15", + "p-queue": "^9.0.1", + "p-retry": "^7.1.1" + }, + "peerDependencies": { + "@langchain/core": "^1.1.48", + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/@langchain/langgraph-sdk/node_modules/@langchain/protocol": { + "version": "0.0.19", + "resolved": "https://registry.npmjs.org/@langchain/protocol/-/protocol-0.0.19.tgz", + "integrity": "sha512-9hKcRrH7cBX6gfutdfXPoft1OCchHe4FEpALoDJMl5Qu+n/YG5ynZmyu8+8cxORlPwHBoKTxggvXz+76M1yX1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@langchain/langgraph-sdk/node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@langchain/langgraph-sdk/node_modules/p-queue": { + "version": "9.3.3", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.3.tgz", + "integrity": "sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.4", + "p-timeout": "^7.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@langchain/langgraph-sdk/node_modules/p-timeout": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz", + "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@langchain/langgraph-swarm": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@langchain/langgraph-swarm/-/langgraph-swarm-1.0.2.tgz", + "integrity": "sha512-5PmdxzSLB7NCXh9awtmia28l30xIZL6DKIUZwKldAbUEu6ynTh9ZPIRxqDor2qAGw5eU1bd+MCHScdiaRX6Z9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "zod": "^3.25.76 || ^4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@langchain/core": "^1.1.44", + "@langchain/langgraph": "^1.3.1-rc.0" + } + }, + "node_modules/@langchain/mcp-adapters": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@langchain/mcp-adapters/-/mcp-adapters-1.1.4.tgz", + "integrity": "sha512-6E8ULWoI9w+lxydeb8yHILyEluonZ4JS42OwSLL1k/RvG/trjGuLYm7P9gE+/pFOxLuNk9wHZ7BLfGju4tcJEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.30.0", + "debug": "^4.4.3", + "zod": "^3.25.76 || ^4" + }, + "engines": { + "node": ">=20.10.0" + }, + "optionalDependencies": { + "extended-eventsource": "^1.7.0" + }, + "peerDependencies": { + "@langchain/core": "^1.0.0", + "@langchain/langgraph": "^1.4.10" + }, + "peerDependenciesMeta": { + "@langchain/core": { + "optional": false + }, + "@langchain/langgraph": { + "optional": false + } + } + }, + "node_modules/@langchain/ollama": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@langchain/ollama/-/ollama-1.3.0.tgz", + "integrity": "sha512-uzLkZVTXN0SI5zAoJbJzL4y/QN7gbmJ1txBgFfqJ5M0Qf+Yu2Zu8q98L3jIrfM+akWMaNmRU4R4APhcfQmeIsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ollama": "^0.6.3" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@langchain/core": "^1.0.0" + } + }, + "node_modules/@langchain/openai": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-1.5.10.tgz", + "integrity": "sha512-4cxdgolkkXwnAiGEkNrue+ba7jUKjfBwleLCX5DrRVcRGrCc4w5EceblYZOIaHMY6+nhwMqIOtSzBWgcBCLfmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tiktoken": "^1.0.12", + "openai": "^7.5.0", + "zod": "^3.25.76 || ^4" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "@langchain/core": "^1.2.9" + } + }, + "node_modules/@langchain/protocol": { + "version": "0.0.18", + "resolved": "https://registry.npmjs.org/@langchain/protocol/-/protocol-0.0.18.tgz", + "integrity": "sha512-XW1egQtPfsGI41w2AMZNFZrUIwFSQHTjVMZs0OaTpCAvht/QLoaPN8FQcsysMVypOhupG28J29yOorrc70otBQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -968,9 +1302,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -988,9 +1319,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1008,9 +1336,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1028,9 +1353,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1048,9 +1370,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1068,9 +1387,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1941,6 +2257,20 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -1981,6 +2311,48 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -2024,6 +2396,66 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/brace-expansion": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", @@ -2050,16 +2482,57 @@ "esbuild": ">=0.18" } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2183,6 +2656,30 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2190,6 +2687,44 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2230,6 +2765,16 @@ "dev": true, "license": "MIT" }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2240,6 +2785,58 @@ "node": ">=8" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", @@ -2247,6 +2844,19 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -2289,6 +2899,13 @@ "@esbuild/win32-x64": "0.28.1" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -2490,6 +3107,46 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -2500,6 +3157,78 @@ "node": ">=12.0.0" } }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/extended-eventsource": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/extended-eventsource/-/extended-eventsource-1.7.0.tgz", + "integrity": "sha512-s8rtvZuYcKBpzytHb5g95cHbZ1J99WeMnV18oKc5wKoxkHzlzpPc/bNAm7Da2Db0BDw0CAu1z3LpH+7UsyzIpw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2521,6 +3250,23 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2552,6 +3298,28 @@ "node": ">=16.0.0" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -2602,6 +3370,26 @@ "dev": true, "license": "ISC" }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2617,6 +3405,55 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-tsconfig": { "version": "4.13.7", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", @@ -2656,6 +3493,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2666,6 +3516,42 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.13.5", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.5.tgz", + "integrity": "sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -2673,6 +3559,44 @@ "dev": true, "license": "MIT" }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2720,6 +3644,33 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", + "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2743,6 +3694,26 @@ "node": ">=0.10.0" } }, + "node_modules/is-network-error": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.2.tgz", + "integrity": "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2789,6 +3760,16 @@ "node": ">=8" } }, + "node_modules/jose": { + "version": "6.2.10", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.10.tgz", + "integrity": "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -2799,6 +3780,16 @@ "node": ">=10" } }, + "node_modules/js-tiktoken": { + "version": "1.0.21", + "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz", + "integrity": "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==", + "dev": true, + "license": "MIT", + "dependencies": { + "base64-js": "^1.5.1" + } + }, "node_modules/js-tokens": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", @@ -2843,6 +3834,13 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -2860,6 +3858,59 @@ "json-buffer": "3.0.1" } }, + "node_modules/langchain": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/langchain/-/langchain-1.5.10.tgz", + "integrity": "sha512-JaC12C1qyGn985vvjttr4hr8lfFzWhrXp2M1byZJGmNJ2RiIgqnhiYDuLlG/xHDxhKD3onJ5pCuUif/cbdqPhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@langchain/langgraph": "^1.4.10", + "@langchain/langgraph-checkpoint": "^1.1.5", + "langsmith": ">=0.5.0 <1.0.0", + "zod": "^3.25.76 || ^4" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@langchain/core": "^1.2.9" + } + }, + "node_modules/langsmith": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.9.0.tgz", + "integrity": "sha512-tlg/aG7qezAKY6G3fgADSX7PkRj+JKoF3z7QNkCMsAOvwvuzhiwP9Amn1Z+zAIxuKoWuXQdIjtFN0LVmUC1oUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-queue": "6.6.2" + }, + "peerDependencies": { + "@opentelemetry/api": "*", + "@opentelemetry/exporter-trace-otlp-proto": "*", + "@opentelemetry/sdk-trace-base": "*", + "openai": "*", + "ws": ">=7" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/exporter-trace-otlp-proto": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + }, + "openai": { + "optional": true + }, + "ws": { + "optional": true + } + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -3226,6 +4277,70 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/minimatch": { "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", @@ -3262,6 +4377,16 @@ "dev": true, "license": "MIT" }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "dev": true, + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -3300,6 +4425,37 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -3310,16 +4466,100 @@ "node": ">=0.10.0" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT" + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/ollama": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/ollama/-/ollama-0.6.3.tgz", + "integrity": "sha512-KEWEhIqE5wtfzEIZbDCLH51VFZ6Z3ZSa6sIOg/E/tBV8S51flyqBOXi+bRxlOYKDf8i327zG9eSTb8IJxvm3Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-fetch": "^3.6.20" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/openai": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-7.8.0.tgz", + "integrity": "sha512-/2g9JzdnXNcjX1W/UlSNu+OdSFDAaAVt0n9Onom0kPenH54o59G2WrX/xjTnr26UHNSh6hxcAf58doGYRme2rw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", + "undici": ">=5 <9", + "ws": "^8.21.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "undici": { + "optional": true + }, + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } }, "node_modules/optionator": { "version": "0.9.4", @@ -3339,6 +4579,16 @@ "node": ">= 0.8.0" } }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -3371,6 +4621,52 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-retry": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-7.1.1.tgz", + "integrity": "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-network-error": "^1.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -3384,6 +4680,16 @@ "node": ">=6" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3404,6 +4710,17 @@ "node": ">=8" } }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -3441,6 +4758,16 @@ "node": ">= 6" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-types": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", @@ -3535,6 +4862,20 @@ "node": ">= 0.8.0" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3545,6 +4886,53 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -3559,6 +4947,16 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -3658,6 +5056,30 @@ "fsevents": "~2.3.2" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -3671,6 +5093,60 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -3694,6 +5170,82 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -3728,6 +5280,16 @@ "dev": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", @@ -3848,6 +5410,16 @@ "node": ">=14.0.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -3972,6 +5544,39 @@ "node": ">= 0.8.0" } }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -4024,6 +5629,16 @@ "dev": true, "license": "MIT" }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -4034,6 +5649,16 @@ "punycode": "^2.1.0" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vite": { "version": "8.0.16", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", @@ -4212,6 +5837,13 @@ "node": ">=18" } }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "dev": true, + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4255,6 +5887,13 @@ "node": ">=0.10.0" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, "node_modules/yaml": { "version": "2.8.3", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", @@ -4291,6 +5930,16 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/tsagentspec/package.json b/tsagentspec/package.json index c5713608..157df500 100644 --- a/tsagentspec/package.json +++ b/tsagentspec/package.json @@ -28,6 +28,11 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" + }, + "./adapters/langgraph": { + "types": "./dist/adapters/langgraph/index.d.ts", + "import": "./dist/adapters/langgraph/index.js", + "require": "./dist/adapters/langgraph/index.cjs" } }, "files": [ @@ -45,7 +50,46 @@ "yaml": "^2.8.3", "zod": "^3.23.0" }, + "peerDependencies": { + "@langchain/core": "^1.2.9", + "@langchain/langgraph": "^1.4.13", + "@langchain/langgraph-swarm": "^1.0.2", + "@langchain/mcp-adapters": "^1.1.4", + "@langchain/ollama": "^1.3.0", + "@langchain/openai": "^1.5.10", + "langchain": "^1.5.10" + }, + "peerDependenciesMeta": { + "@langchain/core": { + "optional": true + }, + "@langchain/langgraph": { + "optional": true + }, + "@langchain/langgraph-swarm": { + "optional": true + }, + "@langchain/mcp-adapters": { + "optional": true + }, + "@langchain/ollama": { + "optional": true + }, + "@langchain/openai": { + "optional": true + }, + "langchain": { + "optional": true + } + }, "devDependencies": { + "@langchain/core": "^1.2.9", + "@langchain/langgraph": "^1.4.13", + "@langchain/langgraph-swarm": "^1.0.2", + "@langchain/mcp-adapters": "^1.1.4", + "@langchain/ollama": "^1.3.0", + "@langchain/openai": "^1.5.10", + "langchain": "^1.5.10", "@eslint/js": "^9.39.2", "@types/node": "^25.2.3", "@vitest/coverage-v8": "^4.1.4", @@ -57,7 +101,7 @@ "vitest": "^4.1.4" }, "overrides": { - "ajv": "6.14.0", + "ajv@^6.0.0": "6.14.0", "esbuild": "0.28.1", "js-yaml": "4.2.0", "minimatch@^3.0.0": { diff --git a/tsagentspec/src/adapters/common/agentspec-exporter.ts b/tsagentspec/src/adapters/common/agentspec-exporter.ts new file mode 100644 index 00000000..2f75f1a5 --- /dev/null +++ b/tsagentspec/src/adapters/common/agentspec-exporter.ts @@ -0,0 +1,188 @@ +/** + * Framework-agnostic exporter converting runtime objects to Agent Spec + * configurations. Port of + * `pyagentspec.adapters._agentspecexporter.AdapterAgnosticAgentSpecExporter`. + */ +import type { ComponentBase } from "../../component.js"; +import { AgentSpecSerializer } from "../../serialization/index.js"; +import type { + ComponentSerializationPlugin, + DisaggregatedComponentsConfig, +} from "../../serialization/index.js"; +import type { AgentSpecVersion } from "../../versioning.js"; +import type { RuntimeToAgentSpecConverter } from "./converters.js"; + +/** + * Runtime components/fields to disaggregate upon serialization. Each item can + * be a runtime component (disaggregated using its converted component id) or + * a `[runtimeComponent, customId]` pair (disaggregated using the custom id). + */ +export type RuntimeDisaggregatedComponentsConfig = ReadonlyArray< + unknown | readonly [unknown, string] +>; + +/** Options for the export methods. */ +export interface ExportOptions { + /** The Agent Spec version to serialize the component at. */ + agentspecVersion?: AgentSpecVersion; + /** + * Components/fields to disaggregate upon serialization. Components listed + * here are disaggregated even if `exportDisaggregatedComponents` is false. + */ + disaggregatedComponents?: RuntimeDisaggregatedComponentsConfig; + /** Whether to export the disaggregated components. Defaults to false. */ + exportDisaggregatedComponents?: boolean; +} + +/** Serialized dictionary form of a component. */ +export type ExportedDict = Record; + +/** Helper class to convert runtime objects to Agent Spec configurations. */ +export abstract class AdapterAgnosticAgentSpecExporter { + /** Serialization plugins passed to the serializer. */ + readonly plugins: ComponentSerializationPlugin[]; + + constructor(plugins?: ComponentSerializationPlugin[]) { + this.plugins = plugins ?? []; + } + + /** Converter used to convert runtime components to Agent Spec components. */ + abstract get runtimeToAgentSpecConverter(): RuntimeToAgentSpecConverter; + + /** + * Transform the given runtime component into the respective Agent Spec JSON + * representation. Returns `[main, referenced]` JSON strings when + * `exportDisaggregatedComponents` is true. + */ + toJson( + runtimeComponent: unknown, + options?: ExportOptions, + ): string | [string, string] { + return this._export("json", runtimeComponent, options) as + | string + | [string, string]; + } + + /** + * Transform the given runtime component into the respective Agent Spec YAML + * representation. Returns `[main, referenced]` YAML strings when + * `exportDisaggregatedComponents` is true. + */ + toYaml( + runtimeComponent: unknown, + options?: ExportOptions, + ): string | [string, string] { + return this._export("yaml", runtimeComponent, options) as + | string + | [string, string]; + } + + /** + * Transform the given runtime component into the respective Agent Spec + * dictionary. Returns `[main, referenced]` dictionaries when + * `exportDisaggregatedComponents` is true. + */ + toDict( + runtimeComponent: unknown, + options?: ExportOptions, + ): ExportedDict | [ExportedDict, ExportedDict] { + return this._export("dict", runtimeComponent, options) as + | ExportedDict + | [ExportedDict, ExportedDict]; + } + + /** + * Transform the given runtime component into the respective AgentSpec + * component. + */ + toComponent(runtimeComponent: unknown): ComponentBase { + return this.runtimeToAgentSpecConverter.convert(runtimeComponent); + } + + /** + * Common implementation of the export methods. The returned type depends on + * the type of exporter. + */ + protected _export( + exporter: "json" | "yaml" | "dict", + runtimeComponent: unknown, + options?: ExportOptions, + ): string | [string, string] | ExportedDict | [ExportedDict, ExportedDict] { + if (exporter !== "json" && exporter !== "yaml" && exporter !== "dict") { + throw new Error( + `Unsupported exporter type: \`${String(exporter)}\`. Expected \`dict\`, \`json\`, or \`yaml\`.`, + ); + } + const serializer = new AgentSpecSerializer(this.plugins); + + const [convertedDisagComponents, referencedComponents] = + options?.disaggregatedComponents !== undefined + ? this._convertDisaggregatedConfig(options.disaggregatedComponents) + : [undefined, undefined]; + const agentspecAssistant = this.runtimeToAgentSpecConverter.convert( + runtimeComponent, + referencedComponents, + ); + const serializerOptions = { + agentspecVersion: options?.agentspecVersion, + disaggregatedComponents: convertedDisagComponents, + exportDisaggregatedComponents: + options?.exportDisaggregatedComponents ?? false, + }; + + if (exporter === "yaml") { + return serializer.toYaml(agentspecAssistant, serializerOptions); + } + const json = serializer.toJson(agentspecAssistant, serializerOptions); + if (exporter === "json") { + return json; + } + // "dict": the TS AgentSpecSerializer has no public toDict, so the + // dictionary form is derived from the JSON serialization. + if (Array.isArray(json)) { + return [ + JSON.parse(json[0]) as ExportedDict, + JSON.parse(json[1]) as ExportedDict, + ]; + } + return JSON.parse(json) as ExportedDict; + } + + /** + * Convert the runtime disaggregated-components config into Agent Spec + * components, accumulating the shared referenced-objects registry that is + * then passed into the root conversion (so disaggregated components share + * identity with references inside the root component). + */ + protected _convertDisaggregatedConfig( + runtimeDisagConfig: RuntimeDisaggregatedComponentsConfig, + ): [DisaggregatedComponentsConfig, Map] { + const agentspecDisaggregatedComponents: Array< + ComponentBase | readonly [ComponentBase, string] + > = []; + const referencedComponents = new Map(); + for (const disagConfig of runtimeDisagConfig) { + const pair = + Array.isArray(disagConfig) && + disagConfig.length === 2 && + typeof disagConfig[1] === "string" + ? (disagConfig as unknown as readonly [unknown, string]) + : undefined; + const runtimeComponent = pair !== undefined ? pair[0] : disagConfig; + const agentspecComponent = this.runtimeToAgentSpecConverter.convert( + runtimeComponent, + referencedComponents, + ); + // Mirroring Python, the converted component keeps its own id everywhere + // (root tree and disaggregated registry); a custom id is passed through + // as a [component, customId] pair and applied by the serializer only as + // the serialization-time mapping key. + agentspecDisaggregatedComponents.push( + pair !== undefined + ? ([agentspecComponent, pair[1]] as const) + : agentspecComponent, + ); + } + return [agentspecDisaggregatedComponents, referencedComponents]; + } +} diff --git a/tsagentspec/src/adapters/common/agentspec-loader.ts b/tsagentspec/src/adapters/common/agentspec-loader.ts new file mode 100644 index 00000000..362a298e --- /dev/null +++ b/tsagentspec/src/adapters/common/agentspec-loader.ts @@ -0,0 +1,232 @@ +/** + * Framework-agnostic loader for Agent Spec configurations. Port of + * `pyagentspec.adapters._agentspecloader.AdapterAgnosticAgentSpecLoader`. + * + * This base class centralizes plugin-aware deserialization, the component + * load policy, and support for disaggregated components + * (`importOnlyReferencedComponents`). Subclasses supply the two converters. + * + * Divergence from Python: the TS `AgentSpecDeserializer` has no + * allowed/blocked component parameters, so the policy is enforced on the + * deserialized component tree (`validateComponentTree`) before conversion, + * not at parse time. + */ +import type { ComponentBase } from "../../component.js"; +import { AgentSpecDeserializer } from "../../serialization/index.js"; +import type { + ComponentDeserializationPlugin, + ComponentsRegistry, +} from "../../serialization/index.js"; +import { + ComponentLoadPolicy, + type ComponentPolicyInput, +} from "./component-policy.js"; +import type { + AgentSpecToRuntimeConverter, + RuntimeToAgentSpecConverter, +} from "./converters.js"; + +/** Components blocked by default: stdio MCP transports run local processes. */ +const DEFAULT_BLOCKED_COMPONENTS: readonly string[] = ["StdioTransport"]; + +/** Constructor options for the adapter-agnostic loader base. */ +export interface AdapterAgnosticAgentSpecLoaderOptions { + /** Registry mapping tool names to runtime implementations. */ + toolRegistry?: Record; + /** Deserialization plugins; builtins are used when omitted. */ + plugins?: ComponentDeserializationPlugin[]; + /** + * Component type names allowed to load. When omitted, all component types + * are allowed unless blocked. + */ + allowedComponents?: ComponentPolicyInput; + /** + * Component type names blocked from loading. When omitted, `StdioTransport` + * is blocked by default. + */ + blockedComponents?: ComponentPolicyInput; +} + +/** Per-call options for the load methods. */ +export interface LoadOptions { + /** + * Registry mapping ids to runtime components/values. Entries are converted + * back to Agent Spec components to resolve references during + * deserialization; if a conversion fails, the given value is used as-is. + */ + componentsRegistry?: Record; + /** + * When true, load only the referenced/disaggregated components and return a + * dictionary mapping component id to runtime components/values. These can + * be used as the `componentsRegistry` when loading the main configuration. + */ + importOnlyReferencedComponents?: boolean; +} + +/** Convert serialized Agent Spec into adapter runtime components. */ +export abstract class AdapterAgnosticAgentSpecLoader { + /** Registry mapping tool names to runtime implementations. */ + readonly toolRegistry: Record; + /** Deserialization plugins passed to the deserializer (builtins if unset). */ + readonly plugins?: ComponentDeserializationPlugin[]; + /** The allow/block policy applied to every loaded component tree. */ + readonly componentLoadPolicy: ComponentLoadPolicy; + /** Normalized allow-list entries, or undefined when no allow list is set. */ + readonly allowedComponents?: readonly string[]; + /** Normalized block-list entries. */ + readonly blockedComponents: readonly string[]; + + constructor(options?: AdapterAgnosticAgentSpecLoaderOptions) { + const opts = options ?? {}; + this.plugins = opts.plugins; + this.toolRegistry = opts.toolRegistry ?? {}; + this.componentLoadPolicy = new ComponentLoadPolicy( + opts.allowedComponents, + opts.blockedComponents ?? DEFAULT_BLOCKED_COMPONENTS, + ); + this.allowedComponents = this.componentLoadPolicy.allowedComponents; + this.blockedComponents = this.componentLoadPolicy.blockedComponents; + } + + /** Converter used to convert Agent Spec components to runtime components. */ + abstract get agentspecToRuntimeConverter(): AgentSpecToRuntimeConverter; + + /** Converter used to convert runtime components to Agent Spec components. */ + abstract get runtimeToAgentSpecConverter(): RuntimeToAgentSpecConverter; + + /** + * Transform the given Agent Spec YAML into runtime components, with support + * for disaggregated configurations. + */ + async loadYaml( + serializedAssistant: string, + options?: LoadOptions, + ): Promise { + return this._load("yaml", serializedAssistant, options); + } + + /** + * Transform the given Agent Spec JSON into runtime components, with support + * for disaggregated configurations. + */ + async loadJson( + serializedAssistant: string, + options?: LoadOptions, + ): Promise { + return this._load("json", serializedAssistant, options); + } + + /** + * Transform the given Agent Spec dictionary into runtime components, with + * support for disaggregated configurations. + */ + async loadDict( + serializedAssistant: Record, + options?: LoadOptions, + ): Promise { + return this._load("dict", serializedAssistant, options); + } + + /** + * Convert an Agent Spec component into a runtime component after validating + * it against the component load policy. + * + * Subclasses may override this method to pass adapter-specific parameters + * into their converter (e.g., checkpointers). + */ + async loadComponent(agentspecComponent: ComponentBase): Promise { + this.componentLoadPolicy.validateComponentTree(agentspecComponent); + return this.agentspecToRuntimeConverter.convert( + agentspecComponent, + this.toolRegistry, + ); + } + + /** + * Convert a runtime components registry into an Agent Spec registry so that + * references can be resolved during deserialization. Values that fail to + * convert are kept as-is with a warning (mirrors Python). + */ + protected _convertComponentRegistry( + runtimeComponentRegistry: Record, + ): ComponentsRegistry { + const converter = this.runtimeToAgentSpecConverter; + const convertedRegistry: ComponentsRegistry = new Map(); + for (const [customId, runtimeComponentOrValue] of Object.entries( + runtimeComponentRegistry, + )) { + try { + convertedRegistry.set( + customId, + converter.convert(runtimeComponentOrValue), + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn( + `Failed to convert runtime component ${customId} with exception \`${message}\`. Fallback to given value.`, + ); + // Mirrors Python: unconvertible registry values are used as-is when + // resolving references. + convertedRegistry.set(customId, runtimeComponentOrValue as ComponentBase); + } + } + return convertedRegistry; + } + + /** Common implementation of the load methods. */ + protected async _load( + loader: "yaml" | "json" | "dict", + serializedAssistant: string | Record, + options?: LoadOptions, + ): Promise { + const deserializer = new AgentSpecDeserializer(this.plugins); + let deserialize: (deserializeOptions: { + componentsRegistry?: ComponentsRegistry; + importOnlyReferencedComponents?: boolean; + }) => ComponentBase | Record; + if (loader === "yaml") { + deserialize = (deserializeOptions) => + deserializer.fromYaml(serializedAssistant as string, deserializeOptions); + } else if (loader === "json") { + deserialize = (deserializeOptions) => + deserializer.fromJson(serializedAssistant as string, deserializeOptions); + } else if (loader === "dict") { + // The TS AgentSpecDeserializer has no public dict entry point; + // round-trip through JSON. + const json = JSON.stringify(serializedAssistant); + deserialize = (deserializeOptions) => + deserializer.fromJson(json, deserializeOptions); + } else { + throw new Error( + `Unsupported loader type: \`${String(loader)}\`. Expected \`dict\`, \`json\`, or \`yaml\`.`, + ); + } + + const convertedRegistry = + options?.componentsRegistry !== undefined + ? this._convertComponentRegistry(options.componentsRegistry) + : undefined; + + if (options?.importOnlyReferencedComponents) { + // Loading the disaggregated components + const referencedComponentsDict = deserialize({ + componentsRegistry: convertedRegistry, + importOnlyReferencedComponents: true, + }) as Record; + const runtimeComponents: Record = {}; + for (const [componentId, agentspecComponent] of Object.entries( + referencedComponentsDict, + )) { + runtimeComponents[componentId] = + await this.loadComponent(agentspecComponent); + } + return runtimeComponents; + } + + const agentspecComponent = deserialize({ + componentsRegistry: convertedRegistry, + importOnlyReferencedComponents: false, + }) as ComponentBase; + return this.loadComponent(agentspecComponent); + } +} diff --git a/tsagentspec/src/adapters/common/component-policy.ts b/tsagentspec/src/adapters/common/component-policy.ts new file mode 100644 index 00000000..e19d0b63 --- /dev/null +++ b/tsagentspec/src/adapters/common/component-policy.ts @@ -0,0 +1,262 @@ +/** + * Component allow/block policy helpers used while loading Agent Spec + * configurations. Port of `pyagentspec.serialization.componentpolicy`. + * + * The TypeScript SDK has no Component class hierarchy, so policy entries are + * componentType strings: concrete component type names (exact match, + * distance 0), the SDK's `AbstractComponentType` group names (group match, + * distance 1), or the `"Component"`/`"ComponentWithIO"` wildcards + * (distance 2). When allow and block entries both match, the closest match + * wins; block entries win same-distance ties. Names that resolve to neither a + * known concrete type nor a group match only that exact serialized + * componentType (distance 0), like unresolved names in Python. + */ +import type { ComponentBase } from "../../component.js"; +import { getChildrenFromFieldValue } from "../../serialization/referencing.js"; +import { OPAQUE_FIELDS } from "../../serialization/types.js"; + +/** A single policy entry: a concrete or abstract componentType name. */ +export type ComponentPolicyEntry = string; + +/** Policy input: a single componentType name or an iterable of names. */ +export type ComponentPolicyInput = + | ComponentPolicyEntry + | Iterable; + +const CONCRETE_MATCH_DISTANCE = 0; +const ABSTRACT_GROUP_MATCH_DISTANCE = 1; +const WILDCARD_MATCH_DISTANCE = 2; + +// Concrete members of each AgenticComponentUnion entry (src/agents/index.ts). +const AGENTIC_COMPONENT_TYPES = [ + "Agent", + "Swarm", + "ManagerWorkers", + "RemoteAgent", + "A2AAgent", + "SpecializedAgent", +]; + +// Concrete members of NodeUnion (src/flows/nodes/index.ts). +const NODE_TYPES = [ + "StartNode", + "EndNode", + "LlmNode", + "ToolNode", + "AgentNode", + "FlowNode", + "BranchingNode", + "MapNode", + "ParallelMapNode", + "ParallelFlowNode", + "ApiNode", + "InputMessageNode", + "OutputMessageNode", + "CatchExceptionNode", +]; + +// Concrete members of ToolUnion (src/tools/index.ts). +const TOOL_TYPES = ["ServerTool", "ClientTool", "RemoteTool", "BuiltinTool", "MCPTool"]; + +/** + * Membership of the SDK's abstract component groups, keyed by + * `AbstractComponentType` name (src/component.ts). Hardcoded from the SDK's + * discriminated unions: + * - AgenticComponentUnion (src/agents/index.ts) + * - NodeUnion (src/flows/nodes/index.ts) + * - ToolUnion (src/tools/index.ts) + * - LlmConfigUnion (src/llms/index.ts) + * - ToolBoxUnion (src/tools/toolbox.ts) + * - OciClientConfigUnion (src/llms/oci-client-config.ts) + * - ClientTransportUnion (src/mcp/client-transport.ts) + * - SupportedDatastoresSchema (src/transforms/message-transform.ts) + * - MessageTransformUnion (src/transforms/message-transform.ts) + */ +const ABSTRACT_COMPONENT_GROUPS: Record> = { + AgenticComponent: new Set(AGENTIC_COMPONENT_TYPES), + Node: new Set(NODE_TYPES), + Tool: new Set(TOOL_TYPES), + LlmConfig: new Set([ + "OpenAiCompatibleConfig", + "OllamaConfig", + "VllmConfig", + "OpenAiConfig", + "OciGenAiConfig", + ]), + ToolBox: new Set(["MCPToolBox"]), + OciClientConfig: new Set([ + "OciClientConfigWithApiKey", + "OciClientConfigWithInstancePrincipal", + "OciClientConfigWithResourcePrincipal", + "OciClientConfigWithSecurityToken", + ]), + ClientTransport: new Set([ + "StdioTransport", + "SSETransport", + "SSEmTLSTransport", + "StreamableHTTPTransport", + "StreamableHTTPmTLSTransport", + "RemoteTransport", + ]), + Datastore: new Set([ + "InMemoryCollectionDatastore", + "OracleDatabaseDatastore", + "PostgresDatabaseDatastore", + ]), + MessageTransform: new Set([ + "MessageSummarizationTransform", + "ConversationSummarizationTransform", + ]), +}; + +/** + * Every builtin componentType extending ComponentWithIOSchema (schemas built + * on ComponentWithIOSchema / ToolBaseSchema / NodeBaseSchema across src/). + */ +const COMPONENT_WITH_IO_TYPES: ReadonlySet = new Set([ + ...AGENTIC_COMPONENT_TYPES, + ...NODE_TYPES, + ...TOOL_TYPES, + "MCPToolSpec", + "Flow", + "AgentSpecializationParameters", +]); + +function normalizeComponentTypes( + componentTypes: ComponentPolicyInput | undefined, +): string[] | undefined { + if (componentTypes === undefined) { + return undefined; + } + const entries: unknown[] = + typeof componentTypes === "string" + ? [componentTypes] + : typeof (componentTypes as Iterable)[Symbol.iterator] === + "function" + ? [...componentTypes] + : (() => { + throw new Error( + "`allowed_components` and `blocked_components` entries must be component " + + `type names or Component classes, got ${String(componentTypes)}.`, + ); + })(); + const normalizedComponentTypes: string[] = []; + for (const componentType of entries) { + if (typeof componentType !== "string") { + throw new Error( + "`allowed_components` and `blocked_components` entries must be component " + + `type names or Component classes, got ${String(componentType)}.`, + ); + } + normalizedComponentTypes.push(componentType); + } + return normalizedComponentTypes; +} + +/** Return the distance of the most specific matching policy entry. */ +function getBestPolicyMatchDistance( + componentType: string, + policyEntries: readonly string[], +): number | undefined { + let bestDistance: number | undefined; + for (const entry of policyEntries) { + let distance: number | undefined; + if (entry === componentType) { + distance = CONCRETE_MATCH_DISTANCE; + } else if (entry === "Component") { + // Matches every component, including unknown plugin-defined types. + distance = WILDCARD_MATCH_DISTANCE; + } else if (entry === "ComponentWithIO") { + distance = COMPONENT_WITH_IO_TYPES.has(componentType) + ? WILDCARD_MATCH_DISTANCE + : undefined; + } else if (ABSTRACT_COMPONENT_GROUPS[entry]?.has(componentType)) { + distance = ABSTRACT_GROUP_MATCH_DISTANCE; + } + if (distance !== undefined && (bestDistance === undefined || distance < bestDistance)) { + bestDistance = distance; + } + } + return bestDistance; +} + +/** + * Allow/block policy for component types loaded from Agent Spec + * configurations. + * + * If no allow list is given, all component types are allowed unless a + * matching block entry applies; with an allow list, only matching component + * types are allowed. When both allow and block entries match, the closest + * hierarchy match wins and block entries win same-distance ties. + */ +export class ComponentLoadPolicy { + /** Normalized allow-list entries, or undefined when no allow list is set. */ + readonly allowedComponents?: readonly string[]; + /** Normalized block-list entries (empty when none). */ + readonly blockedComponents: readonly string[]; + + constructor( + allowedComponents?: ComponentPolicyInput, + blockedComponents?: ComponentPolicyInput, + ) { + this.allowedComponents = normalizeComponentTypes(allowedComponents); + this.blockedComponents = normalizeComponentTypes(blockedComponents) ?? []; + } + + /** Raise if the component type is disallowed by the policy. */ + validateComponentType(componentType: string): void { + const blockedMatchDistance = getBestPolicyMatchDistance( + componentType, + this.blockedComponents, + ); + const allowedMatchDistance = + this.allowedComponents !== undefined + ? getBestPolicyMatchDistance(componentType, this.allowedComponents) + : undefined; + + if ( + blockedMatchDistance !== undefined && + (allowedMatchDistance === undefined || + blockedMatchDistance <= allowedMatchDistance) + ) { + throw new Error( + `Loading Agent Spec component type \`${componentType}\` is in the block list.`, + ); + } + if (this.allowedComponents !== undefined && allowedMatchDistance === undefined) { + throw new Error( + `Loading Agent Spec component type \`${componentType}\` is not in the allow list.`, + ); + } + } + + /** Raise if the component is disallowed by the policy. */ + validateComponent(component: ComponentBase): void { + this.validateComponentType(component.componentType); + } + + /** Validate a constructed component and all nested child components. */ + validateComponentTree(component: ComponentBase): void { + const componentsToCheck: ComponentBase[] = [component]; + const visitedComponents = new Set(); + while (componentsToCheck.length > 0) { + const currentComponent = componentsToCheck.pop()!; + if (visitedComponents.has(currentComponent)) { + continue; + } + visitedComponents.add(currentComponent); + + this.validateComponent(currentComponent); + const fields = currentComponent as unknown as Record; + for (const [fieldName, fieldValue] of Object.entries(fields)) { + if (fieldName === "id" || fieldName === "componentType") continue; + // Opaque fields hold user-controlled data; the structural isComponent + // check would false-positive on plain dicts there (Python's + // isinstance check cannot, since deserialized opaque data never + // becomes Component instances). + if (OPAQUE_FIELDS.has(fieldName)) continue; + componentsToCheck.push(...getChildrenFromFieldValue(fieldValue)); + } + } + } +} diff --git a/tsagentspec/src/adapters/common/converters.ts b/tsagentspec/src/adapters/common/converters.ts new file mode 100644 index 00000000..0ebb1b46 --- /dev/null +++ b/tsagentspec/src/adapters/common/converters.ts @@ -0,0 +1,35 @@ +/** + * Converter interfaces shared by the adapter loaders and exporters. Port of + * the converter Protocols in `pyagentspec.adapters._agentspecloader`. + */ +import type { ComponentBase } from "../../component.js"; + +/** + * Adapter-specific AgentSpec -> runtime converter used by loaders. + * + * Conversion is async in the TypeScript adapters (dynamic imports, MCP + * loading); `options` carries adapter-specific parameters such as + * checkpointers or conversion caches. + */ +export interface AgentSpecToRuntimeConverter< + TOptions = Record, +> { + convert( + agentspecComponent: ComponentBase, + toolRegistry: Record, + options?: TOptions, + ): Promise; +} + +/** + * Adapter-specific runtime -> AgentSpec converter used by loaders and + * exporters. `referencedObjects` memoizes converted components by runtime + * object reference so shared runtime objects become single referenced + * components in the output. + */ +export interface RuntimeToAgentSpecConverter { + convert( + runtimeComponent: unknown, + referencedObjects?: Map, + ): ComponentBase; +} diff --git a/tsagentspec/src/adapters/common/index.ts b/tsagentspec/src/adapters/common/index.ts new file mode 100644 index 00000000..365ff3c9 --- /dev/null +++ b/tsagentspec/src/adapters/common/index.ts @@ -0,0 +1,49 @@ +/** + * Shared adapter common layer barrel (internal use; not a package export). + * + * Framework-agnostic building blocks for AgentSpec adapters: template + * rendering, URL validation, the component load policy, JSON-schema helpers, + * RemoteTool execution, converter interfaces, and the loader/exporter base + * classes. + */ +export { + renderTemplate, + renderNestedObjectTemplate, + stringifyTemplateValue, +} from "./templating.js"; +export { + getUrlMatchParts, + matchesAllowListEntry, + getUrlDestinationPlaceholderNames, + maybeWarnAboutUnrestrictedTemplatedUrl, + validateUrlAgainstAllowList, +} from "./url-validation.js"; +export { + ComponentLoadPolicy, + type ComponentPolicyEntry, + type ComponentPolicyInput, +} from "./component-policy.js"; +export { + jsonSchemasHaveSameType, + buildJsonSchemaFromProperties, +} from "./json-schema.js"; +export { + DEFAULT_HTTP_REQUEST_TIMEOUT_MS, + createRemoteToolFunc, + fetchWithAdapterDefaults, +} from "./tools-common.js"; +export type { + AgentSpecToRuntimeConverter, + RuntimeToAgentSpecConverter, +} from "./converters.js"; +export { + AdapterAgnosticAgentSpecLoader, + type AdapterAgnosticAgentSpecLoaderOptions, + type LoadOptions, +} from "./agentspec-loader.js"; +export { + AdapterAgnosticAgentSpecExporter, + type ExportOptions, + type ExportedDict, + type RuntimeDisaggregatedComponentsConfig, +} from "./agentspec-exporter.js"; diff --git a/tsagentspec/src/adapters/common/json-schema.ts b/tsagentspec/src/adapters/common/json-schema.ts new file mode 100644 index 00000000..d01238be --- /dev/null +++ b/tsagentspec/src/adapters/common/json-schema.ts @@ -0,0 +1,202 @@ +/** + * JSON-schema helpers shared by the AgentSpec adapters. + * + * `jsonSchemasHaveSameType` ports `pyagentspec.property.json_schemas_have_same_type`; + * `buildJsonSchemaFromProperties` builds an object schema from AgentSpec + * properties, suitable as a LangChain tool argument schema. + */ +import type { JsonSchemaValue, Property } from "../../property.js"; + +const MAX_JSON_SCHEMA_UNION_TYPE_ALLOWED_LENGTH = 100; + +/** + * Normalization merges the basic types and anyOf for a schema and returns a + * list containing all the schemas. + */ +function normalizeJsonSchemaUnionTypes( + schema: JsonSchemaValue, +): JsonSchemaValue[] { + const jsonSchemaType = schema["type"] ?? []; + const jsonSchemaTypes: unknown[] = Array.isArray(jsonSchemaType) + ? jsonSchemaType + : [jsonSchemaType]; + + const allTypes: JsonSchemaValue[] = [ + ...((schema["anyOf"] as JsonSchemaValue[] | undefined) ?? []), + ]; + for (const type of jsonSchemaTypes) { + if (type === "array") { + // If one of the basic types is array, we put the items definition in it + allTypes.push({ type: "array", items: schema["items"] ?? {} }); + } else if (type === "object") { + // If one of the basic types is object, we put the properties definition in it + allTypes.push({ + type: "object", + properties: schema["properties"] ?? {}, + additionalProperties: schema["additionalProperties"] ?? false, + }); + } else { + // Normally we just carry over the basic type + allTypes.push({ type }); + } + } + + if (allTypes.length > MAX_JSON_SCHEMA_UNION_TYPE_ALLOWED_LENGTH) { + throw new Error( + `The schema is the union of more than ${MAX_JSON_SCHEMA_UNION_TYPE_ALLOWED_LENGTH}` + + " types. This is not supported. Please consider simplifying the type definition or" + + " using 'Any'.", + ); + } + return allTypes; +} + +/** Check if the two schemas define the same type. */ +export function jsonSchemasHaveSameType( + jsonSchemaA: JsonSchemaValue, + jsonSchemaB: JsonSchemaValue, +): boolean { + if ("allOf" in jsonSchemaA || "allOf" in jsonSchemaB) { + throw new Error("Support for schemas using allOf is not implemented."); + } + if ("oneOf" in jsonSchemaA || "oneOf" in jsonSchemaB) { + throw new Error("Support for schemas using oneOf is not implemented."); + } + + // Basic types must match + if ( + "anyOf" in jsonSchemaA || + Array.isArray(jsonSchemaA["type"]) || + "anyOf" in jsonSchemaB || + Array.isArray(jsonSchemaB["type"]) + ) { + // We need to combine anyOf and the list of types specified in type. + // We normalize them to other json schemas, so that we can compare them + // afterward using this method. + const aTypeList = normalizeJsonSchemaUnionTypes(jsonSchemaA); + const bTypeList = normalizeJsonSchemaUnionTypes(jsonSchemaB); + // We make sure that the sets of possible types overlap correctly (same + // elements). We cannot check the length directly, as the same type could + // be repeated. + for (const aType of aTypeList) { + if (!bTypeList.some((bType) => jsonSchemasHaveSameType(aType, bType))) { + return false; + } + } + for (const bType of bTypeList) { + if (!aTypeList.some((aType) => jsonSchemasHaveSameType(aType, bType))) { + return false; + } + } + // We flattened everything in the anyOf, so no need to go on with the checks + return true; + } + if (jsonSchemaA["type"] !== jsonSchemaB["type"]) { + return false; + } + + // If it's an array, the items type must match + if ("items" in jsonSchemaA || "items" in jsonSchemaB) { + if ( + !jsonSchemasHaveSameType( + (jsonSchemaA["items"] as JsonSchemaValue | undefined) ?? {}, + (jsonSchemaB["items"] as JsonSchemaValue | undefined) ?? {}, + ) + ) { + return false; + } + } + + // If it's an object, the set of properties must match, and their types must match too + if ("properties" in jsonSchemaA || "properties" in jsonSchemaB) { + const aProperties = (jsonSchemaA["properties"] ?? {}) as Record< + string, + JsonSchemaValue + >; + const bProperties = (jsonSchemaB["properties"] ?? {}) as Record< + string, + JsonSchemaValue + >; + const aKeys = Object.keys(aProperties).sort(); + const bKeys = Object.keys(bProperties).sort(); + if ( + aKeys.length !== bKeys.length || + aKeys.some((key, index) => key !== bKeys[index]) + ) { + return false; + } + for (const propertyName of aKeys) { + if ( + !jsonSchemasHaveSameType( + aProperties[propertyName]!, + bProperties[propertyName]!, + ) + ) { + return false; + } + } + } + + if ( + "additionalProperties" in jsonSchemaA || + "additionalProperties" in jsonSchemaB + ) { + const aAdditionalProperties = jsonSchemaA["additionalProperties"] ?? {}; + const bAdditionalProperties = jsonSchemaB["additionalProperties"] ?? {}; + // If any of the two additional properties is a boolean, check strict equality + if ( + typeof aAdditionalProperties === "boolean" || + typeof bAdditionalProperties === "boolean" + ) { + return aAdditionalProperties === bAdditionalProperties; + } + if ( + !jsonSchemasHaveSameType( + aAdditionalProperties as JsonSchemaValue, + bAdditionalProperties as JsonSchemaValue, + ) + ) { + return false; + } + } + return true; +} + +/** + * Build an object JSON schema from AgentSpec properties, suitable as a + * LangChain tool argument schema. Each property contributes its own + * `jsonSchema` (with its default included when set); properties without a + * default are listed as required. + */ +export function buildJsonSchemaFromProperties( + name: string, + properties: Property[], +): JsonSchemaValue { + const schemaProperties: Record = {}; + const required: string[] = []; + for (const property of properties) { + const propertySchema: JsonSchemaValue = { ...property.jsonSchema }; + if ( + property.description !== undefined && + propertySchema["description"] === undefined + ) { + propertySchema["description"] = property.description; + } + if (property.default !== undefined) { + propertySchema["default"] = property.default; + } else { + required.push(property.title); + } + schemaProperties[property.title] = propertySchema; + } + + const schema: JsonSchemaValue = { + title: name, + type: "object", + properties: schemaProperties, + }; + if (required.length > 0) { + schema["required"] = required; + } + return schema; +} diff --git a/tsagentspec/src/adapters/common/templating.ts b/tsagentspec/src/adapters/common/templating.ts new file mode 100644 index 00000000..daf83529 --- /dev/null +++ b/tsagentspec/src/adapters/common/templating.ts @@ -0,0 +1,104 @@ +/** + * Template rendering helpers shared by the AgentSpec adapters. + * + * Mirrors `pyagentspec.adapters._utils.render_template` and + * `render_nested_object_template`: `{{placeholder}}` occurrences whose names + * appear in `inputs` are substituted, unknown placeholders are left verbatim. + * + * Divergence from Python (see the adapter README): Python renders values with + * `str()`; TypeScript uses `String()` for primitives and `JSON.stringify` for + * objects/arrays. + */ +import { TEMPLATE_PLACEHOLDER_REGEXP } from "../../templating.js"; + +/** Render a value for insertion into a template string. */ +export function stringifyTemplateValue(value: unknown): string { + if (typeof value === "object" && value !== null) { + return JSON.stringify(value); + } + return String(value); +} + +function isPlainObject(value: unknown): value is Record { + if (typeof value !== "object" || value === null) return false; + const prototype: unknown = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +/** + * Render a template string using the given inputs. + * + * Placeholders whose names are keys of `inputs` are replaced with the + * stringified value; unknown placeholders stay verbatim. Non-string templates + * are stringified and returned unchanged otherwise. + */ +export function renderTemplate( + template: unknown, + inputs: Record, +): string { + if (typeof template !== "string") { + return stringifyTemplateValue(template); + } + // Fresh regexp instance: the shared exported one is global and stateful. + const re = new RegExp(TEMPLATE_PLACEHOLDER_REGEXP.source, "g"); + const renderedParts: string[] = []; + let lastEnd = 0; + let match: RegExpExecArray | null; + while ((match = re.exec(template)) !== null) { + renderedParts.push(template.slice(lastEnd, match.index)); + // Original placeholder text as it appeared in the template, including + // braces and inner whitespace. + const fullPlaceholder = match[0]; + // Only the placeholder name, extracted from inside {{ ... }}. + const inputTitle = match[1]; + if (inputTitle !== undefined && Object.hasOwn(inputs, inputTitle)) { + renderedParts.push(stringifyTemplateValue(inputs[inputTitle])); + } else { + renderedParts.push(fullPlaceholder); + } + lastEnd = match.index + fullPlaceholder.length; + } + renderedParts.push(template.slice(lastEnd)); + return renderedParts.join(""); +} + +/** + * Recursively render `{{placeholder}}` templates inside an arbitrarily nested + * structure of strings, byte arrays, plain objects, arrays and sets. Object + * keys and values are both rendered; any other value is returned untouched. + */ +export function renderNestedObjectTemplate( + object: unknown, + inputs: Record, +): unknown { + if (typeof object === "string") { + return renderTemplate(object, inputs); + } + if (object instanceof Uint8Array) { + // Python decodes bytes as UTF-8 with errors="replace"; the non-fatal + // TextDecoder replaces invalid sequences the same way. + return renderNestedObjectTemplate( + new TextDecoder("utf-8", { fatal: false }).decode(object), + inputs, + ); + } + if (Array.isArray(object)) { + return object.map((item) => renderNestedObjectTemplate(item, inputs)); + } + if (object instanceof Set) { + return new Set( + [...object].map((item) => renderNestedObjectTemplate(item, inputs)), + ); + } + if (isPlainObject(object)) { + const rendered: Record = {}; + for (const [key, value] of Object.entries(object)) { + rendered[renderTemplate(key, inputs)] = renderNestedObjectTemplate( + value, + inputs, + ); + } + return rendered; + } + return object; +} diff --git a/tsagentspec/src/adapters/common/tools-common.ts b/tsagentspec/src/adapters/common/tools-common.ts new file mode 100644 index 00000000..13e0fdbd --- /dev/null +++ b/tsagentspec/src/adapters/common/tools-common.ts @@ -0,0 +1,213 @@ +/** + * Shared RemoteTool execution helper. Port of + * `pyagentspec.adapters._tools_common._create_remote_tool_func`. + * + * Divergences from Python (see the adapter README): + * - The TS SDK RemoteTool has no `retryPolicy`, so a single fetch attempt is + * performed (no retry/jitter/Retry-After machinery). Like Python without a + * retry policy, the response body is parsed and returned regardless of the + * HTTP status. + * - The TS SDK RemoteTool has no `urlAllowList` field, so the allow-list + * helpers are invoked with `undefined` (i.e. allow) and the templated-URL + * warning fires per the Python rules. + * - `fetch` forbids request bodies on GET/HEAD, so no body is sent for those + * methods. + * + * Python-parity network behavior (NOT divergences): redirects are not + * followed and requests time out after `DEFAULT_HTTP_REQUEST_TIMEOUT_MS`, + * matching httpx's `follow_redirects=False` and 5s-timeout defaults — see + * `fetchWithAdapterDefaults`. + */ +import type { RemoteTool } from "../../tools/remote-tool.js"; +import { + renderNestedObjectTemplate, + renderTemplate, + stringifyTemplateValue, +} from "./templating.js"; +import { + maybeWarnAboutUnrestrictedTemplatedUrl, + validateUrlAgainstAllowList, +} from "./url-validation.js"; + +function isPlainRecord(value: unknown): value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + const prototype: unknown = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +/** + * Default timeout for RemoteTool / ApiNode HTTP requests, in milliseconds. + * + * Mirrors the 5-second default timeout httpx applies to every request made by + * the Python adapter. The TS SDK has no `RetryPolicy.requestTimeout` field yet + * (Python reads a per-tool override from there), so this constant is the only + * knob for the request timeout. + */ +export const DEFAULT_HTTP_REQUEST_TIMEOUT_MS = 5000; + +/** + * Perform one `fetch` with the adapter's Python-parity network behavior: + * + * - Redirects are NOT followed (`redirect: "manual"`): Node's undici then + * returns the 3xx response itself (status/headers/body intact), matching + * httpx's `follow_redirects=False` default. Following redirects on + * untrusted spec config would enable redirect-based egress and forward + * custom auth headers to redirect targets. + * - The request aborts after `DEFAULT_HTTP_REQUEST_TIMEOUT_MS` (httpx's + * default timeout); the abort is rethrown as an Error naming the requester + * and the timeout. + */ +export async function fetchWithAdapterDefaults( + url: string, + init: RequestInit, + requesterDescription: string, +): Promise { + try { + return await fetch(url, { + ...init, + redirect: "manual", + signal: AbortSignal.timeout(DEFAULT_HTTP_REQUEST_TIMEOUT_MS), + }); + } catch (error) { + // AbortSignal.timeout aborts with a DOMException named "TimeoutError". + if ( + typeof error === "object" && + error !== null && + (error as { name?: unknown }).name === "TimeoutError" + ) { + throw new Error( + `${requesterDescription} HTTP request timed out after ` + + `${DEFAULT_HTTP_REQUEST_TIMEOUT_MS}ms.`, + ); + } + throw error; + } +} + +function renderRecord( + record: Record, + kwargs: Record, +): Record { + const rendered: Record = {}; + for (const [key, value] of Object.entries(record)) { + rendered[renderTemplate(key, kwargs)] = renderNestedObjectTemplate( + value, + kwargs, + ); + } + return rendered; +} + +/** + * Create the execution function for an AgentSpec RemoteTool. + * + * The returned function renders `{{placeholder}}` templates in the URL, data, + * headers and query parameters using the call kwargs, validates the rendered + * URL, performs a single `fetch`, and returns the parsed JSON response body. + * + * Note: `requiresConfirmation` wrapping is applied by the framework-specific + * adapter layer (e.g. the LangGraph adapter), not here. + */ +export function createRemoteToolFunc( + remoteTool: RemoteTool, +): (kwargs: Record) => Promise { + maybeWarnAboutUnrestrictedTemplatedUrl( + remoteTool.url, + undefined, + `RemoteTool \`${remoteTool.name}\``, + ); + + return async function remoteToolFunc( + kwargs: Record, + ): Promise { + const remoteToolData = renderNestedObjectTemplate(remoteTool.data, kwargs); + const remoteToolHeaders = renderRecord(remoteTool.headers, kwargs); + const remoteToolQueryParams = renderRecord(remoteTool.queryParams, kwargs); + const remoteToolUrl = renderTemplate(remoteTool.url, kwargs); + + const contentTypeHeader = + remoteToolHeaders["Content-Type"] || remoteToolHeaders["content-type"]; + const expectUrlencodedFormData = + typeof contentTypeHeader === "string" && + contentTypeHeader.includes("application/x-www-form-urlencoded"); + + const requestHeaders: Record = {}; + for (const [key, value] of Object.entries(remoteToolHeaders)) { + requestHeaders[key] = + typeof value === "string" ? value : stringifyTemplateValue(value); + } + const callerSetContentType = Object.keys(requestHeaders).some( + (key) => key.toLowerCase() === "content-type", + ); + + const method = remoteTool.httpMethod; + const methodUpper = method.toUpperCase(); + const methodAllowsBody = methodUpper !== "GET" && methodUpper !== "HEAD"; + + let body: string | URLSearchParams | Uint8Array | undefined; + if (methodAllowsBody) { + if (expectUrlencodedFormData && isPlainRecord(remoteToolData)) { + const form = new URLSearchParams(); + for (const [key, value] of Object.entries(remoteToolData)) { + form.append( + key, + typeof value === "string" ? value : stringifyTemplateValue(value), + ); + } + body = form; + } else if (typeof remoteToolData === "string") { + body = remoteToolData; + } else if (remoteToolData instanceof Uint8Array) { + body = remoteToolData; + } else if (remoteToolData !== undefined && remoteToolData !== null) { + body = JSON.stringify(remoteToolData); + if (!callerSetContentType) { + requestHeaders["Content-Type"] = "application/json"; + } + } + } + + // Kept as the seam for allow-list enforcement: the TS SDK RemoteTool has + // no urlAllowList field yet, so this always allows. + validateUrlAgainstAllowList(remoteToolUrl, undefined); + + const searchParams = new URLSearchParams(); + for (const [key, value] of Object.entries(remoteToolQueryParams)) { + if (Array.isArray(value)) { + for (const item of value) { + searchParams.append( + key, + item == null ? "" : stringifyTemplateValue(item), + ); + } + } else { + searchParams.append( + key, + value == null ? "" : stringifyTemplateValue(value), + ); + } + } + const query = searchParams.toString(); + const requestUrl = + query.length > 0 + ? `${remoteToolUrl}${remoteToolUrl.includes("?") ? "&" : "?"}${query}` + : remoteToolUrl; + + const response = await fetchWithAdapterDefaults( + requestUrl, + { + method, + headers: requestHeaders, + ...(body !== undefined ? { body } : {}), + }, + `RemoteTool \`${remoteTool.name}\``, + ); + // Python (with no retry policy — the only state the TS RemoteTool can + // express) parses and returns the JSON body for every status, so error + // responses flow back to the agent as the tool result. Redirects are not + // followed (see fetchWithAdapterDefaults), so a 3xx body parses here too. + return (await response.json()) as unknown; + }; +} diff --git a/tsagentspec/src/adapters/common/url-validation.ts b/tsagentspec/src/adapters/common/url-validation.ts new file mode 100644 index 00000000..a02a9617 --- /dev/null +++ b/tsagentspec/src/adapters/common/url-validation.ts @@ -0,0 +1,125 @@ +/** + * Helpers for URL validation and optional allow-list handling in HTTP-based + * components. Port of `pyagentspec.adapters._url_validation`. + */ +import { getPlaceholdersFromString } from "../../templating.js"; + +/** + * Return the URL parts used for allow-list matching: scheme, netloc + * (userinfo + host + port) and path (defaulting to "/"). + * + * Matching intentionally considers only scheme, authority, and path. Query + * parameters, URL params, and fragments are ignored. + * + * Python normalizes via pydantic `AnyUrl`; here the WHATWG `URL` parser is + * used. Behavioral difference: WHATWG drops explicit default ports + * (`http://x:80` -> host `x`) while `AnyUrl` keeps them, so matching is + * slightly more lenient here when a URL or pattern spells out the scheme's + * default port. + */ +export function getUrlMatchParts( + url: string, +): [scheme: string, netloc: string, path: string] { + const parsed = new URL(url); + const scheme = parsed.protocol.endsWith(":") + ? parsed.protocol.slice(0, -1) + : parsed.protocol; + const userinfo = + parsed.username !== "" || parsed.password !== "" + ? `${parsed.username}${parsed.password !== "" ? `:${parsed.password}` : ""}@` + : ""; + const netloc = `${userinfo}${parsed.host}`; + const path = parsed.pathname !== "" ? parsed.pathname : "/"; + return [scheme, netloc, path]; +} + +/** Check whether a URL matches one allow-list entry. */ +export function matchesAllowListEntry(url: string, pattern: string): boolean { + const [urlScheme, urlNetloc, urlPath] = getUrlMatchParts(url); + const [patternScheme, patternNetloc, patternPath] = getUrlMatchParts(pattern); + return ( + urlScheme === patternScheme && + urlNetloc === patternNetloc && + urlPath.startsWith(patternPath) + ); +} + +/** + * Return placeholders used in the URL destination part. + * + * The destination is limited to the scheme, host, and port. Placeholders + * appearing only in path, query, or fragment are ignored. + */ +export function getUrlDestinationPlaceholderNames(url: string): string[] { + const schemeSeparator = url.indexOf("://"); + let schemePart: string; + let remainder: string; + if (schemeSeparator !== -1) { + schemePart = url.slice(0, schemeSeparator); + remainder = url.slice(schemeSeparator + 3); + } else { + schemePart = ""; + remainder = url; + } + + const authorityEndPositions = [ + remainder.indexOf("/"), + remainder.indexOf("?"), + remainder.indexOf("#"), + ].filter((pos) => pos !== -1); + const authorityEnd = + authorityEndPositions.length > 0 + ? Math.min(...authorityEndPositions) + : remainder.length; + const authority = remainder.slice(0, authorityEnd); + const atIndex = authority.lastIndexOf("@"); + const hostport = atIndex !== -1 ? authority.slice(atIndex + 1) : authority; + return [ + ...new Set([ + ...getPlaceholdersFromString(schemePart), + ...getPlaceholdersFromString(hostport), + ]), + ].sort(); +} + +/** Warn when a templated URL destination is used without an allow list. */ +export function maybeWarnAboutUnrestrictedTemplatedUrl( + url: string, + urlAllowList: string[] | undefined, + componentName: string, +): void { + if (urlAllowList !== undefined) { + return; + } + + const placeholderNames = getUrlDestinationPlaceholderNames(url); + if (placeholderNames.length === 0) { + return; + } + + const variableList = placeholderNames.map((name) => `\`${name}\``).join(", "); + console.warn( + `${componentName} uses placeholders in the URL destination (${variableList}) ` + + "but no `url_allow_list` is configured. Keep the base URL developer-controlled and " + + "template only path, query, or body values when possible.", + ); +} + +/** Validate a URL against an optional allow list. */ +export function validateUrlAgainstAllowList( + url: string, + urlAllowList: string[] | undefined, +): void { + if (urlAllowList === undefined) { + return; + } + + if (urlAllowList.some((pattern) => matchesAllowListEntry(url, pattern))) { + return; + } + + throw new Error( + "Requested URL is not in allowed list. " + + "Please contact the application administrator to help adding your URL to the list.", + ); +} diff --git a/tsagentspec/src/adapters/langgraph/agentspec-converter-flow.ts b/tsagentspec/src/adapters/langgraph/agentspec-converter-flow.ts new file mode 100644 index 00000000..d4311e89 --- /dev/null +++ b/tsagentspec/src/adapters/langgraph/agentspec-converter-flow.ts @@ -0,0 +1,595 @@ +/** + * Generic LangGraph graph -> Agent Spec Flow conversion. + * + * Port of `pyagentspec.adapters.langgraph._agentspec_converter_flow`: every + * LangGraph node becomes a ToolNode wrapping a synthetic ServerTool (or a + * FlowNode for compiled-subgraph nodes), plain edges become control+data + * edges over a single `state` property, conditional edges expand into a + * conditional ToolNode plus a BranchingNode, and synthetic Start/End nodes + * plus fall-through END edges complete the flow. + * + * Divergence from Python (see the adapter README): Python derives the + * `state` property schemas from the pydantic/TypedDict state classes; the JS + * builders expose channel maps instead, so the schemas list the channel keys + * as untyped properties. + */ +import type { ComponentBase } from "../../component.js"; +import type { ControlFlowEdge, DataFlowEdge, Flow } from "../../flows/index.js"; +import { + DEFAULT_BRANCH, + DEFAULT_INPUT, + createBranchingNode, + createControlFlowEdge, + createDataFlowEdge, + createEndNode, + createFlow, + createFlowNode, + createStartNode, + createToolNode, +} from "../../flows/index.js"; +import type { ComponentWithIO } from "../../component.js"; +import type { JsonSchemaValue, Property } from "../../property.js"; +import { + propertyFromJsonSchema, + stringProperty, + unionProperty, +} from "../../property.js"; +import { createServerTool } from "../../tools/index.js"; + +const START = "__start__"; +const END = "__end__"; + +/** The converter surface needed for subgraph recursion (avoids a cycle). */ +export interface GraphConverterLike { + convert( + runtimeComponent: unknown, + referencedObjects?: Map, + ): ComponentBase; +} + +/** Runtime shape of one LangGraph builder node spec. */ +interface NodeSpecLike { + runnable?: unknown; + input?: unknown; +} + +/** Runtime shape of one LangGraph conditional-edge branch. */ +interface BranchLike { + path?: unknown; + ends?: Record; +} + +/** Runtime shape of a LangGraph StateGraph builder. */ +interface BuilderLike { + nodes: Record; + edges: Iterable<[string, string]>; + branches?: Record>; + channels?: Record; + _schemaDefinition?: unknown; + _inputDefinition?: unknown; + _outputDefinition?: unknown; +} + +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Duck-type check for a compiled LangGraph graph. */ +export function isCompiledGraphLike( + value: unknown, +): value is { builder: BuilderLike; name?: unknown } { + return ( + isPlainRecord(value) && + (value as { lg_is_pregel?: unknown }).lg_is_pregel === true + ); +} + +/** Duck-type check for a StateGraph builder. */ +export function isStateGraphBuilderLike(value: unknown): value is BuilderLike { + if (!isPlainRecord(value)) { + return false; + } + const candidate = value as { + nodes?: unknown; + compile?: unknown; + addNode?: unknown; + }; + return ( + isPlainRecord(candidate.nodes) && + typeof candidate.compile === "function" && + typeof candidate.addNode === "function" + ); +} + +/** Duck-type check for anything convertible to a Flow (builder or compiled). */ +export function isStateGraphLike(value: unknown): boolean { + return isCompiledGraphLike(value) || isStateGraphBuilderLike(value); +} + +/** Normalize a compiled graph or builder to the builder. */ +export function getGraphBuilder(graph: unknown): BuilderLike { + if (isCompiledGraphLike(graph)) { + return graph.builder; + } + return graph as BuilderLike; +} + +/** + * Extract the state-key names of a schema definition: a langgraph channel + * map, an `Annotation.Root` (via `.spec`) or a zod object (via `.shape`). + */ +function definitionKeys(definition: unknown): string[] | undefined { + if (!isPlainRecord(definition)) { + return undefined; + } + const spec = (definition as { spec?: unknown }).spec; + if (isPlainRecord(spec)) { + return Object.keys(spec); + } + const shape = (definition as { shape?: unknown }).shape; + if (isPlainRecord(shape)) { + return Object.keys(shape); + } + return Object.keys(definition); +} + +/** Build the `state` property listing the given state keys. */ +function statePropertyFromKeys(keys: string[] | undefined): Property { + const properties: Record = {}; + for (const key of keys ?? []) { + properties[key] = { title: key }; + } + return propertyFromJsonSchema({ + title: "state", + type: "object", + properties, + }); +} + +function stateSchemaKeys(builder: BuilderLike): string[] | undefined { + return ( + definitionKeys(builder._schemaDefinition) ?? definitionKeys(builder.channels) + ); +} + +function getStateProperty(builder: BuilderLike): Property { + return statePropertyFromKeys(stateSchemaKeys(builder)); +} + +function getInputProperty(builder: BuilderLike): Property { + return statePropertyFromKeys( + definitionKeys(builder._inputDefinition) ?? stateSchemaKeys(builder), + ); +} + +function getOutputProperty(builder: BuilderLike): Property { + return statePropertyFromKeys( + definitionKeys(builder._outputDefinition) ?? stateSchemaKeys(builder), + ); +} + +function getNodeInputProperty( + builder: BuilderLike, + nodeName: string, +): Property { + const spec = builder.nodes[nodeName]; + const inputKeys = definitionKeys(spec?.input); + return statePropertyFromKeys(inputKeys ?? stateSchemaKeys(builder)); +} + +/** Resolve the property describing data flowing towards the target nodes. */ +function resolveOutputProperties( + builder: BuilderLike, + targetNodes: string[], +): Property { + if (targetNodes.length === 0) { + // Nodes without an explicit outgoing edge are routed to END, so the + // property is the output schema of the entire graph. + return getOutputProperty(builder); + } + if (targetNodes.length === 1) { + const nodeName = targetNodes[0]!; + if (nodeName === START) { + return getInputProperty(builder); + } + if (nodeName === END) { + return getOutputProperty(builder); + } + return getNodeInputProperty(builder, nodeName); + } + return unionProperty({ + title: "state", + anyOf: targetNodes.map((nodeName) => + resolveOutputProperties(builder, [nodeName]), + ), + }); +} + +function graphEdges(builder: BuilderLike): [string, string][] { + return [...(builder.edges ?? [])]; +} + +function asNode(component: ComponentBase | undefined): ComponentWithIO { + if (component === undefined) { + throw new Error("Internal error: referenced LangGraph node was not converted"); + } + return component as ComponentWithIO; +} + +/** Reject graphs with several conditional edges on the same source node. */ +function validateConditionalEdgesSupport(builder: BuilderLike): void { + for (const branchSpecs of Object.values(builder.branches ?? {})) { + if (Object.keys(branchSpecs).length > 1) { + throw new Error( + "Conversion of multiple conditional edges with the same source node is not yet supported", + ); + } + } +} + +/** Convert one non-subgraph LangGraph node into an Agent Spec ToolNode. */ +function langgraphNodeConvertToAgentSpec( + builder: BuilderLike, + nodeName: string, + referencedObjects: Map, +): ComponentBase { + const existing = referencedObjects.get(nodeName); + if (existing !== undefined) { + const componentType = (existing as { componentType?: unknown }) + .componentType; + if (typeof componentType !== "string" || !componentType.endsWith("Node")) { + throw new Error( + `expected node ${JSON.stringify(existing)} to be of type Node, got: ${String(componentType)}`, + ); + } + return existing; + } + + const inputProperty = getNodeInputProperty(builder, nodeName); + const targetNodes: string[] = []; + for (const [from, to] of graphEdges(builder)) { + if (from !== to && from === nodeName) { + targetNodes.push(to); + } + } + const outputProperty = resolveOutputProperties(builder, targetNodes); + + const tool = createServerTool({ + name: `${nodeName}_tool`, + inputs: [inputProperty], + outputs: [outputProperty], + }); + const toolNode = createToolNode({ + name: nodeName, + tool, + inputs: [inputProperty], + outputs: [outputProperty], + }); + referencedObjects.set(nodeName, toolNode); + return toolNode; +} + +/** Create (or reuse) the flow's Start and End nodes. */ +function getStartEndNodes( + builder: BuilderLike, + referencedObjects: Map, +): [ComponentBase, ComponentBase] { + if (!referencedObjects.has(START)) { + if (!(START in builder.nodes)) { + referencedObjects.set( + START, + createStartNode({ + name: START, + inputs: [getInputProperty(builder)], + outputs: [getInputProperty(builder)], + }), + ); + } else { + referencedObjects.set( + START, + langgraphNodeConvertToAgentSpec(builder, START, referencedObjects), + ); + } + } + if (!referencedObjects.has(END)) { + if (!(END in builder.nodes)) { + referencedObjects.set( + END, + createEndNode({ + name: END, + inputs: [getOutputProperty(builder)], + outputs: [getOutputProperty(builder)], + }), + ); + } else { + referencedObjects.set( + END, + langgraphNodeConvertToAgentSpec(builder, END, referencedObjects), + ); + } + } + return [referencedObjects.get(START)!, referencedObjects.get(END)!]; +} + +function edgeToControlFlow( + edge: [string, string], + referencedObjects: Map, +): ControlFlowEdge { + const [from, to] = edge; + return createControlFlowEdge({ + name: `${from}_to_${to}`, + fromNode: asNode(referencedObjects.get(from)), + toNode: asNode(referencedObjects.get(to)), + }); +} + +function edgeToDataFlow( + builder: BuilderLike, + edge: [string, string], + referencedObjects: Map, +): DataFlowEdge { + const [from, to] = edge; + const internalStateProperty = + from === START ? getInputProperty(builder) : getStateProperty(builder); + const destinationInputProperty = resolveOutputProperties(builder, [to]); + return createDataFlowEdge({ + name: `${from}_to_${to}_data_edge`, + sourceNode: asNode(referencedObjects.get(from)), + sourceOutput: internalStateProperty.title, + destinationNode: asNode(referencedObjects.get(to)), + destinationInput: destinationInputProperty.title, + }); +} + +/** + * Derive a unique name for a synthetic (conditional / branching) node. + * + * LangGraph JS stores every conditional edge's branch under the fixed default + * key `"condition"`, so a user graph with a real node of that exact name (or + * of a derived synthetic name) would collide: the synthetic node would + * overwrite the real node in the registry and steal its edges. Keep the + * Python-style base name in the common non-colliding case and suffix `_N` + * only when a real or already-registered node claims it. + */ +function uniqueSyntheticNodeName( + baseName: string, + builder: BuilderLike, + referencedObjects: Map, +): string { + let candidate = baseName; + let suffix = 1; + while ( + Object.hasOwn(builder.nodes, candidate) || + referencedObjects.has(candidate) + ) { + candidate = `${baseName}_${suffix}`; + suffix += 1; + } + return candidate; +} + +/** Expand one conditional edge into conditional + branching nodes and edges. */ +function branchConvertToAgentSpec( + sourceNode: string, + branchSpecs: Record, + builder: BuilderLike, + referencedObjects: Map, +): [ComponentBase[], ControlFlowEdge[], DataFlowEdge[]] { + const additionalNodes: ComponentBase[] = []; + const additionalCtrlFlows: ControlFlowEdge[] = []; + const additionalDataFlows: DataFlowEdge[] = []; + + for (const [branchKey, branchSpec] of Object.entries(branchSpecs)) { + const ends = branchSpec.ends; + if (ends === undefined || ends === null) { + throw new Error( + `Mapping for ${branchKey} not found.\n` + + " Make sure to add proper return type hints to the branching function.", + ); + } + const mapping: Record = {}; + for (const [branchName, targetNodeName] of Object.entries(ends)) { + mapping[String(branchName)] = targetNodeName; + } + + // The synthetic node is named after the branch key (Python-style), unless + // a real node claims that name — see uniqueSyntheticNodeName. + const conditionalNodeName = uniqueSyntheticNodeName( + branchKey, + builder, + referencedObjects, + ); + + // Create the conditional node to compute which branch to go to + const conditionalNodeInput = resolveOutputProperties(builder, [sourceNode]); + const conditionalNode = createToolNode({ + name: conditionalNodeName, + tool: createServerTool({ + name: `${conditionalNodeName}_tool`, + inputs: [conditionalNodeInput], + outputs: [stringProperty({ title: DEFAULT_INPUT })], + }), + }); + additionalNodes.push(conditionalNode); + referencedObjects.set(conditionalNodeName, conditionalNode); + + // The source node goes to the conditional node to compute which branch + // to go to. + additionalCtrlFlows.push( + createControlFlowEdge({ + name: `${sourceNode}_to_${conditionalNodeName}`, + fromNode: asNode(referencedObjects.get(sourceNode)), + toNode: conditionalNode, + }), + ); + additionalDataFlows.push( + createDataFlowEdge({ + name: `${sourceNode}_to_${conditionalNodeName}_data_edge`, + sourceNode: asNode(referencedObjects.get(sourceNode)), + sourceOutput: conditionalNodeInput.title, + destinationNode: conditionalNode, + destinationInput: conditionalNodeInput.title, + }), + ); + + // Create the branching node for the current conditional edge + const branchingNodeName = uniqueSyntheticNodeName( + `${conditionalNodeName}_branching_node`, + builder, + referencedObjects, + ); + const branchingNode = createBranchingNode({ + name: branchingNodeName, + mapping, + }); + additionalNodes.push(branchingNode); + referencedObjects.set(branchingNodeName, branchingNode); + + additionalCtrlFlows.push( + createControlFlowEdge({ + name: `${conditionalNodeName}_to_${branchingNodeName}`, + fromNode: conditionalNode, + toNode: branchingNode, + }), + ); + additionalDataFlows.push( + createDataFlowEdge({ + name: `${conditionalNodeName}_to_${branchingNodeName}_data_edge`, + sourceNode: conditionalNode, + sourceOutput: DEFAULT_INPUT, + destinationNode: branchingNode, + destinationInput: DEFAULT_INPUT, + }), + ); + + // For each different target node, create a control flow edge that goes + // from the branching node to the target node if from_branch == branch. + for (const [branchName, targetNodeName] of Object.entries(mapping)) { + additionalCtrlFlows.push( + createControlFlowEdge({ + name: `${branchingNodeName}_to_${targetNodeName}`, + fromNode: branchingNode, + toNode: asNode(referencedObjects.get(targetNodeName)), + fromBranch: branchName, + }), + ); + additionalDataFlows.push( + createDataFlowEdge({ + name: `data_${sourceNode}_to_${targetNodeName}`, + sourceNode: asNode(referencedObjects.get(sourceNode)), + sourceOutput: resolveOutputProperties(builder, [targetNodeName]) + .title, + destinationNode: asNode(referencedObjects.get(targetNodeName)), + destinationInput: resolveOutputProperties(builder, [targetNodeName]) + .title, + }), + ); + } + + // Create an edge for the default case, going straight to the end node. + // This should "in practice" never be reached. + const defaultEdgeName = `${branchingNodeName}_to_${END}`; + if (!additionalCtrlFlows.some((flow) => flow.name === defaultEdgeName)) { + additionalCtrlFlows.push( + createControlFlowEdge({ + name: defaultEdgeName, + fromNode: branchingNode, + toNode: asNode(referencedObjects.get(END)), + fromBranch: DEFAULT_BRANCH, + }), + ); + } + } + + return [additionalNodes, additionalCtrlFlows, additionalDataFlows]; +} + +/** + * Convert a LangGraph StateGraph (builder or compiled) into an Agent Spec + * Flow of synthetic ToolNodes / FlowNodes plus Start/End nodes and edges. + */ +export function langgraphGraphConvertToAgentSpec( + converter: GraphConverterLike, + graph: unknown, + referencedObjects: Map, +): Flow { + validateConditionalEdgesSupport(getGraphBuilder(graph)); + const flowName = isCompiledGraphLike(graph) + ? String(graph.name ?? "LangGraph") + : "LangGraph Flow"; + const builder = getGraphBuilder(graph); + + const nodes: ComponentBase[] = []; + for (const [nodeName, nodeSpec] of Object.entries(builder.nodes)) { + if (nodeName === START || nodeName === END) { + continue; + } + const runnable = nodeSpec.runnable; + if (isCompiledGraphLike(runnable) || isStateGraphBuilderLike(runnable)) { + // Subgraph nodes convert recursively with a fresh registry, matching + // Python. + const subflow = converter.convert(runnable, new Map()) as Flow; + const flowNode = createFlowNode({ + name: nodeName, + subflow: subflow as unknown as Record, + }); + referencedObjects.set(nodeName, flowNode); + nodes.push(flowNode); + } else { + nodes.push( + langgraphNodeConvertToAgentSpec(builder, nodeName, referencedObjects), + ); + } + } + + const [startNode, endNode] = getStartEndNodes(builder, referencedObjects); + nodes.push(startNode); + nodes.push(endNode); + + const controlFlowEdges: ControlFlowEdge[] = []; + const dataFlowEdges: DataFlowEdge[] = []; + for (const edge of graphEdges(builder)) { + controlFlowEdges.push(edgeToControlFlow(edge, referencedObjects)); + dataFlowEdges.push(edgeToDataFlow(builder, edge, referencedObjects)); + } + + for (const [sourceNode, branchSpecs] of Object.entries( + builder.branches ?? {}, + )) { + const [additionalNodes, additionalCtrlFlows, additionalDataFlows] = + branchConvertToAgentSpec( + sourceNode, + branchSpecs, + builder, + referencedObjects, + ); + nodes.push(...additionalNodes); + controlFlowEdges.push(...additionalCtrlFlows); + dataFlowEdges.push(...additionalDataFlows); + } + + // Add missing edges towards END for nodes with no outgoing edges. + for (const agentspecNode of nodes) { + const nodeName = (agentspecNode as { name?: unknown }).name; + if (nodeName === START || nodeName === END) { + continue; + } + const hasOutgoing = controlFlowEdges.some( + (ctrlFlow) => + (ctrlFlow.fromNode as { name?: unknown })["name"] === nodeName, + ); + if (!hasOutgoing) { + const edge: [string, string] = [String(nodeName), END]; + controlFlowEdges.push(edgeToControlFlow(edge, referencedObjects)); + dataFlowEdges.push(edgeToDataFlow(builder, edge, referencedObjects)); + } + } + + return createFlow({ + name: flowName, + startNode: startNode as unknown as Record, + nodes: nodes as unknown as Record[], + controlFlowConnections: controlFlowEdges, + dataFlowConnections: dataFlowEdges, + }); +} diff --git a/tsagentspec/src/adapters/langgraph/agentspec-converter.ts b/tsagentspec/src/adapters/langgraph/agentspec-converter.ts new file mode 100644 index 00000000..d3f03c5f --- /dev/null +++ b/tsagentspec/src/adapters/langgraph/agentspec-converter.ts @@ -0,0 +1,421 @@ +/** + * LangGraph -> Agent Spec converter. + * + * Port of `pyagentspec.adapters.langgraph._agentspecconverter + * .LangGraphToAgentSpecConverter`: LangChain structured tools become + * ServerTools, chat models become LLM configs, langchain react agents become + * Agents, and any other StateGraph becomes a Flow. + * + * Divergences from Python (see the adapter README) — Python leans on CPython + * closure introspection that has no JS equivalent: + * - React agents export from the langchain `ReactAgent` instance (its public + * `options` retains model / systemPrompt / tools). A bare compiled agent + * graph does NOT retain them (private fields) and is rejected with a clear + * error instead of Python's closure digging. + * - Swarm graphs are rejected: the compiled per-agent graphs do not retain + * their models/prompts, so a faithful Swarm export is unreachable in JS. + * - MCP tools load as ServerTools: the MCP connection lives in a JS closure + * that cannot be introspected, so Python's MCPTool recovery is skipped. + * - The TS SDK LlmConfigs have no `retryPolicy`, so ChatOpenAI retry/timeout + * settings are not exported. + * - OciGenAiConfig export is not supported (no langchain-oci JS package). + */ +import { BaseChatModel } from "@langchain/core/language_models/chat_models"; +import type { StructuredToolInterface } from "@langchain/core/tools"; +import { isStructuredTool } from "@langchain/core/tools"; +import { toJsonSchema } from "@langchain/core/utils/json_schema"; +import type { Agent } from "../../agents/index.js"; +import { createAgent as createAgentSpecAgent } from "../../agents/index.js"; +import type { ComponentBase } from "../../component.js"; +import type { LlmConfig } from "../../llms/index.js"; +import { + OpenAIAPIType, + createOllamaConfig, + createOpenAiCompatibleConfig, + createOpenAiConfig, +} from "../../llms/index.js"; +import type { JsonSchemaValue, Property } from "../../property.js"; +import type { Tool } from "../../tools/index.js"; +import { createServerTool } from "../../tools/index.js"; +import type { RuntimeToAgentSpecConverter } from "../common/index.js"; +import { + getGraphBuilder, + isStateGraphLike, + langgraphGraphConvertToAgentSpec, +} from "./agentspec-converter-flow.js"; + +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * True for a LangChain structured tool. `isStructuredTool` alone only tests + * `lc_namespace` (which every LC serializable carries), so the check is + * strengthened with the tool surface: a string name, a schema and `invoke`. + */ +function isLangChainStructuredTool( + value: unknown, +): value is StructuredToolInterface { + if (typeof value !== "object" || value === null) { + return false; + } + const candidate = value as { + name?: unknown; + schema?: unknown; + invoke?: unknown; + }; + return ( + isStructuredTool(value as StructuredToolInterface) && + typeof candidate.name === "string" && + "schema" in candidate && + typeof candidate.invoke === "function" + ); +} + +// JS has no `id()`: emulate stable per-object identities with a WeakMap. +const objectIdentities = new WeakMap(); +let nextObjectIdentity = 1; + +/** + * Runtime-object identity key used for exporter memoization, mirroring + * Python's `_get_obj_reference` (`/`). + */ +function getObjectReference(runtimeComponent: unknown): string { + if ( + (typeof runtimeComponent !== "object" || runtimeComponent === null) && + typeof runtimeComponent !== "function" + ) { + return `${typeof runtimeComponent}/${String(runtimeComponent)}`; + } + const target = runtimeComponent as object; + let identity = objectIdentities.get(target); + if (identity === undefined) { + identity = nextObjectIdentity; + nextObjectIdentity += 1; + objectIdentities.set(target, identity); + } + const constructorName = + (target as { constructor?: { name?: string } }).constructor?.name ?? + "object"; + return `${constructorName.toLowerCase()}/${identity}`; +} + +/** The `ReactAgent` surface used for export (langchain `createAgent` result). */ +interface ReactAgentLike { + options: Record; + graph?: { name?: unknown }; +} + +/** True for a langchain `ReactAgent` instance (the `createAgent` result). */ +function isReactAgentInstance(value: unknown): value is ReactAgentLike { + if (typeof value !== "object" || value === null) { + return false; + } + const candidate = value as { + constructor?: { name?: string }; + options?: unknown; + graph?: unknown; + }; + if (!isPlainRecord(candidate.options)) { + return false; + } + if (candidate.constructor?.name === "ReactAgent") { + return true; + } + const graph = candidate.graph as { invoke?: unknown } | undefined; + return typeof graph?.invoke === "function"; +} + +/** True for a graph compiled by langchain's `createAgent` (fingerprint). */ +function isReactAgentGraph(value: unknown): boolean { + if (!isStateGraphLike(value)) { + return false; + } + const builder = getGraphBuilder(value); + return isPlainRecord(builder.nodes) && "model_request" in builder.nodes; +} + +/** True for a graph built by `@langchain/langgraph-swarm`'s `createSwarm`. */ +function isSwarmGraph(value: unknown): boolean { + if (!isStateGraphLike(value)) { + return false; + } + const builder = getGraphBuilder(value) as { + nodes?: Record; + branches?: Record; + channels?: Record; + _schemaDefinition?: unknown; + }; + const channels = + (isPlainRecord(builder._schemaDefinition) + ? builder._schemaDefinition + : undefined) ?? builder.channels; + if (!isPlainRecord(channels) || !("activeAgent" in channels)) { + return false; + } + const startBranches = builder.branches?.["__start__"]; + if (!isPlainRecord(startBranches)) { + return false; + } + const nodeSpecs = Object.values(builder.nodes ?? {}); + return ( + nodeSpecs.length > 0 && + nodeSpecs.every( + (spec) => + (spec.runnable as { lg_is_pregel?: unknown } | undefined) + ?.lg_is_pregel === true, + ) + ); +} + +/** Build an Agent Spec property from one tool-argument JSON schema. */ +function toolArgumentProperty( + argumentTitle: string, + argumentSchema: JsonSchemaValue, +): Property { + const jsonSchema: JsonSchemaValue = { ...argumentSchema }; + if ( + typeof jsonSchema["title"] !== "string" || + (jsonSchema["title"] as string).length === 0 + ) { + jsonSchema["title"] = argumentTitle; + } + if (!("type" in jsonSchema) && !("anyOf" in jsonSchema)) { + // Python exports an untyped Property here (its Property model accepts a + // bare `{title}` schema); the TS SDK Property requires `type` or + // `anyOf`, so synthesize the closest representable "any" union instead + // of failing the whole export. + jsonSchema["anyOf"] = [ + { type: "object" }, + { type: "array" }, + { type: "string" }, + { type: "number" }, + { type: "integer" }, + { type: "boolean" }, + { type: "null" }, + ]; + } + return { + jsonSchema, + title: argumentTitle, + description: jsonSchema["description"] as string | undefined, + default: jsonSchema["default"], + type: jsonSchema["type"] as string | string[] | undefined, + }; +} + +/** + * Convert LangGraph/LangChain runtime components into Agent Spec components. + * + * Shared runtime objects (the same model or tool instance used twice) become + * single referenced components via the `referencedObjects` memoization map. + */ +export class LangGraphToAgentSpecConverter + implements RuntimeToAgentSpecConverter +{ + /** Convert the given LangGraph object into an Agent Spec component. */ + convert( + runtimeComponent: unknown, + referencedObjects?: Map, + ): ComponentBase { + const registry = referencedObjects ?? new Map(); + // Reuse the same object multiple times to exploit the referencing system. + const objectReference = getObjectReference(runtimeComponent); + const cached = registry.get(objectReference); + if (cached !== undefined) { + return cached; + } + const converted = this._convert(runtimeComponent, registry); + registry.set(objectReference, converted); + return converted; + } + + /** Dispatch one (uncached) conversion on the runtime component's shape. */ + protected _convert( + langgraphComponent: unknown, + referencedObjects: Map, + ): ComponentBase { + // The chat-model check comes first: langchain's `isStructuredTool` only + // tests `lc_namespace`, which every LC serializable (models included) + // carries. + if (langgraphComponent instanceof BaseChatModel) { + return this.baseChatModelConvertToAgentSpec(langgraphComponent); + } + if (isLangChainStructuredTool(langgraphComponent)) { + return this.langgraphAnyToolToAgentSpecTool(langgraphComponent); + } + if (isReactAgentInstance(langgraphComponent)) { + return this.reactAgentConvertToAgentSpec( + langgraphComponent, + referencedObjects, + ); + } + if (isSwarmGraph(langgraphComponent)) { + throw new Error( + "Exporting a LangGraph swarm is not supported by the TypeScript " + + "adapter: the compiled per-agent graphs do not retain their chat " + + "model or system prompt.", + ); + } + if (isReactAgentGraph(langgraphComponent)) { + throw new Error( + "Exporting a compiled agent graph is not supported by the TypeScript " + + "adapter: the compiled graph does not retain its chat model or " + + "system prompt. Export the langchain ReactAgent instance (the " + + "createAgent result) instead.", + ); + } + if (isStateGraphLike(langgraphComponent)) { + return langgraphGraphConvertToAgentSpec( + this, + langgraphComponent, + referencedObjects, + ); + } + throw new Error( + `Conversion for ${String(langgraphComponent)} not implemented yet`, + ); + } + + /** + * Convert a LangChain structured tool into an Agent Spec ServerTool. + * + * Python additionally recovers MCPTool transports from the tool coroutine's + * closure; that is unreachable in JS, so MCP-loaded tools export as plain + * ServerTools (documented divergence). + */ + protected langgraphAnyToolToAgentSpecTool( + tool: StructuredToolInterface, + ): ComponentBase { + const toolSchema = toJsonSchema( + (tool as { schema: Parameters[0] }).schema, + ) as JsonSchemaValue; + const argumentSchemas = isPlainRecord(toolSchema["properties"]) + ? (toolSchema["properties"] as Record) + : {}; + const inputs = Object.entries(argumentSchemas).map( + ([argumentTitle, argumentSchema]) => + toolArgumentProperty(argumentTitle, argumentSchema), + ); + return createServerTool({ + name: tool.name, + description: tool.description ?? "", + inputs, + }); + } + + /** Convert a LangChain chat model into the closest Agent Spec LLM config. */ + protected baseChatModelConvertToAgentSpec(model: BaseChatModel): LlmConfig { + const llmType = + typeof (model as unknown as { _llmType?: () => string })._llmType === + "function" + ? (model as unknown as { _llmType: () => string })._llmType() + : ""; + const constructorName = model.constructor?.name ?? ""; + + if (llmType === "ollama" || constructorName === "ChatOllama") { + const ollamaModel = model as unknown as { + model?: string; + baseUrl?: string; + }; + const modelId = ollamaModel.model ?? ""; + return createOllamaConfig({ + name: modelId, + url: ollamaModel.baseUrl ?? "", + modelId, + }); + } + if (llmType === "openai" || constructorName === "ChatOpenAI") { + const openAiModel = model as unknown as { + model?: string; + useResponsesApi?: boolean; + clientConfig?: { baseURL?: string }; + fields?: { configuration?: { baseURL?: string } }; + }; + const modelName = openAiModel.model ?? ""; + const apiType = openAiModel.useResponsesApi + ? OpenAIAPIType.RESPONSES + : OpenAIAPIType.CHAT_COMPLETIONS; + const baseUrl = + openAiModel.clientConfig?.baseURL ?? + openAiModel.fields?.configuration?.baseURL ?? + ""; + // Note: the TS SDK LlmConfigs have no retryPolicy, so ChatOpenAI + // maxRetries/timeout are not exported (documented divergence). + if (baseUrl.startsWith("https://api.openai.com")) { + return createOpenAiConfig({ + name: modelName, + modelId: modelName, + apiType, + }); + } + return createOpenAiCompatibleConfig({ + name: modelName, + url: baseUrl, + modelId: modelName, + apiType, + }); + } + throw new Error( + `The LLM instance provided is of an unsupported type \`${constructorName || llmType}\`.`, + ); + } + + /** + * Convert a langchain `ReactAgent` into an Agent Spec Agent using its + * public `options` (the JS-native replacement for Python's closure + * introspection on the compiled graph). + */ + protected reactAgentConvertToAgentSpec( + reactAgent: ReactAgentLike, + referencedObjects: Map, + ): Agent { + const options = reactAgent.options; + const optionsName = options["name"]; + const graphName = reactAgent.graph?.name; + const agentName = + typeof optionsName === "string" && optionsName.length > 0 + ? optionsName + : typeof graphName === "string" && + graphName.length > 0 && + graphName !== "LangGraph" + ? graphName + : "LangGraph Agent"; + + const model = options["model"]; + if (typeof model === "string") { + throw new Error( + "Exporting an agent created from a model identifier string is not " + + "supported; pass a chat model instance to createAgent instead.", + ); + } + const llmConfig = this.convert(model, referencedObjects) as LlmConfig; + + const systemPromptRaw = options["systemPrompt"]; + let systemPrompt = ""; + if (typeof systemPromptRaw === "string") { + systemPrompt = systemPromptRaw; + } else if ( + isPlainRecord(systemPromptRaw) || + (typeof systemPromptRaw === "object" && systemPromptRaw !== null) + ) { + const content = (systemPromptRaw as { content?: unknown }).content; + systemPrompt = content === undefined ? "" : String(content); + } + + const optionTools = Array.isArray(options["tools"]) + ? (options["tools"] as unknown[]) + : []; + const tools = optionTools.map( + (langgraphTool) => + this.convert(langgraphTool, referencedObjects) as Tool, + ); + + return createAgentSpecAgent({ + name: agentName, + llmConfig, + systemPrompt, + tools, + }); + } +} diff --git a/tsagentspec/src/adapters/langgraph/agentspec-exporter.ts b/tsagentspec/src/adapters/langgraph/agentspec-exporter.ts new file mode 100644 index 00000000..745e40c7 --- /dev/null +++ b/tsagentspec/src/adapters/langgraph/agentspec-exporter.ts @@ -0,0 +1,22 @@ +/** + * Public exporter converting LangGraph objects into Agent Spec + * configurations. + * + * Port of `pyagentspec.adapters.langgraph.agentspecexporter.AgentSpecExporter` + * on top of the adapter-agnostic exporter base: serialization plugins, + * disaggregated configurations, and the LangGraph-specific runtime converter. + */ +import { AdapterAgnosticAgentSpecExporter } from "../common/index.js"; +import { LangGraphToAgentSpecConverter } from "./agentspec-converter.js"; + +/** + * Helper class to convert LangGraph components (react agents, chat models, + * structured tools, state graphs) into Agent Spec configurations via + * `toJson` / `toYaml` / `toDict` / `toComponent`. + */ +export class AgentSpecExporter extends AdapterAgnosticAgentSpecExporter { + /** Converter used to convert LangGraph components to Agent Spec components. */ + get runtimeToAgentSpecConverter(): LangGraphToAgentSpecConverter { + return new LangGraphToAgentSpecConverter(); + } +} diff --git a/tsagentspec/src/adapters/langgraph/agentspec-loader.ts b/tsagentspec/src/adapters/langgraph/agentspec-loader.ts new file mode 100644 index 00000000..161ccdfd --- /dev/null +++ b/tsagentspec/src/adapters/langgraph/agentspec-loader.ts @@ -0,0 +1,90 @@ +/** + * Public loader converting Agent Spec configurations into LangGraph objects. + * + * Port of `pyagentspec.adapters.langgraph.agentspecloader.AgentSpecLoader` on + * top of the adapter-agnostic loader base: plugin-aware deserialization, the + * component load policy (`StdioTransport` blocked by default), disaggregated + * configurations, and the LangGraph-specific checkpointer / config / + * middleware options threaded into every conversion. + */ +import type { RunnableConfig } from "@langchain/core/runnables"; +import type { BaseCheckpointSaver } from "@langchain/langgraph"; +import type { ComponentBase } from "../../component.js"; +import { + AdapterAgnosticAgentSpecLoader, + type AdapterAgnosticAgentSpecLoaderOptions, +} from "../common/index.js"; +import { LangGraphToAgentSpecConverter } from "./agentspec-converter.js"; +import { AgentSpecToLangGraphConverter } from "./langgraph-converter.js"; + +/** Constructor options for the LangGraph `AgentSpecLoader`. */ +export interface AgentSpecLoaderOptions + extends AdapterAgnosticAgentSpecLoaderOptions { + /** + * LangGraph checkpointer wired into created graphs; enables features that + * require one (e.g., client tools and tool confirmation interrupts). + */ + checkpointer?: BaseCheckpointSaver; + /** RunnableConfig passed to created runnables/graphs. */ + config?: RunnableConfig; + /** + * LangChain agent middleware instances forwarded verbatim to + * `createAgent({middleware})` when compiling an Agent Spec `Agent` into a + * react graph. Order is preserved — index 0 is the outermost middleware. + * When omitted or empty, the middleware option is not passed at all. + */ + middleware?: unknown[]; +} + +/** + * Helper class to convert Agent Spec configurations into LangGraph objects. + * + * Loading is async: `loadYaml` / `loadJson` / `loadDict` return promises of + * the converted runtime component (or, with + * `importOnlyReferencedComponents: true`, a record mapping component ids to + * runtime components). + */ +export class AgentSpecLoader extends AdapterAgnosticAgentSpecLoader { + /** Checkpointer wired into created graphs. */ + readonly checkpointer?: BaseCheckpointSaver; + /** RunnableConfig passed to created runnables/graphs. */ + readonly config?: RunnableConfig; + private readonly middleware: unknown[]; + + constructor(options?: AgentSpecLoaderOptions) { + super(options); + this.checkpointer = options?.checkpointer; + this.config = options?.config; + this.middleware = [...(options?.middleware ?? [])]; + } + + /** Converter used to convert Agent Spec components to LangGraph components. */ + get agentspecToRuntimeConverter(): AgentSpecToLangGraphConverter { + return new AgentSpecToLangGraphConverter(); + } + + /** Converter used to convert LangGraph components to Agent Spec components. */ + get runtimeToAgentSpecConverter(): LangGraphToAgentSpecConverter { + return new LangGraphToAgentSpecConverter(); + } + + /** + * Convert an Agent Spec component into a LangGraph component after + * validating it against the component load policy, threading the loader's + * checkpointer, config and middleware into the conversion. + */ + override async loadComponent( + agentspecComponent: ComponentBase, + ): Promise { + this.componentLoadPolicy.validateComponentTree(agentspecComponent); + return this.agentspecToRuntimeConverter.convert( + agentspecComponent, + this.toolRegistry, + { + checkpointer: this.checkpointer, + config: this.config, + middleware: this.middleware, + }, + ); + } +} diff --git a/tsagentspec/src/adapters/langgraph/index.ts b/tsagentspec/src/adapters/langgraph/index.ts new file mode 100644 index 00000000..a0edafd9 --- /dev/null +++ b/tsagentspec/src/adapters/langgraph/index.ts @@ -0,0 +1,30 @@ +/** + * LangGraph adapter public barrel. + * + * Mirrors the Python `pyagentspec.adapters.langgraph` public API: + * `AgentSpecLoader`, `AgentSpecExporter`, `DELEGATE_TOOL_PREFIX` and + * `isDelegationToolName`, plus the adapter's shared types. + */ +export { + AgentSpecLoader, + type AgentSpecLoaderOptions, +} from "./agentspec-loader.js"; +export { AgentSpecExporter } from "./agentspec-exporter.js"; +export { + DELEGATE_TOOL_PREFIX, + isDelegationToolName, +} from "./manager-workers.js"; +export type { + ConvertOptions, + ExecuteOutput, + FlowState, + NextNodeInputs, + NodeExecutionDetails, + NodeOutputs, + ToolRegistry, +} from "./types.js"; +export type { + ExportOptions, + ExportedDict, + RuntimeDisaggregatedComponentsConfig, +} from "../common/index.js"; diff --git a/tsagentspec/src/adapters/langgraph/langgraph-converter.ts b/tsagentspec/src/adapters/langgraph/langgraph-converter.ts new file mode 100644 index 00000000..5c740e65 --- /dev/null +++ b/tsagentspec/src/adapters/langgraph/langgraph-converter.ts @@ -0,0 +1,932 @@ +/** + * AgentSpec -> LangGraph converter. + * + * Port of `pyagentspec.adapters.langgraph._langgraphconverter + * .AgentSpecToLangGraphConverter`: dispatches on the component type and + * assembles langchain `createAgent` react agents, `@langchain/langgraph-swarm` + * swarms, hierarchical ManagerWorkers graphs and Flow StateGraphs. + * + * Runtime contracts (state keys, node names, error-message text) mirror the + * Python adapter exactly so specs behave the same across both SDKs. + * + * Divergences from Python (see the adapter README): + * - Conversion is async end to end (dynamic imports, MCP loading). + * - An Agent converts to a langchain `ReactAgent` instance rather than a bare + * compiled graph: it is directly invocable, and its `options` property is + * the sanctioned source for the exporter. Call sites needing the compiled + * graph (swarm assembly, the ManagerWorkers `__manager__` node) unwrap + * `.graph`. + * - Declared agent inputs extend the react-agent state through a zod object + * schema (`Annotation.Root` is silently ignored by the JS `createAgent`); + * the langchain JS agent state has no `remaining_steps` channel, so no such + * key is added. + * - No tracing callbacks/spans are attached; `patchWithExecutionSpan` is a + * no-op seam invoked at the same sites as Python. + * - Python's "async interrupts on Python < 3.11" load-time warning has no JS + * equivalent and is not ported. + */ +import { ToolMessage, type BaseMessage } from "@langchain/core/messages"; +import type { RunnableConfig } from "@langchain/core/runnables"; +import type { BaseCheckpointSaver } from "@langchain/langgraph"; +import { Annotation, START, StateGraph } from "@langchain/langgraph"; +import { ToolInvocationError, createAgent, toolStrategy } from "langchain"; +import { z } from "zod"; +import type { Agent, ManagerWorkers, Swarm } from "../../agents/index.js"; +import { HandoffMode } from "../../agents/index.js"; +import { type ComponentBase, isComponent } from "../../component.js"; +import type { + AgentNode, + ControlFlowEdge, + DataFlowEdge, + Flow, + Node, +} from "../../flows/index.js"; +import { DEFAULT_NEXT_BRANCH, createDataFlowEdge } from "../../flows/index.js"; +import type { LlmConfig } from "../../llms/index.js"; +import type { ClientTransport, MCPTool } from "../../mcp/index.js"; +import type { Property } from "../../property.js"; +import type { MCPToolBox, Tool, ToolBox } from "../../tools/index.js"; +import type { ComponentWithIO } from "../../component.js"; +import { buildJsonSchemaFromProperties, jsonSchemasHaveSameType } from "../common/index.js"; +import { convertLlmConfig as convertLlmConfigToChatModel } from "./llm.js"; +import { + ManagerWorkersNodeExecutor, + compileManagerWorkers, +} from "./manager-workers.js"; +import { convertClientTransport, convertMcpTool, convertMcpToolbox } from "./mcp.js"; +import { + AgentNodeExecutor, + ApiNodeExecutor, + BranchingNodeExecutor, + CatchExceptionNodeExecutor, + EndNodeExecutor, + FlowNodeExecutor, + InputMessageNodeExecutor, + LlmNodeExecutor, + MapNodeExecutor, + OutputMessageNodeExecutor, + StartNodeExecutor, + ToolNodeExecutor, +} from "./node-execution.js"; +import { + convertClientTool, + convertRemoteTool, + convertServerTool, + ensureCheckpointerAndValidToolConfig, +} from "./tools.js"; +import { patchWithExecutionSpan } from "./tracing.js"; +import type { + ConvertOptions, + FlowState, + NextNodeInputs, + NodeExecutionDetails, + NodeOutputs, + ToolRegistry, +} from "./types.js"; + +/** Conversion parameters threaded through every recursive conversion. */ +interface ConversionContext { + toolRegistry: ToolRegistry; + convertedComponents: Map; + checkpointer?: BaseCheckpointSaver; + config: RunnableConfig; + middleware: unknown[]; +} + +/** Inputs of the react-agent assembly helper. */ +interface ReactAgentInfo { + name: string; + systemPrompt: string; + agent: Agent; + llmConfig: LlmConfig; + tools: Tool[]; + toolboxes: ToolBox[]; + inputs: Property[]; + outputs: Property[]; + additionalLangGraphTools?: unknown[]; +} + +/** The structural surface of a flow node executor used by the converter. */ +interface NodeExecutorLike { + attachEdge(edge: DataFlowEdge): void; + call(state: FlowState, config: RunnableConfig): Promise>; +} + +interface EndNodeExecutorLike extends NodeExecutorLike { + setFlowOutputs(flowOutputs: Property[]): void; +} + +interface MapNodeExecutorLike extends NodeExecutorLike { + setInputsToIterate(inputsToIterate: string[]): void; +} + +/** Loosely-typed StateGraph surface for graphs with dynamic node names. */ +interface DynamicStateGraph { + addNode(key: string, action: unknown): DynamicStateGraph; + addEdge(start: string, end: string): DynamicStateGraph; + addConditionalEdges( + source: string, + path: (state: FlowState) => string, + pathMap?: Record, + ): DynamicStateGraph; + compile(options?: { + checkpointer?: BaseCheckpointSaver; + name?: string; + }): unknown; +} + +type LangGraphSwarmModule = typeof import("@langchain/langgraph-swarm"); + +async function importLangGraphSwarmModule(): Promise { + try { + return await import("@langchain/langgraph-swarm"); + } catch (error) { + throw new Error( + "@langchain/langgraph-swarm is required to convert Swarm components. " + + "Install it (e.g., npm install @langchain/langgraph-swarm) or remove Swarms from the spec.", + { cause: error }, + ); + } +} + +/** + * Unwrap a langchain `ReactAgent` to its compiled graph; compiled graphs (and + * anything else) pass through unchanged. + */ +function resolveCompiledGraph(agentOrGraph: unknown): unknown { + if (typeof agentOrGraph === "object" && agentOrGraph !== null) { + const candidate = agentOrGraph as { + lg_is_pregel?: unknown; + graph?: { lg_is_pregel?: unknown }; + }; + if (candidate.lg_is_pregel === true) { + return agentOrGraph; + } + if ( + typeof candidate.graph === "object" && + candidate.graph !== null && + candidate.graph.lg_is_pregel === true + ) { + return candidate.graph; + } + } + return agentOrGraph; +} + +/** + * Python-parity tool error handling for react-agent tool nodes. + * + * LangChain JS's default ToolNode handler converts EVERY tool error into a + * ToolMessage fed back to the model, while langgraph Python's default (which + * the Python adapter relies on) only does so for tool-input validation errors + * and re-raises everything else. The Agent Spec runtime contracts depend on + * the Python semantics: a rejected `requiresConfirmation` interrupt must + * raise `Tool '' was denied by the user (reason: ...).` out of + * `invoke`, and malformed confirmation resume payloads must raise their + * validation error. Returning `undefined` from a custom handler makes the + * ToolNode re-throw the error; GraphInterrupts are always re-thrown before + * the handler applies, so client-tool/confirmation interrupts still work. + */ +function pythonParityToolErrorHandler( + error: unknown, + toolCall: { id?: string; name: string }, +): ToolMessage | undefined { + if (ToolInvocationError.isInstance(error)) { + return new ToolMessage({ + content: error.message, + tool_call_id: toolCall.id ?? "", + name: toolCall.name, + }); + } + return undefined; +} + +/** + * Install the Python-parity tool error handler on a react agent's `tools` + * node. `createAgent` exposes no tool-error-handling option, so the compiled + * graph's ToolNode (reachable at `graph.builder.nodes["tools"].runnable`, a + * probed-stable surface the exporter also relies on) is patched in place. + * Agents without tools have no `tools` node and are left untouched. + */ +function applyPythonToolErrorSemantics(reactAgent: unknown): void { + const graph = ( + reactAgent as { + graph?: { + builder?: { nodes?: Record }; + }; + } + ).graph; + const runnable = graph?.builder?.nodes?.["tools"]?.runnable as + | { handleToolErrors?: unknown } + | undefined; + if (runnable !== undefined && "handleToolErrors" in runnable) { + runnable.handleToolErrors = pythonParityToolErrorHandler; + } +} + +/** Duck-type check for a compiled LangGraph graph. */ +function isCompiledGraph(value: unknown): boolean { + return ( + typeof value === "object" && + value !== null && + (value as { lg_is_pregel?: unknown }).lg_is_pregel === true + ); +} + +/** A last-value channel with an initial default. */ +function lastValueChannel(defaultValue: () => T) { + return Annotation({ + reducer: (_current: T, update: T) => update, + default: defaultValue, + }); +} + +function findPropertyByTitle( + properties: Property[], + title: string, + context: string, +): Property { + const property = properties.find((candidate) => candidate.title === title); + if (property === undefined) { + throw new Error(`Property \`${title}\` was not found in ${context}.`); + } + return property; +} + +const NODE_COMPONENT_TYPES = new Set([ + "StartNode", + "EndNode", + "ToolNode", + "LlmNode", + "AgentNode", + "FlowNode", + "BranchingNode", + "MapNode", + "ParallelMapNode", + "ParallelFlowNode", + "ApiNode", + "InputMessageNode", + "OutputMessageNode", + "CatchExceptionNode", +]); + +/** + * Convert Agent Spec components into LangGraph runtime components. + * + * `convert` memoizes by component id in the per-call `convertedComponents` + * map, which doubles as a seam for pre-seeding already-converted fakes in + * tests. `convertLlmConfig` is `protected` so tests can substitute fake chat + * models by subclassing. + */ +export class AgentSpecToLangGraphConverter { + /** + * Convert the given Agent Spec component into the corresponding LangGraph + * component. + * + * When no `config` is given, a `{configurable: {thread_id}}` config with a + * random thread id is defaulted if a checkpointer is present (else an empty + * config), mirroring Python. + */ + async convert( + agentspecComponent: ComponentBase, + toolRegistry: ToolRegistry, + options?: ConvertOptions, + ): Promise { + const checkpointer = options?.checkpointer; + let config = options?.config; + if (config === undefined) { + config = + checkpointer !== undefined + ? { configurable: { thread_id: crypto.randomUUID() } } + : {}; + } + const context: ConversionContext = { + toolRegistry, + convertedComponents: options?.convertedComponents ?? new Map(), + checkpointer, + config, + middleware: [...(options?.middleware ?? [])], + }; + return this.convertWithContext(agentspecComponent, context); + } + + /** Memoized conversion entry used for every nested component. */ + protected async convertWithContext( + agentspecComponent: ComponentBase, + context: ConversionContext, + ): Promise { + if (!context.convertedComponents.has(agentspecComponent.id)) { + context.convertedComponents.set( + agentspecComponent.id, + await this.convertComponent(agentspecComponent, context), + ); + } + return context.convertedComponents.get(agentspecComponent.id); + } + + /** Dispatch a single (uncached) component conversion by component type. */ + protected async convertComponent( + agentspecComponent: ComponentBase, + context: ConversionContext, + ): Promise { + if (!isComponent(agentspecComponent)) { + throw new Error( + "Expected object of type 'pyagentspec.component.Component'," + + ` but got ${typeof agentspecComponent} instead`, + ); + } + const componentType = agentspecComponent.componentType; + switch (componentType) { + case "Agent": + return this.convertAgent(agentspecComponent as Agent, context); + case "Swarm": + return this.convertSwarm(agentspecComponent as Swarm, context); + case "ManagerWorkers": + return this.compileManagerWorkersGraph( + agentspecComponent as ManagerWorkers, + context, + ); + case "OpenAiConfig": + case "OpenAiCompatibleConfig": + case "VllmConfig": + case "OllamaConfig": + case "OciGenAiConfig": + return this.convertLlmConfig(agentspecComponent as LlmConfig); + case "StdioTransport": + case "SSETransport": + case "SSEmTLSTransport": + case "StreamableHTTPTransport": + case "StreamableHTTPmTLSTransport": + case "RemoteTransport": + return convertClientTransport(agentspecComponent as ClientTransport); + case "MCPTool": { + const mcpTool = agentspecComponent as MCPTool; + ensureCheckpointerAndValidToolConfig(mcpTool, context.checkpointer); + const connection = await this.convertWithContext( + mcpTool.clientTransport, + context, + ); + return convertMcpTool( + mcpTool, + context.toolRegistry, + connection as Parameters[2], + ); + } + case "MCPToolBox": { + const mcpToolbox = agentspecComponent as MCPToolBox; + const connection = await this.convertWithContext( + mcpToolbox.clientTransport, + context, + ); + return convertMcpToolbox( + mcpToolbox, + context.toolRegistry, + connection as Parameters[2], + ); + } + case "ServerTool": { + const serverTool = agentspecComponent as Extract; + ensureCheckpointerAndValidToolConfig(serverTool, context.checkpointer); + return convertServerTool(serverTool, context.toolRegistry); + } + case "ClientTool": { + const clientTool = agentspecComponent as Extract; + ensureCheckpointerAndValidToolConfig(clientTool, context.checkpointer); + return convertClientTool(clientTool); + } + case "RemoteTool": { + const remoteTool = agentspecComponent as Extract; + ensureCheckpointerAndValidToolConfig(remoteTool, context.checkpointer); + return convertRemoteTool(remoteTool); + } + case "Flow": + return this.convertFlow(agentspecComponent as Flow, context); + default: + if (NODE_COMPONENT_TYPES.has(componentType)) { + return this.convertNode( + agentspecComponent as unknown as Node, + context, + ); + } + throw new Error( + `The Agent Spec type '${componentType}' is not yet supported for conversion.`, + ); + } + } + + /** + * Create the LangChain chat model for an Agent Spec LLM configuration. + * + * Protected so tests can subclass the converter and substitute fake chat + * models; the default implementation delegates to `convertLlmConfig` from + * `llm.js`. + */ + protected async convertLlmConfig(llmConfig: LlmConfig): Promise { + return convertLlmConfigToChatModel(llmConfig); + } + + /** Build the zod state schema extending the agent state with declared inputs. */ + private buildAgentStateSchema(inputs: Property[]): z.ZodTypeAny { + // The JS createAgent has no runtime-validated required/optional split and + // silently ignores Annotation.Root schemas; a zod object with optional + // keys adds the channels so declared inputs round-trip through invoke(). + const shape: Record = {}; + for (const property of inputs) { + shape[property.title] = z.unknown().optional(); + } + return z.object(shape); + } + + /** + * Assemble a langchain react agent from Agent Spec information, mirroring + * Python's `_create_react_agent_with_given_info`: converted model and + * tools, tool-strategy structured output for declared outputs (with the + * structured-output sentence appended to the system prompt), extended state + * for declared inputs, middleware forwarded only when non-empty. + */ + protected async createReactAgentWithGivenInfo( + info: ReactAgentInfo, + context: ConversionContext, + ): Promise { + const model = await this.convertWithContext(info.llmConfig, context); + const langgraphTools: unknown[] = [...(info.additionalLangGraphTools ?? [])]; + for (const agentspecTool of info.tools) { + langgraphTools.push(await this.convertWithContext(agentspecTool, context)); + } + for (const toolbox of info.toolboxes) { + const toolboxTools = (await this.convertWithContext( + toolbox, + context, + )) as unknown[]; + langgraphTools.push(...toolboxTools); + } + + let systemPrompt = info.systemPrompt; + let responseFormat: unknown; + if (info.outputs.length > 0) { + // Explicitly use the tool strategy instead of letting LangChain select + // a provider strategy: OpenAI-compatible models do not necessarily + // support provider-native structured output. + responseFormat = toolStrategy( + buildJsonSchemaFromProperties("AgentOutputModel", info.outputs) as { + type: "object"; + [key: string]: unknown; + }, + ); + systemPrompt = + `${systemPrompt}\n\n` + + "After using the available tools, provide the final result by calling the " + + "structured output tool. Do not respond with a plain-text final answer."; + } + + const createAgentParams: Record = { + name: info.name, + model, + tools: langgraphTools, + systemPrompt, + }; + if (context.checkpointer !== undefined) { + createAgentParams["checkpointer"] = context.checkpointer; + } + if (responseFormat !== undefined) { + createAgentParams["responseFormat"] = responseFormat; + } + if (info.inputs.length > 0) { + createAgentParams["stateSchema"] = this.buildAgentStateSchema(info.inputs); + } + if (context.middleware.length > 0) { + createAgentParams["middleware"] = context.middleware; + } + const reactAgent = createAgent( + createAgentParams as unknown as Parameters[0], + ); + applyPythonToolErrorSemantics(reactAgent); + return patchWithExecutionSpan(reactAgent); + } + + private async convertAgent( + agent: Agent, + context: ConversionContext, + ): Promise { + return this.createReactAgentWithGivenInfo( + { + name: agent.name, + systemPrompt: agent.systemPrompt, + agent, + llmConfig: agent.llmConfig, + tools: agent.tools, + toolboxes: agent.toolboxes, + inputs: agent.inputs ?? [], + outputs: agent.outputs ?? [], + }, + context, + ); + } + + private async convertSwarm( + swarm: Swarm, + context: ConversionContext, + ): Promise { + if (swarm.handoff === HandoffMode.NEVER) { + // We cannot control what langgraph-swarm does internally in terms of + // conversation sharing, so NEVER is not really supported. + throw new Error( + "Handoff mode NEVER is not supported for conversion in LangGraph adapter", + ); + } + // LangGraph distinguishes agents by name; relationships are tuples of + // (fromAgent, toAgent) and we assume to get only agents in relationships. + const agentsByName = new Map>(); + for (const relationship of swarm.relationships) { + for (const participant of relationship) { + agentsByName.set(String(participant["name"]), participant); + } + } + for (const participant of agentsByName.values()) { + // Handoff is performed with tools, so only Agents can take part. + if (participant["componentType"] !== "Agent") { + throw new Error( + "Only Agents are supported as part of a Swarm in the LangGraph " + + `adapter, received ${String(participant["componentType"])} instead.`, + ); + } + // Convert the agents even though the converted graphs are re-created + // below with handoff tools, so they land in the converted-components + // cache in case they are used in other places. + await this.convertWithContext( + participant as unknown as ComponentBase, + context, + ); + } + const handoffs = new Map(); + for (const agentName of agentsByName.keys()) { + handoffs.set(agentName, []); + } + for (const [fromAgent, toAgent] of swarm.relationships) { + handoffs.get(String(fromAgent["name"]))?.push(String(toAgent["name"])); + } + + const swarmModule = await importLangGraphSwarmModule(); + // Re-create the agents with the additional handoff tools. + const langgraphAgents: unknown[] = []; + for (const participant of agentsByName.values()) { + const agent = participant as unknown as Agent; + const reactAgent = await this.createReactAgentWithGivenInfo( + { + name: agent.name, + systemPrompt: agent.systemPrompt, + agent, + llmConfig: agent.llmConfig, + tools: agent.tools, + toolboxes: agent.toolboxes, + inputs: agent.inputs ?? [], + outputs: agent.outputs ?? [], + additionalLangGraphTools: (handoffs.get(agent.name) ?? []).map( + (toAgentName) => + swarmModule.createHandoffTool({ agentName: toAgentName }), + ), + }, + context, + ); + langgraphAgents.push(resolveCompiledGraph(reactAgent)); + } + const workflow = swarmModule.createSwarm({ + agents: langgraphAgents as never, + defaultActiveAgent: String(swarm.firstAgent["name"]), + }); + return workflow.compile({ + ...(context.checkpointer !== undefined + ? { checkpointer: context.checkpointer } + : {}), + name: swarm.name, + }); + } + + /** + * Compile a ManagerWorkers into its hierarchical graph. When + * `systemPromptOverride` is set (a ManagerWorkers flow step with rendered + * inputs), it replaces the group manager's system prompt and the manager's + * declared inputs are dropped (they are baked into the prompt). + */ + private async compileManagerWorkersGraph( + managerWorkers: ManagerWorkers, + context: ConversionContext, + systemPromptOverride?: string, + ): Promise { + return compileManagerWorkers(managerWorkers, { + checkpointer: context.checkpointer, + ...(systemPromptOverride !== undefined + ? { systemPrompt: systemPromptOverride } + : {}), + compileManagerAgent: async (rosterSystemPrompt, delegationTools) => { + // compileManagerWorkers already validated the group manager type. + const managerAgent = managerWorkers.groupManager as unknown as Agent; + const reactAgent = await this.createReactAgentWithGivenInfo( + { + name: managerAgent.name, + systemPrompt: rosterSystemPrompt, + agent: managerAgent, + llmConfig: managerAgent.llmConfig, + tools: managerAgent.tools ?? [], + toolboxes: managerAgent.toolboxes ?? [], + inputs: + systemPromptOverride !== undefined + ? [] + : (managerAgent.inputs ?? []), + outputs: managerAgent.outputs ?? [], + additionalLangGraphTools: delegationTools, + }, + context, + ); + return resolveCompiledGraph(reactAgent); + }, + convertWorker: (worker) => + this.convertWithContext(worker as unknown as ComponentBase, context), + }); + } + + private async convertFlow( + flow: Flow, + context: ConversionContext, + ): Promise { + // The input/output schemas must reference the SAME channel instances as + // the state schema, or StateGraph rejects them as conflicting channels. + const inputsChannel = lastValueChannel(() => ({})); + const outputsChannel = lastValueChannel(() => ({})); + const messagesChannel = lastValueChannel(() => []); + const nodeExecutionDetailsChannel = lastValueChannel( + () => ({}), + ); + const graphBuilder = new StateGraph({ + state: Annotation.Root({ + inputs: inputsChannel, + outputs: outputsChannel, + messages: messagesChannel, + node_execution_details: nodeExecutionDetailsChannel, + }), + input: Annotation.Root({ + inputs: inputsChannel, + messages: messagesChannel, + }), + output: Annotation.Root({ + outputs: outputsChannel, + messages: messagesChannel, + node_execution_details: nodeExecutionDetailsChannel, + }), + }) as unknown as DynamicStateGraph; + + graphBuilder.addEdge(START, String(flow.startNode["id"])); + + const flowNodes = flow.nodes as unknown as Node[]; + const nodeExecutors = new Map(); + for (const node of flowNodes) { + nodeExecutors.set( + node.id, + (await this.convertWithContext( + node as unknown as ComponentBase, + context, + )) as NodeExecutorLike, + ); + } + + // Tell the MapNodes which inputs they should iterate over, based on the + // type of the outputs they are connected to; give EndNodes the flow + // outputs to reshape their result. Mirroring Python, only explicitly + // declared data-flow connections take part in MapNode iteration wiring. + for (const node of flowNodes) { + if (node.componentType === "MapNode") { + const inputsToIterate: string[] = []; + for (const dataFlowEdge of flow.dataFlowConnections ?? []) { + if (String(dataFlowEdge.destinationNode["id"]) !== node.id) { + continue; + } + const sourceProperty = findPropertyByTitle( + (dataFlowEdge.sourceNode["outputs"] as Property[] | undefined) ?? [], + dataFlowEdge.sourceOutput, + `the outputs of node \`${String(dataFlowEdge.sourceNode["name"])}\``, + ); + const innerFlowInputProperty = findPropertyByTitle( + (node.subflow["inputs"] as Property[] | undefined) ?? [], + dataFlowEdge.destinationInput.replace("iterated_", ""), + `the inputs of the subflow of MapNode \`${node.name}\``, + ); + // Compare against an array-of-inner-input schema, like Python's + // ListProperty(item_type=inner).json_schema (titles are ignored by + // the comparison). + if ( + jsonSchemasHaveSameType(sourceProperty.jsonSchema, { + type: "array", + items: innerFlowInputProperty.jsonSchema, + }) + ) { + inputsToIterate.push(dataFlowEdge.destinationInput); + } + } + (nodeExecutors.get(node.id) as MapNodeExecutorLike).setInputsToIterate( + inputsToIterate, + ); + } else if (node.componentType === "EndNode") { + (nodeExecutors.get(node.id) as EndNodeExecutorLike).setFlowOutputs( + flow.outputs ?? [], + ); + } + } + + for (const [nodeId, nodeExecutor] of nodeExecutors) { + // Graph node names are the AgentSpec node ids. + graphBuilder.addNode(nodeId, (state: FlowState, config: RunnableConfig) => + nodeExecutor.call(state, config), + ); + } + + let dataFlowConnections: DataFlowEdge[]; + if (flow.dataFlowConnections === undefined) { + // Manually create data flow connections if they are not given in the + // flow: one edge per matching-title (source output, destination input) + // pair. This is the conversion recommended by the Agent Spec language + // specification. + dataFlowConnections = []; + for (const sourceNode of flowNodes) { + for (const destinationNode of flowNodes) { + for (const sourceOutput of sourceNode.outputs ?? []) { + for (const destinationInput of destinationNode.inputs ?? []) { + if (sourceOutput.title === destinationInput.title) { + dataFlowConnections.push( + createDataFlowEdge({ + name: `${sourceNode.name}-${destinationNode.name}-${sourceOutput.title}`, + sourceNode: sourceNode as unknown as ComponentWithIO, + sourceOutput: sourceOutput.title, + destinationNode: destinationNode as unknown as ComponentWithIO, + destinationInput: destinationInput.title, + }), + ); + } + } + } + } + } + } else { + dataFlowConnections = flow.dataFlowConnections; + } + + for (const dataFlowEdge of dataFlowConnections) { + // Flow validation guarantees every edge endpoint is a node of the flow. + nodeExecutors + .get(String(dataFlowEdge.sourceNode["id"]))! + .attachEdge(dataFlowEdge); + } + + this.addConditionalEdgesToGraph(flow.controlFlowConnections, graphBuilder); + + const compiledGraph = graphBuilder.compile( + context.checkpointer !== undefined + ? { checkpointer: context.checkpointer } + : {}, + ); + return patchWithExecutionSpan(compiledGraph); + } + + /** Add one conditional edge per source node, routing on the last branch. */ + private addConditionalEdgesToGraph( + controlFlowConnections: ControlFlowEdge[], + graphBuilder: DynamicStateGraph, + ): void { + const controlFlow = new Map>(); + for (const controlFlowEdge of controlFlowConnections) { + const sourceNodeId = String(controlFlowEdge.fromNode["id"]); + let mapping = controlFlow.get(sourceNodeId); + if (mapping === undefined) { + mapping = {}; + controlFlow.set(sourceNodeId, mapping); + } + // Python's `from_branch or DEFAULT_NEXT_BRANCH`: an empty-string + // branch coerces to the default branch too, not just null/undefined. + const branchName = controlFlowEdge.fromBranch || DEFAULT_NEXT_BRANCH; + mapping[branchName] = String(controlFlowEdge.toNode["id"]); + } + for (const [sourceNodeId, controlFlowMapping] of controlFlow) { + graphBuilder.addConditionalEdges( + sourceNodeId, + (state: FlowState) => + state.node_execution_details?.branch ?? DEFAULT_NEXT_BRANCH, + controlFlowMapping, + ); + } + } + + /** Build the node executor for one flow node. */ + protected async convertNode( + node: Node, + context: ConversionContext, + ): Promise { + switch (node.componentType) { + case "StartNode": + return new StartNodeExecutor(node); + case "EndNode": + return new EndNodeExecutor(node); + case "ToolNode": { + const convertedTool = await this.convertWithContext(node.tool, context); + return new ToolNodeExecutor(node, convertedTool); + } + case "LlmNode": { + const chatModel = await this.convertWithContext(node.llmConfig, context); + return new LlmNodeExecutor(node, chatModel); + } + case "AgentNode": + return this.convertAgentNode(node, context); + case "BranchingNode": + return new BranchingNodeExecutor(node); + case "ApiNode": + return new ApiNodeExecutor(node); + case "FlowNode": { + const subflow = await this.convertWithContext( + node.subflow as unknown as ComponentBase, + context, + ); + if (!isCompiledGraph(subflow)) { + throw new Error("FlowNodeExecutor can only initialize FlowNode"); + } + return new FlowNodeExecutor(node, subflow, context.config); + } + case "CatchExceptionNode": { + const subflow = await this.convertWithContext( + node.subflow as unknown as ComponentBase, + context, + ); + if (!isCompiledGraph(subflow)) { + throw new Error( + "Internal error: CatchExceptionNodeExecutor expects `subflow` " + + `to be a CompiledStateGraph, was ${typeof subflow}`, + ); + } + return new CatchExceptionNodeExecutor(node, subflow, context.config); + } + case "InputMessageNode": + return new InputMessageNodeExecutor(node); + case "OutputMessageNode": + return new OutputMessageNodeExecutor(node); + case "MapNode": { + const subflow = await this.convertWithContext( + node.subflow as unknown as ComponentBase, + context, + ); + if (!isCompiledGraph(subflow)) { + throw new Error("MapNodeExecutor can only be initialized with MapNode"); + } + return new MapNodeExecutor(node, subflow, context.config); + } + default: + throw new Error( + `The AgentSpec component of type ${node.componentType} is not yet supported for conversion`, + ); + } + } + + /** + * Build the executor for an AgentNode: a `ManagerWorkersNodeExecutor` when + * the node's agent is a ManagerWorkers, else an `AgentNodeExecutor`. The + * executor receives a compile factory taking the rendered system prompt + * (executors render templates against node inputs and cache per rendered + * prompt). + */ + private convertAgentNode(node: AgentNode, context: ConversionContext): unknown { + if (node.agent.componentType === "ManagerWorkers") { + const managerWorkers = node.agent as ManagerWorkers; + return new ManagerWorkersNodeExecutor( + node, + (renderedSystemPrompt: string) => + this.compileManagerWorkersGraph( + managerWorkers, + context, + renderedSystemPrompt, + ), + context.config, + ); + } + const agentComponent = node.agent; + const compileAgentFactory = async ( + renderedSystemPrompt: string, + ): Promise => { + if (agentComponent.componentType !== "Agent") { + throw new Error( + "AgentNodeExecutor can only be used with AgentSpecAgent agents", + ); + } + const agent = agentComponent as Agent; + return this.createReactAgentWithGivenInfo( + { + name: agent.name, + systemPrompt: renderedSystemPrompt, + agent, + llmConfig: agent.llmConfig, + tools: agent.tools, + toolboxes: agent.toolboxes, + inputs: agent.inputs ?? [], + outputs: agent.outputs ?? [], + }, + context, + ); + }; + return new AgentNodeExecutor(node, compileAgentFactory, context.config); + } +} diff --git a/tsagentspec/src/adapters/langgraph/llm.ts b/tsagentspec/src/adapters/langgraph/llm.ts new file mode 100644 index 00000000..36b2e20d --- /dev/null +++ b/tsagentspec/src/adapters/langgraph/llm.ts @@ -0,0 +1,198 @@ +/** + * LLM config conversion for the LangGraph adapter. + * + * Port of the `_llm_convert_to_langgraph` section of + * `pyagentspec.adapters.langgraph._langgraphconverter`. + * + * Divergences from Python (see the adapter README): + * - Conversion is async (chat-model packages are loaded via dynamic import so + * they stay optional peer dependencies). + * - The TS SDK LlmConfig components have no `retryPolicy` field, so the + * Python retry-policy-to-ChatOpenAI mapping is not ported. + * - OciGenAiConfig is not supported (no langchain-oci package for JS). + * - No tracing callbacks are attached here (tracing is a no-op seam in v1). + */ +import type { BaseChatModel } from "@langchain/core/language_models/chat_models"; +import type { LlmConfig, LlmGenerationConfig } from "../../llms/index.js"; +import { OpenAIAPIType } from "../../llms/index.js"; + +/** Normalized Agent Spec generation settings supported by the LangGraph adapter. */ +export interface GenerationConfig { + temperature?: number; + maxTokens?: number; + topP?: number; +} + +/** + * Copy only the generation parameters that are set (temperature, maxTokens, + * topP) from an Agent Spec `LlmGenerationConfig`. + */ +export function generationConfigFromAgentSpec( + generationParameters: LlmGenerationConfig | undefined, +): GenerationConfig { + const generationConfig: GenerationConfig = {}; + if (generationParameters === undefined) { + return generationConfig; + } + if (generationParameters.temperature !== undefined) { + generationConfig.temperature = generationParameters.temperature; + } + if (generationParameters.maxTokens !== undefined) { + generationConfig.maxTokens = generationParameters.maxTokens; + } + if (generationParameters.topP !== undefined) { + generationConfig.topP = generationParameters.topP; + } + return generationConfig; +} + +function ensureUrlHasScheme(url: string): string { + const trimmed = url.trim(); + if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) { + return `http://${trimmed}`; + } + return trimmed; +} + +/** + * Correctly format a URL for an OpenAI-compatible server. + * + * - Ensures a scheme (http, https) is present, defaulting to 'http'. + * - Replaces any existing path with exactly '/v1'. + * - Strips query parameters and fragments. + * + * Examples: + * - "localhost:8000" -> "http://localhost:8000/v1" + * - "127.0.0.1:5000" -> "http://127.0.0.1:5000/v1" + * - "https://api.example.com" -> "https://api.example.com/v1" + * - "http://my-host/api/v2" -> "http://my-host/v1" + */ +export function prepareOpenAiCompatibleUrl(url: string): string { + const parsed = new URL(ensureUrlHasScheme(url)); + parsed.pathname = "/v1"; + parsed.search = ""; + parsed.hash = ""; + return parsed.toString(); +} + +type ChatOpenAiModule = typeof import("@langchain/openai"); +type ChatOllamaModule = typeof import("@langchain/ollama"); + +async function importChatOpenAiModule(): Promise { + try { + return await import("@langchain/openai"); + } catch (error) { + throw new Error( + "@langchain/openai is required to convert OpenAI-compatible LLM configs. " + + "Install it (e.g., npm install @langchain/openai) or remove them from the spec.", + { cause: error }, + ); + } +} + +async function importChatOllamaModule(): Promise { + try { + return await import("@langchain/ollama"); + } catch (error) { + throw new Error( + "@langchain/ollama is required to convert OllamaConfig LLM configs. " + + "Install it (e.g., npm install @langchain/ollama) or remove them from the spec.", + { cause: error }, + ); + } +} + +/** + * Create a ChatOpenAI model without overriding env-based defaults. + * + * If no api key is given and the `OPENAI_API_KEY` environment variable is not + * set, a fake "EMPTY" key is used so servers that require no key still work. + */ +async function createChatOpenAiModel(options: { + modelId: string; + useResponsesApi: boolean; + generationConfig: GenerationConfig; + baseUrl?: string; + apiKey?: string; +}): Promise { + const { ChatOpenAI } = await importChatOpenAiModule(); + // Mirror the Python fallback chain: a MISSING config value falls back to + // OPENAI_API_KEY -> "EMPTY", but an explicit key (even an empty string) is + // used as-is — the spec's key must never be silently replaced by the + // developer's environment credential. + const apiKey = + options.apiKey ?? (process.env["OPENAI_API_KEY"] || "EMPTY"); + return new ChatOpenAI({ + model: options.modelId, + useResponsesApi: options.useResponsesApi, + apiKey, + temperature: options.generationConfig.temperature, + maxTokens: options.generationConfig.maxTokens, + topP: options.generationConfig.topP, + ...(options.baseUrl !== undefined + ? { configuration: { baseURL: options.baseUrl } } + : {}), + }); +} + +/** + * Create the LangChain chat model for the given Agent Spec LLM configuration. + * + * VllmConfig / OpenAiCompatibleConfig map to ChatOpenAI with a normalized + * OpenAI-compatible base URL; OpenAiConfig maps to ChatOpenAI without a base + * URL; OllamaConfig maps to ChatOllama. OciGenAiConfig is not supported yet. + */ +export async function convertLlmConfig( + llmConfig: LlmConfig, +): Promise { + const generationConfig = generationConfigFromAgentSpec( + llmConfig.defaultGenerationParameters, + ); + + switch (llmConfig.componentType) { + case "VllmConfig": + return createChatOpenAiModel({ + modelId: llmConfig.modelId, + baseUrl: prepareOpenAiCompatibleUrl(llmConfig.url), + apiKey: llmConfig.apiKey, + useResponsesApi: llmConfig.apiType === OpenAIAPIType.RESPONSES, + generationConfig, + }); + case "OllamaConfig": { + const { ChatOllama } = await importChatOllamaModule(); + return new ChatOllama({ + baseUrl: llmConfig.url, + model: llmConfig.modelId, + temperature: generationConfig.temperature, + numPredict: generationConfig.maxTokens, + topP: generationConfig.topP, + }); + } + case "OpenAiConfig": + return createChatOpenAiModel({ + modelId: llmConfig.modelId, + apiKey: llmConfig.apiKey, + useResponsesApi: llmConfig.apiType === OpenAIAPIType.RESPONSES, + generationConfig, + }); + case "OpenAiCompatibleConfig": + return createChatOpenAiModel({ + modelId: llmConfig.modelId, + baseUrl: prepareOpenAiCompatibleUrl(llmConfig.url), + apiKey: llmConfig.apiKey, + useResponsesApi: llmConfig.apiType === OpenAIAPIType.RESPONSES, + generationConfig, + }); + case "OciGenAiConfig": + throw new Error( + "The Agent Spec type 'OciGenAiConfig' is not supported by the LangGraph TypeScript adapter yet.", + ); + default: { + const componentType = (llmConfig as { componentType: string }) + .componentType; + throw new Error( + `The Agent Spec type '${componentType}' is not yet supported for conversion.`, + ); + } + } +} diff --git a/tsagentspec/src/adapters/langgraph/manager-workers.ts b/tsagentspec/src/adapters/langgraph/manager-workers.ts new file mode 100644 index 00000000..69bd6953 --- /dev/null +++ b/tsagentspec/src/adapters/langgraph/manager-workers.ts @@ -0,0 +1,513 @@ +/** + * ManagerWorkers compilation for the LangGraph adapter. + * + * Port of `pyagentspec.adapters.langgraph._managerworkers` and + * `_managerworkers_node`: a hierarchical StateGraph where a react-agent + * manager delegates tasks to worker subgraphs through synthetic + * `__delegate_to__` tools. + * + * The delegation protocol is visible on purpose: `__delegate_to__` + * calls stream like any other tool call. Consumers that would rather not + * render them can filter on `isDelegationToolName`. + * + * Runtime contracts (node names, tool names, Send payload keys, roster text) + * mirror the Python adapter exactly so specs behave the same across SDKs. + */ +import type { BaseMessage, BaseMessageLike } from "@langchain/core/messages"; +import { HumanMessage, ToolMessage } from "@langchain/core/messages"; +import type { RunnableConfig } from "@langchain/core/runnables"; +import type { StructuredToolInterface } from "@langchain/core/tools"; +import { tool } from "@langchain/core/tools"; +import type { BaseCheckpointSaver } from "@langchain/langgraph"; +import { + Command, + END, + MessagesAnnotation, + START, + Send, + StateGraph, + getCurrentTaskInput, +} from "@langchain/langgraph"; +import type { ManagerWorkers } from "../../agents/index.js"; +import type { AgentNode } from "../../flows/index.js"; +import { renderTemplate } from "../common/index.js"; +import { AgentNodeExecutor } from "./node-execution.js"; +import { patchWithExecutionSpan } from "./tracing.js"; +import type { ExecuteOutput, NodeOutputs } from "./types.js"; + +/** + * Prefix of the synthetic `__delegate_to__` tool names the manager's + * LLM uses to address a worker. The dunder prefix, like the delegation keys + * below, keeps it from colliding with a real tool named + * `delegate_to_`. + */ +export const DELEGATE_TOOL_PREFIX = "__delegate_to__"; + +// Cannot collide with a worker node name: normalizeIdentifier strips leading +// and trailing underscores, so no normalized name ever starts with one. +const MANAGER_NODE_KEY = "__manager__"; + +// Keys of the per-delegation `Send` payload: the task to run, and the +// tool_call_id the worker's reply must answer. Routing per delegation +// (instead of off shared state) lets one manager turn delegate to several +// workers at once. +const DELEGATE_TASK_KEY = "__delegate_task__"; +const DELEGATE_CALL_ID_KEY = "__delegate_tool_call_id__"; + +/** True for the synthetic `__delegate_to__` tool names a manager emits. */ +export function isDelegationToolName(name: unknown): name is string { + return typeof name === "string" && name.startsWith(DELEGATE_TOOL_PREFIX); +} + +/** Lowercase, collapse non-alphanumerics to underscores, strip leading/trailing ones. */ +function normalizeIdentifier(s: string): string { + return s + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); +} + +/** + * Normalize a worker name into a LangGraph node identifier. + * + * The LLM has to emit `__delegate_to__` reliably as a tool name, + * so node names stay ASCII identifiers. Falls back to the normalized + * component id when the name slugifies to nothing. + */ +function safeNodeName(name: string, fallbackId: string): string { + return normalizeIdentifier(name) || normalizeIdentifier(fallbackId) || "worker"; +} + +/** Read `messages` off a state object, defensively copied to an array. */ +function messagesOf(state: unknown): unknown[] { + if (typeof state === "object" && state !== null) { + const messages = (state as { messages?: unknown }).messages; + if (Array.isArray(messages)) { + return [...messages]; + } + } + return []; +} + +/** + * Append an `Available workers:` block listing `- : `. + * + * Descriptions are flattened to one line each, since the LLM routes off the + * block's one-line-per-worker shape. + */ +function appendWorkersRoster( + systemPrompt: string, + entries: [string, string][], +): string { + if (entries.length === 0) { + return systemPrompt; + } + const lines = entries.map( + ([name, description]) => `- ${name}: ${description.replace(/\s+/g, " ").trim()}`, + ); + const roster = "Available workers:\n" + lines.join("\n"); + return systemPrompt ? `${systemPrompt}\n\n${roster}` : roster; +} + +/** + * Build the `__delegate_to__` tool the manager's LLM emits to route + * to a worker. + * + * Executing the tool is only how the call escapes the react subgraph: its + * body surfaces the subgraph messages to the parent with + * `Command({graph: Command.PARENT})` and no `goto`. Routing stays in the edge + * built by `makeManagerRouter`; a `goto` here would collapse several + * same-turn delegations into one parent Command and leave the other + * `tool_call_id`s unanswered. + */ +function makeWorkerDelegationTool( + workerNodeName: string, +): StructuredToolInterface { + const toolName = `${DELEGATE_TOOL_PREFIX}${workerNodeName}`; + const description = + `Delegate a task to the ${workerNodeName} worker and receive its response. ` + + `Use this when the task fits the worker's described capability.`; + return tool( + async () => { + // The task (and the tool_call_id) are declared for the LLM-facing + // schema; the routing edge recovers both off the surfaced AIMessage's + // tool_calls. The addMessages reducer dedupes by id, so re-surfacing + // messages is a no-op. `getCurrentTaskInput` is the JS equivalent of + // Python's InjectedState. + // + // JS divergence from Python (which returns an update-only parent + // command): `goto: END` is REQUIRED here. `Command#goto` defaults to + // `[]`, and langchain's ToolNode folds any parent command whose goto is + // an array of Sends (the empty array included) into a goto-only + // command, dropping the update — the manager's messages would never + // reach the parent graph. A truthy non-Send goto keeps the command + // intact end to end; END is harmless as a routed destination because + // the delegation Sends emitted by the router create their own tasks + // (verified against @langchain/langgraph 1.4.13 / langchain 1.5.10). + const state = getCurrentTaskInput(); + return new Command({ + graph: Command.PARENT, + goto: END, + update: { messages: messagesOf(state) }, + }); + }, + { + name: toolName, + description, + schema: { + type: "object", + properties: { task: { type: "string" } }, + required: ["task"], + }, + }, + ) as StructuredToolInterface; +} + +interface ToolCallLike { + name?: unknown; + args?: Record; + id?: unknown; +} + +/** + * Build the conditional edge routing the parent graph off the manager's last + * AIMessage: one `Send` per `__delegate_to__` tool call, or `END` + * when it emitted none. + * + * Every delegation gets its own `Send`, so each tool_call_id is answered + * independently; an unanswered one breaks the manager's next-turn + * tool-call/result sequence. Plain tool calls already ran inside the react + * loop; that includes a real tool whose name merely starts with the prefix, + * which is why a suffix that is not a worker node is not routed. + */ +function makeManagerRouter( + workerNodeNames: string[], +): (state: Record) => Send[] | string { + const knownWorkers = new Set(workerNodeNames); + return (state: Record): Send[] | string => { + const messages = Array.isArray(state["messages"]) ? state["messages"] : []; + const last = messages[messages.length - 1] as + | { tool_calls?: ToolCallLike[] } + | undefined; + const sends: Send[] = []; + for (const toolCall of last?.tool_calls ?? []) { + const name = toolCall?.name; + if (!isDelegationToolName(name)) { + continue; + } + const workerNodeName = name.slice(DELEGATE_TOOL_PREFIX.length); + if (!knownWorkers.has(workerNodeName)) { + continue; + } + const args = toolCall.args ?? {}; + const task = args["task"]; + sends.push( + new Send(workerNodeName, { + [DELEGATE_TASK_KEY]: (typeof task === "string" ? task : "") || "", + [DELEGATE_CALL_ID_KEY]: + (typeof toolCall.id === "string" ? toolCall.id : "") || "", + }), + ); + } + return sends.length > 0 ? sends : END; + }; +} + +/** A graph-like runtime object exposing `invoke`. */ +interface InvocableGraph { + invoke( + input: unknown, + config?: RunnableConfig, + ): Promise>; +} + +/** + * Wrap a worker subgraph as a node of the ManagerWorkers parent graph. + * + * Hierarchical rather than shared-state like a Swarm: each run is handed only + * the manager's chosen task, and the worker's answer comes back as a + * ToolMessage so the manager's react loop sees a well-formed tool response on + * its next turn. The worker receives this node's ambient run config + * explicitly, which streams its token events under the worker node's + * checkpoint namespace. + */ +function wrapWorkerForSubgraph( + workerGraph: unknown, + _workerNodeName: string, +): ( + state: Record, + config: RunnableConfig, +) => Promise> { + return async function workerNode( + state: Record, + config: RunnableConfig, + ): Promise> { + const task = state[DELEGATE_TASK_KEY]; + const input = { + messages: [ + new HumanMessage({ + content: (typeof task === "string" ? task : "") || "", + }), + ], + }; + const result = await (workerGraph as InvocableGraph).invoke(input, config); + const messages = Array.isArray(result?.["messages"]) + ? (result["messages"] as { content?: unknown }[]) + : []; + const lastMessage = messages[messages.length - 1]; + const content = lastMessage?.content ?? ""; + const callId = state[DELEGATE_CALL_ID_KEY]; + return { + messages: [ + new ToolMessage({ + content: (content as string) || "", + tool_call_id: (typeof callId === "string" ? callId : "") || "", + }), + ], + }; + }; +} + +/** Loosely-typed StateGraph surface for graphs with dynamic node names. */ +interface DynamicStateGraph { + addNode(key: string, action: unknown): DynamicStateGraph; + addEdge(start: string, end: string): DynamicStateGraph; + addConditionalEdges( + source: string, + path: (state: Record) => Send[] | string, + pathMap?: Record, + ): DynamicStateGraph; + compile(options?: { + checkpointer?: BaseCheckpointSaver; + name?: string; + }): unknown; +} + +/** Options for `compileManagerWorkers`. */ +export interface CompileManagerWorkersOptions { + /** Checkpointer wired into the compiled parent graph. */ + checkpointer?: BaseCheckpointSaver; + /** + * Overrides the group manager's system prompt (before the roster is + * appended). Used by `ManagerWorkersNodeExecutor` to bake rendered flow + * inputs into the prompt. + */ + systemPrompt?: string; + /** + * Compiles the group-manager Agent into a graph usable as the + * `__manager__` node, given the roster-augmented system prompt and the + * delegation tools to append. Provided by the converter (which owns react + * agent assembly). + */ + compileManagerAgent: ( + rosterSystemPrompt: string, + delegationTools: StructuredToolInterface[], + ) => Promise; + /** + * Converts one worker agentic component into an invocable graph. Provided + * by the converter (recursive conversion, memoized by component id). + */ + convertWorker: (worker: Record) => Promise; +} + +/** + * Compile a `ManagerWorkers` into a hierarchical LangGraph. + * + * Topology: + * + * ┌─ __delegate_to__w1 ─→ worker_1 ─┐ + * START → manager ┤ ├→ manager (loop) + * └─ __delegate_to__w2 ─→ worker_2 ─┘ + * │ + * └─ no tool_call ─→ END + * + * The manager is a react-agent holding one synthetic `__delegate_to__` + * tool per worker. A conditional edge routes each delegation to its worker, + * which runs in an isolated message context and answers with a `ToolMessage` + * matched to the pending delegation id. Workers are converted recursively and + * wired in as subgraph nodes. + */ +export async function compileManagerWorkers( + managerWorkers: ManagerWorkers, + options: CompileManagerWorkersOptions, +): Promise { + const groupManager = managerWorkers.groupManager; + if (groupManager["componentType"] !== "Agent") { + // Delegation is routed off the manager's tool_calls, so the manager needs + // a chat-LLM; a Flow, Swarm or nested ManagerWorkers gives nothing to + // route on. + throw new Error( + `ManagerWorkers.group_manager must be an Agent for LangGraph ` + + `conversion; got ${String(groupManager["componentType"])}.`, + ); + } + + const namedWorkers: [string, Record][] = + managerWorkers.workers.map((worker) => [ + safeNodeName(String(worker["name"] ?? ""), String(worker["id"] ?? "")), + worker, + ]); + const workerNodeNames = namedWorkers.map(([nodeName]) => nodeName); + if (new Set(workerNodeNames).size !== workerNodeNames.length) { + throw new Error( + "ManagerWorkers worker names collide after normalization: " + + `${JSON.stringify(workerNodeNames)}. Give each worker a unique name.`, + ); + } + + // The roster tells the LLM which delegation tool maps to which worker. + const basePrompt = + options.systemPrompt ?? String(groupManager["systemPrompt"] ?? ""); + const rosterPrompt = appendWorkersRoster( + basePrompt, + namedWorkers.map(([nodeName, worker]) => [ + nodeName, + String(worker["description"] ?? ""), + ]), + ); + + // The delegation tools execute inside the react loop: their + // Command({graph: PARENT}) is how the call escapes the subgraph so the + // conditional edge below can route on it. + const delegationTools = workerNodeNames.map((nodeName) => + makeWorkerDelegationTool(nodeName), + ); + const managerGraph = await options.compileManagerAgent( + rosterPrompt, + delegationTools, + ); + + // Manager and workers all go in as compiled subgraph nodes, which is what + // makes LangGraph stream them with `subgraph: true`. + const builder = new StateGraph( + MessagesAnnotation, + ) as unknown as DynamicStateGraph; + builder.addNode(MANAGER_NODE_KEY, managerGraph); + for (const [nodeName, worker] of namedWorkers) { + const workerGraph = await options.convertWorker(worker); + builder.addNode(nodeName, wrapWorkerForSubgraph(workerGraph, nodeName)); + // Workers always loop back to the manager. + builder.addEdge(nodeName, MANAGER_NODE_KEY); + } + + builder.addEdge(START, MANAGER_NODE_KEY); + const pathMap: Record = {}; + for (const nodeName of workerNodeNames) { + pathMap[nodeName] = nodeName; + } + pathMap[END] = END; + // The path map covers every worker plus END, so langgraph can validate the + // routing statically. + builder.addConditionalEdges( + MANAGER_NODE_KEY, + makeManagerRouter(workerNodeNames), + pathMap, + ); + + const compiledGraph = builder.compile({ + ...(options.checkpointer !== undefined + ? { checkpointer: options.checkpointer } + : {}), + name: managerWorkers.name, + }); + return patchWithExecutionSpan(compiledGraph); +} + +/** + * Executes an `AgentNode` whose agent is a `ManagerWorkers`. + * + * The hierarchical graph runs over `MessagesState`, which can carry neither + * structured inputs inward nor a `structured_response` outward. Inputs are + * therefore rendered into the group-manager's system prompt before compiling, + * and the manager's final message is the node's single string output. + */ +export class ManagerWorkersNodeExecutor extends AgentNodeExecutor { + private readonly agentNode: AgentNode; + private readonly managerWorkers: ManagerWorkers; + private readonly compileManagerWorkersFn: ( + renderedSystemPrompt: string, + ) => Promise; + private readonly invokeConfig: RunnableConfig; + /** Compiled graphs cached by rendered group-manager system prompt. */ + private readonly graphCache = new Map(); + + constructor( + node: AgentNode, + compileManagerWorkers: (renderedSystemPrompt: string) => Promise, + config: RunnableConfig, + ) { + super(node, compileManagerWorkers, config); + if (node.agent.componentType !== "ManagerWorkers") { + throw new Error( + "ManagerWorkersNodeExecutor requires an AgentNode holding a ManagerWorkers", + ); + } + this.agentNode = node; + this.managerWorkers = node.agent as ManagerWorkers; + this.compileManagerWorkersFn = compileManagerWorkers; + this.invokeConfig = config; + // Anything but a single string output cannot be honored (see class + // docstring); raising here fails at conversion time rather than mid-run. + const outputs = node.outputs ?? []; + if (outputs.length > 0 && (outputs.length !== 1 || outputs[0]!.type !== "string")) { + throw new Error( + "A ManagerWorkers flow step supports a single string output; " + + `node \`${node.name}\` declares ${JSON.stringify(outputs.map((o) => o.title))}.`, + ); + } + } + + /** + * Compile the `ManagerWorkers` with the node inputs rendered into the + * group-manager's system prompt, cached by rendered prompt (the same key + * `AgentNodeExecutor` uses for its react-agent cache). + */ + private async createManagerWorkersWithGivenInputValues( + inputs: NodeOutputs, + ): Promise { + const groupManager = this.managerWorkers.groupManager; + const systemPrompt = renderTemplate( + String(groupManager["systemPrompt"] ?? ""), + inputs, + ); + let graph = this.graphCache.get(systemPrompt); + if (graph === undefined) { + graph = await this.compileManagerWorkersFn(systemPrompt); + this.graphCache.set(systemPrompt, graph); + } + return graph; + } + + protected async _execute( + inputs: NodeOutputs, + messages: BaseMessage[], + ): Promise { + // Inputs were baked into the group-manager's prompt, so this graph runs + // on messages alone rather than the react-agent's remaining_steps state. + const graph = await this.createManagerWorkersWithGivenInputValues(inputs); + // LangGraph's agent expects at least one user message to drive execution. + const drivingMessages: BaseMessageLike[] = + messages.length > 0 ? messages : [{ role: "user", content: "" }]; + const result = await (graph as InvocableGraph).invoke( + { messages: drivingMessages }, + this.invokeConfig, + ); + const resultMessages = Array.isArray(result["messages"]) + ? (result["messages"] as { content?: unknown }[]) + : []; + const lastMessage = resultMessages[resultMessages.length - 1]; + const nodeOutputs = this.agentNode.outputs ?? []; + if (nodeOutputs.length === 0) { + return [ + {}, + { + generated_messages: [ + { role: "assistant", content: (lastMessage?.content ?? "") as string }, + ], + }, + ]; + } + // The constructor already rejected any shape but a single string output. + return [{ [nodeOutputs[0]!.title]: lastMessage?.content }, {}]; + } +} diff --git a/tsagentspec/src/adapters/langgraph/mcp.ts b/tsagentspec/src/adapters/langgraph/mcp.ts new file mode 100644 index 00000000..6f276932 --- /dev/null +++ b/tsagentspec/src/adapters/langgraph/mcp.ts @@ -0,0 +1,311 @@ +/** + * MCP tool conversion for the LangGraph adapter. + * + * Port of the MCP sections of + * `pyagentspec.adapters.langgraph._langgraphconverter` (transport mapping, + * tool loading and registry caching, MCPTool / MCPToolBox conversion) on top + * of `@langchain/mcp-adapters`. + * + * Divergences from Python (see the adapter README): + * - mTLS transports (SSEmTLSTransport, StreamableHTTPmTLSTransport) are not + * supported: `@langchain/mcp-adapters` connections have no TLS client-cert + * options (Python passes an httpx client factory). + * - `sessionParameters.readTimeoutSeconds` maps to the stdio connection's + * `defaultToolTimeout` (per tool call): Python passes it as the MCP + * session's per-request read timeout via stdio `session_kwargs` only, so + * only the tool-call path is covered on both sides. + * - Tools are loaded through a `MultiServerMCPClient` that keeps its + * connection open for the lifetime of the loaded tools (Python opens a + * fresh MCP session per tool call). + * - No tracing callbacks are attached to loaded tools (tracing is a no-op + * seam in v1). + */ +import type { StructuredToolInterface } from "@langchain/core/tools"; +import type { Connection } from "@langchain/mcp-adapters"; +import type { ClientTransport, MCPTool, MCPToolSpec } from "../../mcp/index.js"; +import type { JsonSchemaValue } from "../../property.js"; +import type { MCPToolBox } from "../../tools/index.js"; +import { jsonSchemasHaveSameType } from "../common/index.js"; +import type { ToolRegistry } from "./types.js"; + +type McpAdaptersModule = typeof import("@langchain/mcp-adapters"); + +async function importMcpAdaptersModule(): Promise { + try { + return await import("@langchain/mcp-adapters"); + } catch (error) { + throw new Error( + "@langchain/mcp-adapters is required to preload MCP tools. " + + "Install it (e.g., npm install @langchain/mcp-adapters) or remove MCP tools from the spec.", + { cause: error }, + ); + } +} + +/** + * Convert an AgentSpec MCP client transport into a `@langchain/mcp-adapters` + * connection. + * + * StdioTransport maps to a stdio connection, SSETransport to an "sse" + * connection and StreamableHTTPTransport to an "http" connection (static + * headers included). mTLS transports are not supported yet. + */ +export function convertClientTransport( + agentspecTransport: ClientTransport, +): Connection { + switch (agentspecTransport.componentType) { + case "StdioTransport": + return { + transport: "stdio", + command: agentspecTransport.command, + args: agentspecTransport.args, + ...(agentspecTransport.env !== undefined + ? { env: agentspecTransport.env } + : {}), + ...(agentspecTransport.cwd !== undefined + ? { cwd: agentspecTransport.cwd } + : {}), + // Python wires readTimeoutSeconds as the MCP session's per-request + // read timeout (stdio only); defaultToolTimeout is the JS analogue + // for the tool-call path. + ...(agentspecTransport.sessionParameters?.readTimeoutSeconds !== + undefined + ? { + defaultToolTimeout: + agentspecTransport.sessionParameters.readTimeoutSeconds * 1000, + } + : {}), + }; + case "SSETransport": + return { + transport: "sse", + url: agentspecTransport.url, + ...(agentspecTransport.headers !== undefined + ? { headers: agentspecTransport.headers } + : {}), + }; + case "StreamableHTTPTransport": + return { + transport: "http", + url: agentspecTransport.url, + ...(agentspecTransport.headers !== undefined + ? { headers: agentspecTransport.headers } + : {}), + }; + case "SSEmTLSTransport": + case "StreamableHTTPmTLSTransport": + throw new Error( + `The Agent Spec type '${agentspecTransport.componentType}' is not supported by the LangGraph TypeScript adapter yet.`, + ); + default: { + const componentType = (agentspecTransport as { componentType: string }) + .componentType; + throw new Error( + `Agent Spec ClientTransport '${componentType}' is not supported yet.`, + ); + } + } +} + +function getSessionToolsFromToolRegistry( + toolRegistry: ToolRegistry, + connPrefix: string, +): Record { + const sessionTools: Record = {}; + for (const [key, value] of Object.entries(toolRegistry)) { + if (key.startsWith(connPrefix)) { + sessionTools[key.slice(connPrefix.length)] = + value as StructuredToolInterface; + } + } + return sessionTools; +} + +function addSessionToolsToRegistry( + toolRegistry: ToolRegistry, + tools: StructuredToolInterface[], + connPrefix: string, +): void { + // Prepare a staged mapping so we can insert all-or-nothing. + const staged: Record = {}; + for (const loadedTool of tools) { + const toolName = (loadedTool as { name?: unknown }).name; + if (typeof toolName !== "string" || toolName.length === 0) { + throw new Error("Loaded a tool without a name attribute or __name__."); + } + const key = `${connPrefix}${toolName}`; + // Do not overwrite an existing entry if present. + if (Object.hasOwn(toolRegistry, key)) { + throw new Error( + "Trying to add the same tool twice; this might happen " + + "when the tool is declared as both a standalone MCPTool and part of a MCPToolBox", + ); + } + staged[key] = loadedTool; + } + // Commit staged entries. + Object.assign(toolRegistry, staged); +} + +/** + * Load the MCP tools exposed by a client transport and cache them in the + * tool registry under `${clientTransport.id}::${toolName}` keys. + * + * If any tools are already present in the registry for this transport, they + * are returned without reloading; otherwise all tools are loaded and inserted + * atomically. Returns a map of tool name to LangChain tool. + */ +export async function getOrCreateMcpTools( + clientTransport: ClientTransport, + connection: Connection, + toolRegistry: ToolRegistry, +): Promise> { + const connPrefix = `${clientTransport.id}::`; + const existing = getSessionToolsFromToolRegistry(toolRegistry, connPrefix); + if (Object.keys(existing).length > 0) { + return existing; + } + + const { MultiServerMCPClient } = await importMcpAdaptersModule(); + const serverName = clientTransport.id; + // The client stays referenced by the loaded tools; it is intentionally not + // closed here (closing it would break later tool invocations). + const client = new MultiServerMCPClient({ + mcpServers: { [serverName]: connection }, + }); + const tools = await client.getTools(serverName); + + addSessionToolsToRegistry(toolRegistry, tools, connPrefix); + + return getSessionToolsFromToolRegistry(toolRegistry, connPrefix); +} + +/** Shallow-copy a JSON schema, lowercasing its `title` when present. */ +function normalizeTitle(schema: JsonSchemaValue): JsonSchemaValue { + const out: JsonSchemaValue = { ...schema }; + if (typeof out["title"] === "string") { + out["title"] = out["title"].toLowerCase(); + } + return out; +} + +function areMcpToolSpecAndLangchainSchemasEqual( + mcpSpec: MCPToolSpec, + langchainTool: StructuredToolInterface, +): boolean { + const argsSchema = (langchainTool as { schema?: unknown }).schema; + if ( + typeof argsSchema !== "object" || + argsSchema === null || + Array.isArray(argsSchema) + ) { + throw new Error( + `Expected Langchain StructuredTool.args_schema to be a dict but got ${typeof argsSchema}`, + ); + } + const agentspecJsonSchemas: JsonSchemaValue = {}; + for (const input of mcpSpec.inputs ?? []) { + agentspecJsonSchemas[String(input.jsonSchema["title"])] = normalizeTitle( + input.jsonSchema, + ); + } + const remoteProperties = + ((argsSchema as JsonSchemaValue)["properties"] as + | Record + | undefined) ?? {}; + const langchainJsonSchemas: JsonSchemaValue = {}; + for (const [key, value] of Object.entries(remoteProperties)) { + langchainJsonSchemas[key] = normalizeTitle(value); + } + return jsonSchemasHaveSameType(agentspecJsonSchemas, langchainJsonSchemas); +} + +/** + * Convert an AgentSpec MCPTool: load (or reuse from the registry cache) the + * tools exposed by its transport and return the one with the tool's name. + * + * An already-converted connection may be passed to reuse the converter's + * memoized transport conversion. + */ +export async function convertMcpTool( + agentspecMcpTool: MCPTool, + toolRegistry: ToolRegistry, + connection?: Connection, +): Promise { + const resolvedConnection = + connection ?? convertClientTransport(agentspecMcpTool.clientTransport); + const exposedTools = await getOrCreateMcpTools( + agentspecMcpTool.clientTransport, + resolvedConnection, + toolRegistry, + ); + const exposedTool = exposedTools[agentspecMcpTool.name]; + if (exposedTool === undefined) { + // Python raises a bare KeyError here. + throw new Error( + `MCP tool '${agentspecMcpTool.name}' was not found in the tools exposed ` + + `by the MCP server for transport '${agentspecMcpTool.clientTransport.id}'.`, + ); + } + return exposedTool; +} + +/** + * Convert an AgentSpec MCPToolBox into the list of LangChain tools exposed by + * its transport, applying the toolbox's `toolFilter`. + * + * Filter entries may be tool names or MCPToolSpec objects; specs are + * validated against the remote tool's argument schema. Without a filter, all + * tools are returned. An already-converted connection may be passed to reuse + * the converter's memoized transport conversion. + */ +export async function convertMcpToolbox( + agentspecMcpToolbox: MCPToolBox, + toolRegistry: ToolRegistry, + connection?: Connection, +): Promise { + const resolvedConnection = + connection ?? convertClientTransport(agentspecMcpToolbox.clientTransport); + const remoteTools = await getOrCreateMcpTools( + agentspecMcpToolbox.clientTransport, + resolvedConnection, + toolRegistry, + ); + // Normalize filter to name -> MCPToolSpec | null (null when the filter + // entry is a plain string). + const filterMap = new Map(); + for (const filterEntry of agentspecMcpToolbox.toolFilter ?? []) { + if (typeof filterEntry === "string") { + filterMap.set(filterEntry, null); + } else { + filterMap.set(filterEntry.name, filterEntry); + } + } + // If no filter provided, return all tools. + if (filterMap.size === 0) { + return Object.values(remoteTools); + } + // Find missing by name first (own-keys membership like Python's dict, so + // filter names like "constructor" cannot resolve to inherited functions). + const missing = [...filterMap.keys()] + .filter((name) => !Object.hasOwn(remoteTools, name)) + .sort(); + if (missing.length > 0) { + throw new Error("Missing tools: " + missing.join(", ")); + } + // Validate specs (when provided) and collect tools in filter order. + for (const [name, spec] of filterMap) { + const remoteTool = remoteTools[name]!; + if ( + spec !== null && + !areMcpToolSpecAndLangchainSchemasEqual(spec, remoteTool) + ) { + throw new Error( + `Input descriptors mismatch for tool '${spec.name}'.\n` + + `Local: ${JSON.stringify(spec)}\n` + + `Remote: ${JSON.stringify((remoteTool as { schema?: unknown }).schema)}`, + ); + } + } + return [...filterMap.keys()].map((name) => remoteTools[name]!); +} diff --git a/tsagentspec/src/adapters/langgraph/node-execution.ts b/tsagentspec/src/adapters/langgraph/node-execution.ts new file mode 100644 index 00000000..d1636a3c --- /dev/null +++ b/tsagentspec/src/adapters/langgraph/node-execution.ts @@ -0,0 +1,1249 @@ +/** + * Flow node executors for the LangGraph adapter. + * + * Port of `pyagentspec.adapters.langgraph._node_execution`: one executor per + * Agent Spec flow node type, each turning the shared flow state into node + * inputs, executing the node, and folding outputs / routing details back into + * the state. + * + * Runtime contracts (state keys, branch names, interrupt payloads, + * error-message text) mirror the Python adapter exactly so specs behave the + * same across both SDKs. + * + * Divergences from Python (see the adapter README): + * - Execution is async-only (no sync `__call__` / thread offloading). + * - Executors never mutate the incoming state: they return updated copies + * with the same accumulate semantics as Python's in-place mutation. + * - Executors receive their collaborators from the converter (converted + * tools, chat models, compiled subgraphs, agent compile factories) instead + * of importing the converter, so there are no module cycles. + * - Node execution spans/events are not emitted (tracing is a no-op seam). + * - JS has no tuple type: arrays map positionally onto multiple declared + * tool-node outputs where Python only accepts tuples. + * - The react-agent invoke payload adds no `remaining_steps` / + * `structured_response` keys: the langchain JS agent state has neither + * channel (structured output lands in `structuredResponse`). + */ +import type { BaseMessage } from "@langchain/core/messages"; +import type { RunnableConfig } from "@langchain/core/runnables"; +import { addMessages, interrupt } from "@langchain/langgraph"; +import type { + AgentNode, + ApiNode, + BranchingNode, + CatchExceptionNode, + EndNode, + FlowNode, + InputMessageNode, + LlmNode, + MapNode, + OutputMessageNode, + StartNode, + ToolNode, +} from "../../flows/index.js"; +import { + CAUGHT_EXCEPTION_BRANCH, + DEFAULT_BRANCH, + DEFAULT_INPUT_MESSAGE_OUTPUT, + DEFAULT_NEXT_BRANCH, +} from "../../flows/index.js"; +import type { DataFlowEdge } from "../../flows/index.js"; +import type { Property } from "../../property.js"; +import { + fetchWithAdapterDefaults, + maybeWarnAboutUnrestrictedTemplatedUrl, + renderNestedObjectTemplate, + renderTemplate, + stringifyTemplateValue, + validateUrlAgainstAllowList, +} from "../common/index.js"; +import type { + ExecuteOutput, + FlowState, + NextNodeInputs, + NodeExecutionDetails, + NodeOutputs, +} from "./types.js"; + +/** The structural surface of an Agent Spec flow node used by the executors. */ +interface FlowNodeLike { + id: string; + name: string; + inputs?: Property[]; + outputs?: Property[]; +} + +/** A compiled graph / react agent surface: everything invocable. */ +interface InvocableGraph { + invoke( + input: unknown, + config?: RunnableConfig, + ): Promise>; +} + +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Serialize one string the way Python's `json.dumps` does (ensure_ascii). */ +function pythonJsonDumpsString(value: string): string { + let out = '"'; + for (const ch of value) { + const code = ch.codePointAt(0)!; + if (ch === '"') out += '\\"'; + else if (ch === "\\") out += "\\\\"; + else if (ch === "\b") out += "\\b"; + else if (ch === "\f") out += "\\f"; + else if (ch === "\n") out += "\\n"; + else if (ch === "\r") out += "\\r"; + else if (ch === "\t") out += "\\t"; + else if (code < 0x20 || code > 0x7e) { + if (code > 0xffff) { + // ensure_ascii escapes astral characters as a surrogate pair. + const high = 0xd800 + ((code - 0x10000) >> 10); + const low = 0xdc00 + ((code - 0x10000) & 0x3ff); + out += `\\u${high.toString(16).padStart(4, "0")}`; + out += `\\u${low.toString(16).padStart(4, "0")}`; + } else { + out += `\\u${code.toString(16).padStart(4, "0")}`; + } + } else out += ch; + } + return out + '"'; +} + +/** + * Serialize a value the way Python's `json.dumps` does with its default + * arguments: `", "` / `": "` separators, ensure_ascii `\uXXXX` escapes, and + * `Infinity`/`-Infinity`/`NaN` literals (allow_nan). Used when casting + * non-string values into `string`-typed properties so the resulting flow + * state text matches the Python adapter byte-for-byte. + */ +export function pythonJsonDumps(value: unknown): string { + if (value === null || value === undefined) return "null"; + if (typeof value === "boolean") return value ? "true" : "false"; + if (typeof value === "number") { + if (Number.isFinite(value)) return JSON.stringify(value); + if (value === Infinity) return "Infinity"; + if (value === -Infinity) return "-Infinity"; + return "NaN"; + } + if (typeof value === "string") return pythonJsonDumpsString(value); + if (Array.isArray(value)) { + return `[${value.map((item) => pythonJsonDumps(item)).join(", ")}]`; + } + if (typeof value === "object") { + const entries = Object.entries(value as Record) + .filter(([, v]) => v !== undefined && typeof v !== "function") + .map(([k, v]) => `${pythonJsonDumpsString(k)}: ${pythonJsonDumps(v)}`); + return `{${entries.join(", ")}}`; + } + return JSON.stringify(value) ?? "null"; +} + +/** Digit run with Python's underscore separators (`1_000`, not `1__0`). */ +const PY_DIGITS = String.raw`\d(?:_?\d)*`; + +/** Python `int()` string grammar: optional sign + underscore-separated digits. */ +const PYTHON_INT_REGEXP = new RegExp(`^[+-]?${PY_DIGITS}$`); + +/** Python `float()` numeric grammar (decimal/scientific, no hex/binary/octal). */ +const PYTHON_FLOAT_REGEXP = new RegExp( + `^[+-]?(?:(?:${PY_DIGITS})?\\.${PY_DIGITS}|${PY_DIGITS}\\.?)(?:[eE][+-]?${PY_DIGITS})?$`, +); + +/** + * Parse a (trimmed) string with Python `float()` semantics: decimal and + * scientific forms plus `inf`/`infinity`/`nan` (any case, optional sign) and + * underscore digit separators. Returns `undefined` for anything Python's + * `float()` rejects (hex/binary/octal literals, `1__0`, empty strings, ...). + */ +function parsePythonFloat(text: string): number | undefined { + const unsigned = text.toLowerCase().replace(/^[+-]/, ""); + if (unsigned === "inf" || unsigned === "infinity") { + return text.startsWith("-") ? -Infinity : Infinity; + } + if (unsigned === "nan") return NaN; + if (!PYTHON_FLOAT_REGEXP.test(text)) return undefined; + const parsed = Number(text.replace(/_/g, "")); + return Number.isNaN(parsed) ? undefined : parsed; +} + +/** + * Cast the given values to the types declared by the properties and add + * missing defaults, mirroring Python's `_cast_values_and_add_defaults`: + * non-strings are `json.dumps`-serialized into `string` properties, numbers + * become booleans, numeric strings parse into `integer`/`number` properties + * (an unparsable integer string raises like Python's `int()`; an unparsable + * number string is left as-is like Python's swallowed `float()` error), and + * a property with neither value nor default raises. Values for undeclared + * properties are dropped. + */ +export function castValuesAndAddDefaults( + valuesDict: Record, + properties: Property[], + nodeName: string, +): NodeOutputs { + const resultsDict: NodeOutputs = {}; + for (const property of properties) { + const key = property.title; + if (Object.hasOwn(valuesDict, key)) { + let value = valuesDict[key]; + const propertyType = property.type; + if (propertyType === "string" && typeof value !== "string") { + value = pythonJsonDumps(value); + } else if (propertyType === "boolean" && typeof value === "number") { + value = Boolean(value); + } else if (propertyType === "integer" && typeof value === "boolean") { + value = value ? 1 : 0; + } else if (propertyType === "integer" && typeof value === "number") { + value = Math.trunc(value); + } else if (propertyType === "integer" && typeof value === "string") { + // Python does `int(value.strip())` and re-raises for any unparsable + // string (its error-message guard never matches `int()`'s text), so + // an unparsable integer string aborts the flow here too. + const trimmed = value.trim(); + if (PYTHON_INT_REGEXP.test(trimmed)) { + value = parseInt(trimmed.replace(/_/g, ""), 10); + } else { + // Python raises ValueError with this exact message (repr'd value). + throw new Error( + `invalid literal for int() with base 10: ${JSON.stringify(trimmed)}`, + ); + } + } else if (propertyType === "number" && typeof value === "boolean") { + value = value ? 1 : 0; + } else if (propertyType === "number" && typeof value === "string") { + // Try converting numeric strings to floats with Python `float()` + // semantics; if the parse fails, leave the string as-is (Python + // swallows the `could not convert string to float:` error). + const parsed = parsePythonFloat(value.trim()); + if (parsed !== undefined) { + value = parsed; + } + } + resultsDict[key] = value; + } else if (property.default !== undefined) { + resultsDict[key] = property.default; + } else { + throw new Error( + `Expected node \`${nodeName}\` to have a value ` + + `for property \`${property.title}\`, but none was found.`, + ); + } + } + return resultsDict; +} + +/** + * Extract the outputs of an agent invoke result for the expected output + * properties, merging (in increasing priority) property defaults, the + * structured response, and top-level result entries. Reads the langchain JS + * `structuredResponse` key, falling back to Python's `structured_response`. + */ +export function extractOutputsFromInvokeResult( + result: Record, + expectedOutputs: Property[], +): NodeOutputs { + const outputs: NodeOutputs = {}; + for (const output of expectedOutputs) { + if (output.default !== undefined) { + outputs[output.title] = output.default; + } + } + const structuredResponse = + result["structuredResponse"] ?? result["structured_response"]; + if (isPlainRecord(structuredResponse)) { + Object.assign(outputs, structuredResponse); + } + for (const output of expectedOutputs) { + if (Object.hasOwn(result, output.title)) { + outputs[output.title] = result[output.title]; + } + } + return outputs; +} + +/** + * Base class of the flow node executors. + * + * `call` is the LangGraph node function: it selects this node's pending + * inputs from the state, casts them against the declared input properties, + * executes the node, and returns the updated flow state (accumulated inputs + * routing table, cast outputs, merged messages and execution details). + */ +export abstract class NodeExecutor< + TNode extends FlowNodeLike = FlowNodeLike, +> { + protected readonly node: TNode; + protected readonly edges: DataFlowEdge[] = []; + + constructor(node: TNode) { + this.node = node; + } + + /** Attach a data-flow edge whose source is this node. */ + attachEdge(edge: DataFlowEdge): void { + this.edges.push(edge); + } + + /** Execute this node against the current flow state (LangGraph node fn). */ + async call(state: FlowState, _config?: RunnableConfig): Promise { + const inputs = this.getInputs(state); + const [outputs, executionDetails] = await this._execute( + inputs, + state.messages ?? [], + ); + return this.updateStatus(outputs, executionDetails, state); + } + + /** Execute the node with the given cast inputs; returns outputs + details. */ + protected abstract _execute( + inputs: NodeOutputs, + messages: BaseMessage[], + ): Promise; + + /** + * Retrieve the inputs for this node (the `state.inputs` entries keyed by + * this node's id), adding default values when missing and casting to the + * declared types. + */ + protected getInputs(state: FlowState): NodeOutputs { + const nodeInputs = state.inputs?.[this.node.id]; + const ioInputs: Record = isPlainRecord(nodeInputs) + ? { ...nodeInputs } + : {}; + return castValuesAndAddDefaults( + ioInputs, + this.node.inputs ?? [], + this.node.name, + ); + } + + /** + * Fold the node outputs and execution details into the flow state: cast the + * outputs, route them along the attached data-flow edges into the pending + * inputs of downstream nodes (accumulating into a copy of the previous + * routing table), default the execution details, and merge generated + * messages via LangGraph's `addMessages`. + */ + protected updateStatus( + outputs: NodeOutputs, + executionDetails: NodeExecutionDetails, + previousState: FlowState, + ): FlowState { + const castOutputs = castValuesAndAddDefaults( + outputs, + this.node.outputs ?? [], + this.node.name, + ); + const nextNodeInputs: NextNodeInputs = { ...(previousState.inputs ?? {}) }; + for (const edge of this.edges) { + const destinationNodeId = String(edge.destinationNode["id"]); + const existing = nextNodeInputs[destinationNodeId]; + const destinationInputs: Record = isPlainRecord(existing) + ? { ...existing } + : {}; + if (!Object.hasOwn(castOutputs, edge.sourceOutput)) { + // Python raises a bare KeyError here. + throw new Error( + `Node \`${this.node.name}\` produced no output ` + + `\`${edge.sourceOutput}\` required by data-flow edge \`${edge.name}\`.`, + ); + } + destinationInputs[edge.destinationInput] = castOutputs[edge.sourceOutput]; + nextNodeInputs[destinationNodeId] = destinationInputs; + } + + const details: NodeExecutionDetails = { + branch: executionDetails.branch ?? DEFAULT_NEXT_BRANCH, + generated_messages: executionDetails.generated_messages ?? [], + should_finish: executionDetails.should_finish ?? false, + }; + return { + inputs: nextNodeInputs, + outputs: castOutputs, + messages: addMessages( + previousState.messages ?? [], + details.generated_messages ?? [], + ), + node_execution_details: details, + }; + } +} + +/** + * Executes a StartNode: consumes the flow-level invocation inputs (plain + * string keys at the top level of `state.inputs`) and passes them through as + * outputs, flowing to downstream nodes along the data edges. + */ +export class StartNodeExecutor extends NodeExecutor { + protected override getInputs(state: FlowState): NodeOutputs { + // At StartNode time the state inputs hold the flow's initial call inputs + // as plain `{inputName: value}` keys (no node-id nesting): consume all of + // them (they are removed from the state in updateStatus below). + const ioInputs: Record = { ...(state.inputs ?? {}) }; + return castValuesAndAddDefaults( + ioInputs, + this.node.inputs ?? [], + this.node.name, + ); + } + + protected override updateStatus( + outputs: NodeOutputs, + executionDetails: NodeExecutionDetails, + previousState: FlowState, + ): FlowState { + // Python pops the consumed flow-level inputs out of the state; the + // non-mutating equivalent is starting the routing table from scratch. + return super.updateStatus(outputs, executionDetails, { + ...previousState, + inputs: {}, + }); + } + + protected async _execute( + inputs: NodeOutputs, + _messages: BaseMessage[], + ): Promise { + return [inputs, {}]; + } +} + +/** + * Executes an EndNode: passes its inputs through as outputs, reshapes them to + * the flow's declared outputs, and marks the run finished on the node's + * branch. + */ +export class EndNodeExecutor extends NodeExecutor { + private flowOutputs: Property[] = []; + + /** Give the executor the flow outputs used to reshape the final state. */ + setFlowOutputs(flowOutputs: Property[]): void { + this.flowOutputs = flowOutputs; + } + + protected async _execute( + inputs: NodeOutputs, + _messages: BaseMessage[], + ): Promise { + return [inputs, { branch: this.node.branchName, should_finish: true }]; + } + + protected override updateStatus( + outputs: NodeOutputs, + executionDetails: NodeExecutionDetails, + previousState: FlowState, + ): FlowState { + const newState = super.updateStatus( + outputs, + executionDetails, + previousState, + ); + const nodeOutputs = newState.outputs; + const filteredOutputs: NodeOutputs = {}; + for (const property of this.flowOutputs) { + filteredOutputs[property.title] = Object.hasOwn( + nodeOutputs, + property.title, + ) + ? nodeOutputs[property.title] + : property.default; + } + for (const [propertyName, propertyValue] of Object.entries(nodeOutputs)) { + if (propertyValue === undefined) { + throw new Error( + `EndNode \`${this.node.name}\` exited without any value generated for property \`${propertyName}\``, + ); + } + } + return { ...newState, outputs: filteredOutputs }; + } +} + +/** + * Executes a BranchingNode: reads its first input and selects the branch its + * mapping points to (the `default` branch when the value is unmapped). + */ +export class BranchingNodeExecutor extends NodeExecutor { + constructor(node: BranchingNode) { + super(node); + if (!node.inputs || node.inputs.length === 0) { + throw new Error("BranchingNode requires at least one input"); + } + } + + protected async _execute( + inputs: NodeOutputs, + _messages: BaseMessage[], + ): Promise { + const nodeInputs = this.node.inputs ?? []; + const inputBranchPropTitle = nodeInputs[0]!.title; + const inputBranchName = Object.hasOwn(inputs, inputBranchPropTitle) + ? inputs[inputBranchPropTitle] + : DEFAULT_BRANCH; + const selectedBranch = + typeof inputBranchName === "string" && + Object.hasOwn(this.node.mapping, inputBranchName) + ? this.node.mapping[inputBranchName]! + : DEFAULT_BRANCH; + return [{}, { branch: selectedBranch }]; + } +} + +/** True for a list of MCP-style content blocks (text / image / file). */ +function isMcpContentBlocksList(items: unknown[]): boolean { + // Empty lists are ambiguous; treat them as non-MCP to avoid false positives + if (items.length === 0) { + return false; + } + for (const element of items) { + if (!isPlainRecord(element)) { + return false; + } + const blockType = element["type"]; + if (blockType !== "text" && blockType !== "image" && blockType !== "file") { + return false; + } + if (blockType === "text") { + if (typeof element["text"] !== "string") { + return false; + } + } else if ( + !("base64" in element) && + !("url" in element) && + !("file_id" in element) + ) { + return false; + } + } + return true; +} + +/** Extract the payload of one MCP content block. */ +function extractValueFromContentBlock(block: Record): unknown { + const blockType = block["type"]; + if (blockType === "text") { + return block["text"]; + } + if (blockType === "image" || blockType === "file") { + if ("base64" in block) { + return block["base64"]; + } + if ("url" in block) { + return block["url"]; + } + if ("file_id" in block) { + return block["file_id"]; + } + throw new Error( + `No payload found in ${blockType} block: ${JSON.stringify(block)}`, + ); + } + throw new Error( + `Unsupported message content block type: ${String(blockType)}`, + ); +} + +/** + * Executes a ToolNode: invokes the converted LangChain tool with the node + * inputs and maps the raw tool output onto the node's declared output + * properties (MCP content-block lists map positionally; dicts are filtered; + * arrays map positionally onto multiple outputs). + */ +export class ToolNodeExecutor extends NodeExecutor { + private readonly toolCallable: InvocableGraph; + + constructor(node: ToolNode, tool: unknown) { + super(node); + if ( + typeof tool !== "object" || + tool === null || + typeof (tool as { invoke?: unknown }).invoke !== "function" + ) { + throw new Error( + `ToolNodeExecutor expected a LangChain StructuredTool, but got ${typeof tool}.`, + ); + } + this.toolCallable = tool as InvocableGraph; + } + + /** Best-effort mapping of raw tool outputs to the declared node outputs. */ + private formatToolResult(toolOutput: unknown): ExecuteOutput { + const nodeOutputProperties = this.node.outputs ?? []; + let mapped: NodeOutputs; + if (Array.isArray(toolOutput) && isMcpContentBlocksList(toolOutput)) { + const extractedValues = (toolOutput as Record[]).map( + (block) => extractValueFromContentBlock(block), + ); + mapped = {}; + nodeOutputProperties.forEach((property, i) => { + if (i >= extractedValues.length) { + // Python raises a bare IndexError ("list index out of range") here. + throw new Error( + `Tool node \`${this.node.name}\` returned ${extractedValues.length} ` + + `content block(s) but declares ${nodeOutputProperties.length} ` + + `outputs; no value for output \`${property.title}\`.`, + ); + } + mapped[property.title] = extractedValues[i]; + }); + } else if (nodeOutputProperties.length === 1) { + // The tool returns a dict with a single key being the node's output + // property's title: use it as-is to avoid double-wrapping. + const onlyTitle = nodeOutputProperties[0]!.title; + if ( + isPlainRecord(toolOutput) && + Object.keys(toolOutput).length === 1 && + Object.hasOwn(toolOutput, onlyTitle) + ) { + mapped = toolOutput; + } else { + mapped = { [onlyTitle]: toolOutput }; + } + } else if (isPlainRecord(toolOutput)) { + // The node emits multiple outputs: filter the tool output. + mapped = {}; + for (const property of nodeOutputProperties) { + if (Object.hasOwn(toolOutput, property.title)) { + mapped[property.title] = toolOutput[property.title]; + } + } + } else if (Array.isArray(toolOutput)) { + // Multiple outputs from an array (Python: tuple): map positionally. + mapped = {}; + nodeOutputProperties.forEach((property, i) => { + if (i >= toolOutput.length) { + // Python raises a bare IndexError ("tuple index out of range") here. + throw new Error( + `Tool node \`${this.node.name}\` returned ${toolOutput.length} ` + + `value(s) but declares ${nodeOutputProperties.length} ` + + `outputs; no value for output \`${property.title}\`.`, + ); + } + mapped[property.title] = toolOutput[i]; + }); + } else { + throw new Error( + `Unsupported multi-output mapping for tool_output: ${stringifyTemplateValue(toolOutput)}` + + `(declared_outputs=${nodeOutputProperties.length}).`, + ); + } + return [mapped, {}]; + } + + protected async _execute( + inputs: NodeOutputs, + _messages: BaseMessage[], + ): Promise { + const toolOutput = await this.toolCallable.invoke(inputs); + return this.formatToolResult(toolOutput); + } +} + +/** + * Executes an AgentNode holding a plain Agent: renders the agent's system + * prompt against the node inputs, compiles (and caches) a react agent per + * rendered prompt through the converter-provided factory, and invokes it on + * the flow messages. + */ +export class AgentNodeExecutor extends NodeExecutor { + private readonly compileAgent: ( + renderedSystemPrompt: string, + ) => Promise; + protected readonly config: RunnableConfig; + /** Compiled agents cached by rendered system prompt. */ + private readonly agentsCache = new Map(); + + constructor( + node: AgentNode, + compileAgent: (renderedSystemPrompt: string) => Promise, + config: RunnableConfig, + ) { + super(node); + this.compileAgent = compileAgent; + this.config = config; + } + + private async createReactAgentWithGivenInputValues( + inputs: NodeOutputs, + ): Promise { + if (this.node.agent.componentType !== "Agent") { + throw new Error( + "AgentNodeExecutor can only be used with AgentSpecAgent agents", + ); + } + const agentComponent = this.node.agent as { systemPrompt?: unknown }; + const systemPrompt = renderTemplate( + String(agentComponent.systemPrompt ?? ""), + inputs, + ); + let agent = this.agentsCache.get(systemPrompt); + if (agent === undefined) { + agent = await this.compileAgent(systemPrompt); + this.agentsCache.set(systemPrompt, agent); + } + return agent as InvocableGraph; + } + + /** LangGraph's agent expects at least one user message to drive execution. */ + protected withDrivingMessage(messages: BaseMessage[]): unknown[] { + return messages.length > 0 ? messages : [{ role: "user", content: "" }]; + } + + /** Map an agent invoke result onto the node outputs (or a chat message). */ + protected formatAgentResult(result: Record): ExecuteOutput { + const nodeOutputs = this.node.outputs ?? []; + if (nodeOutputs.length === 0) { + const messages = Array.isArray(result["messages"]) + ? (result["messages"] as { content?: unknown }[]) + : []; + const generatedMessage = messages[messages.length - 1]; + return [ + {}, + { + generated_messages: [ + { + role: "assistant", + content: (generatedMessage?.content ?? "") as string, + }, + ], + }, + ]; + } + return [extractOutputsFromInvokeResult(result, nodeOutputs), {}]; + } + + protected async _execute( + inputs: NodeOutputs, + messages: BaseMessage[], + ): Promise { + const agent = await this.createReactAgentWithGivenInputValues(inputs); + const preparedInputs: Record = { + ...inputs, + messages: this.withDrivingMessage(messages), + }; + const result = await agent.invoke(preparedInputs, this.config); + return this.formatAgentResult(result); + } +} + +/** + * Executes an InputMessageNode: interrupts the graph with an empty-string + * payload; the resume value becomes both the node output and a new user + * message. + */ +export class InputMessageNodeExecutor extends NodeExecutor { + protected async _execute( + _inputs: NodeOutputs, + _messages: BaseMessage[], + ): Promise { + const response = interrupt(""); + const outputs = this.node.outputs ?? []; + const outputName = + outputs.length > 0 ? outputs[0]!.title : DEFAULT_INPUT_MESSAGE_OUTPUT; + return [ + { [outputName]: response }, + { + generated_messages: [ + { role: "user", content: response as string }, + ], + }, + ]; + } +} + +/** + * Executes an OutputMessageNode: renders the node's message template against + * the inputs and emits it as an assistant message. + */ +export class OutputMessageNodeExecutor extends NodeExecutor { + protected async _execute( + inputs: NodeOutputs, + _messages: BaseMessage[], + ): Promise { + const message = renderTemplate(this.node.message, inputs); + return [ + {}, + { generated_messages: [{ role: "assistant", content: message }] }, + ]; + } +} + +/** The chat-model surface the LlmNodeExecutor relies on. */ +interface ChatModelLike { + invoke(input: unknown, config?: unknown): Promise; + withStructuredOutput?(schema: Record): { + invoke(input: unknown, config?: unknown): Promise; + }; +} + +/** + * Executes an LlmNode: renders the prompt template against the inputs and + * invokes the chat model, using structured output whenever the declared + * outputs are anything but a single string. + */ +export class LlmNodeExecutor extends NodeExecutor { + private readonly llm: ChatModelLike; + private readonly requiresStructuredGeneration: boolean; + private readonly structuredLlm: + | { invoke(input: unknown, config?: unknown): Promise } + | undefined; + + constructor(node: LlmNode, llm: unknown) { + super(node); + if ( + typeof llm !== "object" || + llm === null || + typeof (llm as { invoke?: unknown }).invoke !== "function" + ) { + throw new Error("Llm can only be initialized with a BaseChatModel"); + } + this.llm = llm as ChatModelLike; + + const nodeOutputs = node.outputs ?? []; + this.requiresStructuredGeneration = !( + nodeOutputs.length === 1 && nodeOutputs[0]!.type === "string" + ); + if (this.requiresStructuredGeneration) { + if (typeof this.llm.withStructuredOutput !== "function") { + throw new Error( + "Llm can only be initialized with a BaseChatModel supporting withStructuredOutput", + ); + } + const jsonSchema: Record = { + // Title is required by langgraph + title: "structured_output", + type: "object", + properties: Object.fromEntries( + nodeOutputs.map((output) => [output.title, output.jsonSchema]), + ), + }; + this.structuredLlm = this.llm.withStructuredOutput(jsonSchema); + } else { + this.structuredLlm = undefined; + } + } + + private buildInvokeInputs(inputs: NodeOutputs): unknown[] { + const renderedPrompt = renderTemplate(this.node.promptTemplate, inputs); + return [{ role: "user", content: renderedPrompt }]; + } + + private formatStructuredOutput( + nodeOutputs: Property[], + generatedRaw: unknown, + ): NodeOutputs { + if (!isPlainRecord(generatedRaw)) { + throw new Error( + `Expected structured LLM to return a dict, got ${typeof generatedRaw}`, + ); + } + let generatedOutput: NodeOutputs = generatedRaw; + // LangGraph sometimes flattens a 1-property nested object; rebuild if needed + if ( + nodeOutputs.length === 1 && + nodeOutputs[0]!.title !== Object.keys(generatedOutput)[0] + ) { + generatedOutput = { [nodeOutputs[0]!.title]: generatedOutput }; + } + return generatedOutput; + } + + private formatUnstructuredOutput( + nodeOutputs: Property[], + generatedMessage: unknown, + ): NodeOutputs { + const outputName = + nodeOutputs.length > 0 ? nodeOutputs[0]!.title : "generated_text"; + if ( + typeof generatedMessage !== "object" || + generatedMessage === null || + !("content" in generatedMessage) + ) { + throw new Error( + "generated_message should not be a dict when not doing structured generation", + ); + } + return { + [outputName]: (generatedMessage as { content?: unknown }).content, + }; + } + + protected async _execute( + inputs: NodeOutputs, + _messages: BaseMessage[], + ): Promise { + const invokeInputs = this.buildInvokeInputs(inputs); + const nodeOutputs = this.node.outputs ?? []; + if (this.requiresStructuredGeneration) { + if (this.structuredLlm === undefined) { + throw new Error("Structured LLM was not initialized"); + } + const generatedRaw = await this.structuredLlm.invoke(invokeInputs); + return [this.formatStructuredOutput(nodeOutputs, generatedRaw), {}]; + } + const generatedMessage = await this.llm.invoke(invokeInputs); + return [this.formatUnstructuredOutput(nodeOutputs, generatedMessage), {}]; + } +} + +/** + * Executes an ApiNode: renders `{{placeholder}}` templates in the URL, data, + * headers and query params against the inputs, performs the HTTP request and + * returns the parsed JSON response body as the node output. + */ +export class ApiNodeExecutor extends NodeExecutor { + constructor(node: ApiNode) { + super(node); + // The TS SDK ApiNode has no urlAllowList field yet: the helpers are + // invoked with `undefined` (i.e. allow), matching the documented + // divergence, so the templated-URL warning fires per the Python rules. + maybeWarnAboutUnrestrictedTemplatedUrl( + node.url, + undefined, + `ApiNode \`${node.name}\``, + ); + } + + private buildRequest(inputs: NodeOutputs): { + url: string; + init: RequestInit; + } { + const apiNode = this.node; + const apiNodeData = renderNestedObjectTemplate(apiNode.data, inputs); + const apiNodeHeaders: Record = {}; + for (const [key, value] of Object.entries(apiNode.headers)) { + apiNodeHeaders[renderTemplate(key, inputs)] = renderNestedObjectTemplate( + value, + inputs, + ); + } + const apiNodeQueryParams: Record = {}; + for (const [key, value] of Object.entries(apiNode.queryParams)) { + apiNodeQueryParams[renderTemplate(key, inputs)] = + renderNestedObjectTemplate(value, inputs); + } + const apiNodeUrl = renderTemplate(apiNode.url, inputs); + + const contentTypeHeader = + apiNodeHeaders["Content-Type"] ?? apiNodeHeaders["content-type"]; + const expectUrlencodedFormData = + typeof contentTypeHeader === "string" && + contentTypeHeader.includes("application/x-www-form-urlencoded"); + + const requestHeaders: Record = {}; + for (const [key, value] of Object.entries(apiNodeHeaders)) { + requestHeaders[key] = + typeof value === "string" ? value : stringifyTemplateValue(value); + } + const callerSetContentType = Object.keys(requestHeaders).some( + (key) => key.toLowerCase() === "content-type", + ); + + const method = apiNode.httpMethod; + const methodUpper = method.toUpperCase(); + // fetch forbids request bodies on GET/HEAD (Python's httpx sends them). + const methodAllowsBody = methodUpper !== "GET" && methodUpper !== "HEAD"; + if (!methodAllowsBody) { + const hasDeclaredBody = + apiNodeData !== undefined && + apiNodeData !== null && + apiNodeData !== "" && + !(isPlainRecord(apiNodeData) && Object.keys(apiNodeData).length === 0); + if (hasDeclaredBody) { + // Forced divergence from Python: warn instead of silently dropping. + console.warn( + `ApiNode \`${apiNode.name}\` declares request data for HTTP method ` + + `${methodUpper}, but fetch forbids request bodies on GET/HEAD: ` + + `the declared body is not sent (the Python adapter sends it).`, + ); + } + } + + let body: string | URLSearchParams | Uint8Array | undefined; + if (methodAllowsBody) { + if (expectUrlencodedFormData && isPlainRecord(apiNodeData)) { + const form = new URLSearchParams(); + for (const [key, value] of Object.entries(apiNodeData)) { + form.append( + key, + typeof value === "string" ? value : stringifyTemplateValue(value), + ); + } + body = form; + } else if (typeof apiNodeData === "string") { + body = apiNodeData; + } else if (apiNodeData instanceof Uint8Array) { + body = apiNodeData; + } else if (apiNodeData !== undefined && apiNodeData !== null) { + body = JSON.stringify(apiNodeData); + if (!callerSetContentType) { + requestHeaders["Content-Type"] = "application/json"; + } + } + } + + // Kept as the seam for allow-list enforcement: the TS SDK ApiNode has no + // urlAllowList field yet, so this always allows. + validateUrlAgainstAllowList(apiNodeUrl, undefined); + + const searchParams = new URLSearchParams(); + for (const [key, value] of Object.entries(apiNodeQueryParams)) { + if (Array.isArray(value)) { + for (const item of value) { + searchParams.append( + key, + item == null ? "" : stringifyTemplateValue(item), + ); + } + } else { + searchParams.append( + key, + value == null ? "" : stringifyTemplateValue(value), + ); + } + } + const query = searchParams.toString(); + const requestUrl = + query.length > 0 + ? `${apiNodeUrl}${apiNodeUrl.includes("?") ? "&" : "?"}${query}` + : apiNodeUrl; + + return { + url: requestUrl, + init: { + method, + headers: requestHeaders, + ...(body !== undefined ? { body } : {}), + }, + }; + } + + protected async _execute( + inputs: NodeOutputs, + _messages: BaseMessage[], + ): Promise { + const { url, init } = this.buildRequest(inputs); + // Redirects are not followed and the request times out after the shared + // default, matching Python's httpx defaults (see fetchWithAdapterDefaults). + const response = await fetchWithAdapterDefaults( + url, + init, + `ApiNode \`${this.node.name}\``, + ); + // Python parses the JSON body regardless of the HTTP status (a 3xx + // response returned without following included). + const responseJson = (await response.json()) as unknown; + return [responseJson as NodeOutputs, {}]; + } +} + +/** + * Executes a FlowNode: invokes the compiled subflow with this node's inputs + * and messages; the subflow's outputs become the node outputs and its + * terminating EndNode branch propagates as this node's branch. + */ +export class FlowNodeExecutor extends NodeExecutor { + private readonly subflow: InvocableGraph; + private readonly config: RunnableConfig; + + constructor(node: FlowNode, subflow: unknown, config: RunnableConfig) { + super(node); + this.subflow = subflow as InvocableGraph; + this.config = config; + } + + protected async _execute( + inputs: NodeOutputs, + messages: BaseMessage[], + ): Promise { + const flowOutput = await this.subflow.invoke( + { messages, inputs }, + this.config, + ); + const details = flowOutput["node_execution_details"] as + | NodeExecutionDetails + | undefined; + return [ + (flowOutput["outputs"] ?? {}) as NodeOutputs, + { branch: details?.branch ?? DEFAULT_NEXT_BRANCH }, + ]; + } +} + +/** + * Executes a CatchExceptionNode: invokes the compiled subflow; on success the + * subflow outputs pass through with `caught_exception_info: null`, and on + * error the subflow's declared output defaults are emitted with the error + * message on the `caught_exception_branch`. + */ +export class CatchExceptionNodeExecutor extends NodeExecutor { + private readonly subflow: InvocableGraph; + private readonly config: RunnableConfig; + + constructor( + node: CatchExceptionNode, + subflow: unknown, + config: RunnableConfig, + ) { + super(node); + this.subflow = subflow as InvocableGraph; + this.config = config; + } + + protected async _execute( + inputs: NodeOutputs, + messages: BaseMessage[], + ): Promise { + try { + const flowOutput = await this.subflow.invoke( + { messages, inputs }, + this.config, + ); + const outputs: NodeOutputs = isPlainRecord(flowOutput["outputs"]) + ? { ...(flowOutput["outputs"] as NodeOutputs) } + : {}; + // As per the spec, when the subflow runs without error + // `caught_exception_info` is null. + outputs["caught_exception_info"] = null; + const details = flowOutput["node_execution_details"] as + | NodeExecutionDetails + | undefined; + return [outputs, { branch: details?.branch ?? DEFAULT_NEXT_BRANCH }]; + } catch (error) { + // Python emits an ExceptionRaised event on the current node span here; + // tracing is a no-op seam in the TS adapter, so nothing is emitted. + const defaultOutputs: NodeOutputs = {}; + const subflowOutputs = + (this.node.subflow["outputs"] as Property[] | undefined) ?? []; + for (const property of subflowOutputs) { + // Use default value for subflow outputs when exception occurs + defaultOutputs[property.title] = property.default; + } + defaultOutputs["caught_exception_info"] = + error instanceof Error ? error.message : String(error); + return [defaultOutputs, { branch: CAUGHT_EXCEPTION_BRANCH }]; + } + } +} + +/** + * Executes a MapNode: iterates the compiled subflow over the `iterated_` + * inputs the converter selected (broadcasting the others) and appends each + * run's subflow outputs into the node's `collected_` outputs. + */ +export class MapNodeExecutor extends NodeExecutor { + private readonly subflow: InvocableGraph; + private inputsToIterate: string[] = []; + + constructor(node: MapNode, subflow: unknown, _config: RunnableConfig) { + super(node); + if (!node.inputs || node.inputs.length === 0) { + throw new Error("MapNode has no inputs"); + } + // Mirroring Python, the subflow runs are not passed the ambient config. + this.subflow = subflow as InvocableGraph; + } + + /** Set which inputs to iterate over (decided by the converter). */ + setInputsToIterate(inputsToIterate: string[]): void { + this.inputsToIterate = inputsToIterate; + } + + private prepareIterations(inputs: NodeOutputs): { + subflowInputsList: Record[]; + outputs: Record; + } { + const outputs: Record = {}; + for (const output of this.node.outputs ?? []) { + outputs[output.title] = []; + } + + if (this.inputsToIterate.length === 0) { + throw new Error("MapNode has no inputs to iterate"); + } + + let numInputsToIterate: number | undefined; + for (const inputName of this.inputsToIterate) { + const iterable = inputs[inputName]; + const size = + Array.isArray(iterable) || typeof iterable === "string" + ? iterable.length + : undefined; + if (size === undefined) { + throw new Error( + `Found inputs to iterate with different sizes (${stringifyTemplateValue(iterable)} and ${String(numInputsToIterate)})`, + ); + } + if (numInputsToIterate === undefined) { + numInputsToIterate = size; + } else if (size !== numInputsToIterate) { + throw new Error( + `Found inputs to iterate with different sizes (${stringifyTemplateValue(iterable)} and ${numInputsToIterate})`, + ); + } + } + if (numInputsToIterate === undefined) { + throw new Error( + "MapNode inputs_to_iterate did not match any provided inputs", + ); + } + + const subflowInputsList: Record[] = []; + for (let i = 0; i < numInputsToIterate; i += 1) { + const subInputs: Record = {}; + for (const inputProperty of this.node.inputs ?? []) { + const title = inputProperty.title; + // Note: Python strips every `iterated_` occurrence here (str.replace + // with no count), not just the prefix. + const subflowInputName = title.replaceAll("iterated_", ""); + if (this.inputsToIterate.includes(title)) { + const collection = inputs[title]; + subInputs[subflowInputName] = Array.isArray(collection) + ? collection[i] + : typeof collection === "string" + ? collection[i] + : undefined; + } else { + subInputs[subflowInputName] = inputs[title]; + } + } + subflowInputsList.push(subInputs); + } + return { subflowInputsList, outputs }; + } + + private accumulateOutputs( + outputs: Record, + subflowOutputs: Record, + ): void { + for (const [outputName, outputValue] of Object.entries(subflowOutputs)) { + const collectedOutputName = `collected_${outputName}`; + // Not all outputs might be exposed: keep only those the node declares. + const collected = outputs[collectedOutputName]; + if (collected !== undefined) { + collected.push(outputValue); + } + } + } + + protected async _execute( + inputs: NodeOutputs, + messages: BaseMessage[], + ): Promise { + const { subflowInputsList, outputs } = this.prepareIterations(inputs); + for (const subflowInputs of subflowInputsList) { + const subflowResult = await this.subflow.invoke({ + inputs: subflowInputs, + messages, + }); + const subflowOutputs = subflowResult["outputs"]; + if (isPlainRecord(subflowOutputs)) { + this.accumulateOutputs(outputs, subflowOutputs); + } + } + return [outputs, {}]; + } +} diff --git a/tsagentspec/src/adapters/langgraph/tools.ts b/tsagentspec/src/adapters/langgraph/tools.ts new file mode 100644 index 00000000..beaf6c0d --- /dev/null +++ b/tsagentspec/src/adapters/langgraph/tools.ts @@ -0,0 +1,368 @@ +/** + * Tool conversion for the LangGraph adapter. + * + * Port of the tool sections of + * `pyagentspec.adapters.langgraph._langgraphconverter`: ServerTool / + * ClientTool / RemoteTool conversion, the confirmation-interrupt machinery, + * and the checkpointer requirements for interrupting tools. + * + * Runtime contracts (interrupt payload shapes, error-message text) mirror the + * Python adapter exactly so specs behave the same across both SDKs. + * + * Divergences from Python (see the adapter README): + * - JS tools take a single input object, so there is no positional-args path: + * client tool interrupts always carry `inputs: { args: [], kwargs }`, and + * Python's "Args are not supported, please only use kwargs" branch cannot + * trigger. + * - Python's sync `func` / async `coroutine` pair collapses into one function + * (JS is async-native). + * - Interpolated values in mirrored error/interrupt messages are rendered with + * `JSON.stringify` instead of Python's `repr`. + * - No tracing callbacks are attached (tracing is a no-op seam in v1). + */ +import type { StructuredToolInterface } from "@langchain/core/tools"; +import { isStructuredTool, tool } from "@langchain/core/tools"; +import type { BaseCheckpointSaver } from "@langchain/langgraph"; +import { interrupt } from "@langchain/langgraph"; +import type { JsonSchemaValue, Property } from "../../property.js"; +import type { + ClientTool, + RemoteTool, + ServerTool, + Tool, +} from "../../tools/index.js"; +import { + buildJsonSchemaFromProperties, + createRemoteToolFunc, +} from "../common/index.js"; +import type { ToolRegistry } from "./types.js"; + +const ALLOWED_DECISIONS = ["approve", "reject"]; + +/** A tool implementation function: receives the parsed input object. */ +export type ToolFunction = (input: unknown, config?: unknown) => unknown; + +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Merge each declared input property's default into the tool-call input when + * the key is absent, mirroring the default injection performed by Python's + * pydantic argument models (langchain JS does not apply JSON-schema + * defaults). Applied before the confirmation wrapper so confirmation and + * client-tool interrupt payloads carry the defaults, like Python. + */ +function applyInputDefaults( + input: Record, + properties: Property[], +): Record { + const record: Record = { ...input }; + for (const property of properties) { + if ( + property.default !== undefined && + !Object.hasOwn(record, property.title) + ) { + record[property.title] = property.default; + } + } + return record; +} + +/** + * Build the JSON-schema argument schema for an AgentSpec tool from its input + * properties (the schema is titled `${toolName}Args`, mirroring Python's + * generated pydantic model name). + */ +export function buildArgsSchema( + toolName: string, + properties: Property[], +): JsonSchemaValue { + return buildJsonSchemaFromProperties(`${toolName}Args`, properties); +} + +/** + * Validate the checkpointer requirements of a tool: tools with + * `requiresConfirmation` and all ClientTools interrupt at runtime, which + * requires a checkpointer. + */ +export function ensureCheckpointerAndValidToolConfig( + agentspecTool: Tool, + checkpointer: BaseCheckpointSaver | undefined, +): void { + const toolName = agentspecTool.name; + if (agentspecTool.requiresConfirmation && checkpointer == null) { + throw new Error( + `A Checkpointer is required for tool '${toolName}' because requires_confirmation=True`, + ); + } else if ( + agentspecTool.componentType === "ClientTool" && + checkpointer == null + ) { + throw new Error( + `A Checkpointer is required when using ClientTool '${toolName}'.`, + ); + } +} + +/** + * Interrupt the graph asking the user to approve or reject a tool execution. + * + * The interrupt payload and the resume shape are aligned with the langchain + * human-in-the-loop docs and mirror the Python adapter exactly. Returns a + * `[approved, reason]` tuple. + */ +export function confirmToolUse( + toolName: string, + toolArguments: Record, +): [boolean, string] { + const confirmationPayload = { + action_requests: [ + { + name: toolName, + arguments: toolArguments, + description: `Tool execution pending approval\n\nTool: ${toolName}\nArgs: ${JSON.stringify(toolArguments)}`, + }, + ], + review_configs: [ + { + action_name: toolName, + allowed_decisions: ALLOWED_DECISIONS, + description: + 'Please resume with {"decisions": [{"type": "approve"}]} # or "reject" ' + + 'with an optional "reason" for rejected tool calls.', + }, + ], + }; + const response = interrupt( + confirmationPayload, + ); + if (!isPlainRecord(response) || !("decisions" in response)) { + throw new Error( + `Tool confirmation result for tool ${toolName} is not valid, should be a ` + + `dict with a 'decisions' key, was ${JSON.stringify(response)} of type ${typeof response}.`, + ); + } + const decisions = response["decisions"]; + const decisionList = Array.isArray(decisions) ? decisions : []; + if (decisionList.length !== 1) { + throw new Error( + `Tool confirmation result for tool ${toolName} is not valid, decisions ` + + `should be of length 1, was of length ${decisionList.length}`, + ); + } + const decision: unknown = decisionList[0]; + if ( + !isPlainRecord(decision) || + !("type" in decision) || + typeof decision["type"] !== "string" || + !ALLOWED_DECISIONS.includes(decision["type"]) + ) { + throw new Error( + `Tool confirmation result for tool ${toolName} is not valid, ` + + `decision should be in ['approve', 'reject'], was ${JSON.stringify(decision)}.`, + ); + } + const reason = + decision["reason"] !== undefined + ? String(decision["reason"]) + : "No reason was provided."; + return [decision["type"] === "approve", reason]; +} + +/** + * Wrap a tool function so that it first interrupts for confirmation (when + * required). A rejected confirmation throws + * `Tool '' was denied by the user (reason: ).`. + */ +export function confirmThen( + func: ToolFunction, + toolName: string, + requiresConfirmation: boolean, +): ToolFunction { + if (!requiresConfirmation) { + return func; + } + return function confirmedToolFunction( + input: unknown, + config?: unknown, + ): unknown { + const confirmationArguments = isPlainRecord(input) + ? input + : { args: [input] }; + const [confirmed, reason] = confirmToolUse(toolName, confirmationArguments); + if (!confirmed) { + throw new Error( + `Tool '${toolName}' was denied by the user (reason: ${reason}).`, + ); + } + return func(input, config); + }; +} + +/** + * Convert an AgentSpec ServerTool into a LangChain structured tool using its + * implementation from the tool registry. + * + * The registry value may be a LangChain structured tool (its name, + * description and schema are reused; it must expose a callable `func`) or a + * plain (sync or async) function (name, description and argument schema come + * from the AgentSpec tool). `requiresConfirmation` wraps the implementation + * with a confirmation interrupt. + */ +export function convertServerTool( + agentspecServerTool: ServerTool, + toolRegistry: ToolRegistry, +): StructuredToolInterface { + const toolName = agentspecServerTool.name; + // Own-keys membership like Python's dict: `in` would walk the prototype + // chain and let names like "constructor" resolve to inherited functions. + if (!Object.hasOwn(toolRegistry, toolName)) { + throw new Error( + `The Agent Spec representation includes a tool '${toolName}' ` + + `but this tool does not appear in the tool registry`, + ); + } + const toolObj = toolRegistry[toolName]; + const toolDescription = agentspecServerTool.description ?? ""; + const requiresConfirmation = agentspecServerTool.requiresConfirmation; + + if (isStructuredTool(toolObj as StructuredToolInterface)) { + // A LangChain tool instance from the registry: reuse its name, + // description and schema; wrap its implementation function. Python's + // StructuredTool-vs-other-BaseTool split collapses here since every + // LangChain JS tool exposes the same surface. + const registeredTool = toolObj as StructuredToolInterface & { + func?: unknown; + }; + const registeredFunc = registeredTool.func; + if (typeof registeredFunc !== "function") { + throw new Error( + `Unsupported tool type for '${toolName}': StructuredTool has neither func nor coroutine.`, + ); + } + if (registeredTool.schema == null) { + throw new Error( + `Unsupported tool type for '${toolName}': StructuredTool has no args_schema.`, + ); + } + // Bridge the calling conventions: a registered tool's `func` is invoked + // as `(input, runManager, parentConfig)`, while the wrapper created by + // `tool()` below invokes our function as `(input, config)`. + const registeredCallable: ToolFunction = (input, config) => + ( + registeredFunc as ( + input: unknown, + runManager?: unknown, + parentConfig?: unknown, + ) => unknown + )(input, undefined, config); + const wrapped = confirmThen( + registeredCallable, + toolName, + requiresConfirmation, + ); + return tool(wrapped as (input: unknown) => unknown, { + name: registeredTool.name, + description: registeredTool.description, + schema: registeredTool.schema, + }) as StructuredToolInterface; + } + if (typeof toolObj === "function") { + const toolInputs = agentspecServerTool.inputs ?? []; + const wrapped = confirmThen( + toolObj as ToolFunction, + toolName, + requiresConfirmation, + ); + const withDefaults: ToolFunction = (input, config) => + wrapped( + isPlainRecord(input) ? applyInputDefaults(input, toolInputs) : input, + config, + ); + return tool(withDefaults as (input: unknown) => unknown, { + name: toolName, + description: toolDescription, + schema: buildArgsSchema(toolName, toolInputs), + }) as StructuredToolInterface; + } + throw new Error( + `Unsupported tool type for '${toolName}': ${typeof toolObj}. ` + + `Expected callable, StructuredTool, or supported BaseTool.`, + ); +} + +/** + * Convert an AgentSpec ClientTool into a LangChain structured tool whose + * implementation interrupts the graph with a `client_tool_request` payload; + * the resume value is returned as the tool result. + */ +export function convertClientTool( + agentspecClientTool: ClientTool, +): StructuredToolInterface { + const toolName = agentspecClientTool.name; + const toolDescription = agentspecClientTool.description ?? ""; + const requiresConfirmation = agentspecClientTool.requiresConfirmation; + + const clientToolFunc = (kwargs: unknown): unknown => { + const kwargsRecord = applyInputDefaults( + isPlainRecord(kwargs) ? kwargs : {}, + agentspecClientTool.inputs ?? [], + ); + if (requiresConfirmation) { + const [confirmed, reason] = confirmToolUse(toolName, kwargsRecord); + if (!confirmed) { + throw new Error( + `Tool '${toolName}' was denied by the user (reason: ${reason}).`, + ); + } + } + const toolRequest = { + type: "client_tool_request", + name: toolName, + description: toolDescription, + inputs: { + args: [] as unknown[], + kwargs: kwargsRecord, + }, + }; + return interrupt(toolRequest); + }; + + // Note: no tool execution callback is attached, matching Python. + return tool(clientToolFunc, { + name: toolName, + description: toolDescription, + schema: buildArgsSchema(toolName, agentspecClientTool.inputs ?? []), + }) as StructuredToolInterface; +} + +/** + * Convert an AgentSpec RemoteTool into a LangChain structured tool wrapping + * the shared remote-tool fetch executor, with confirmation wrapping when + * `requiresConfirmation` is set. + */ +export function convertRemoteTool( + agentspecRemoteTool: RemoteTool, +): StructuredToolInterface { + const toolName = agentspecRemoteTool.name; + const toolDescription = agentspecRemoteTool.description ?? ""; + const toolInputs = agentspecRemoteTool.inputs ?? []; + const remoteToolFunc = createRemoteToolFunc(agentspecRemoteTool); + const wrapped = confirmThen( + (input: unknown) => + remoteToolFunc(isPlainRecord(input) ? input : {}), + toolName, + agentspecRemoteTool.requiresConfirmation, + ); + const withDefaults: ToolFunction = (input, config) => + wrapped( + applyInputDefaults(isPlainRecord(input) ? input : {}, toolInputs), + config, + ); + return tool(withDefaults as (input: unknown) => unknown, { + name: toolName, + description: toolDescription, + schema: buildArgsSchema(toolName, toolInputs), + }) as StructuredToolInterface; +} diff --git a/tsagentspec/src/adapters/langgraph/tracing.ts b/tsagentspec/src/adapters/langgraph/tracing.ts new file mode 100644 index 00000000..b03ddef7 --- /dev/null +++ b/tsagentspec/src/adapters/langgraph/tracing.ts @@ -0,0 +1,55 @@ +/** + * Tracing seams for the LangGraph adapter. + * + * The Python adapter attaches tracing callbacks and execution spans at three + * kinds of sites: LLM callbacks on every converted chat model, tool callbacks + * on converted server/remote/MCP tools, and stream-wrapping execution spans on + * every compiled agent / flow / manager-workers graph. + * + * The TypeScript SDK has no tracing package yet, so these functions are no-op + * seams: they are invoked from the exact same attachment sites as Python so + * that a future port of `pyagentspec.tracing` only needs to fill in the + * implementations here (returning real `BaseCallbackHandler`s and wrapping + * `stream`/`streamEvents` in execution spans) without touching the converter. + */ +import type { LlmConfig } from "../../llms/index.js"; +import type { Tool } from "../../tools/index.js"; + +/** + * Build the tracing callbacks to attach to a chat model created for the given + * Agent Spec LLM config. + * + * Python attaches an `AgentSpecLlmCallbackHandler` emitting + * `LlmGenerationRequest` / `LlmGenerationChunkReceived` / + * `LlmGenerationResponse` events inside an `LlmGenerationSpan`. No-op until + * the tracing package is ported. + */ +export function buildLlmCallbacks(_llmConfig: LlmConfig): unknown[] { + return []; +} + +/** + * Build the tracing callbacks to attach to a LangChain tool created for the + * given Agent Spec tool. + * + * Python attaches an `AgentSpecToolCallbackHandler` emitting + * `ToolExecutionRequest` / `ToolExecutionResponse` events inside a + * `ToolExecutionSpan`. No-op until the tracing package is ported. + */ +export function buildToolCallbacks(_tool: Tool): unknown[] { + return []; +} + +/** + * Wrap a compiled graph (or react agent) so each run is traced inside an + * execution span. + * + * Python monkey-patches `stream`/`astream` to open an + * `AgentExecutionSpan` / `FlowExecutionSpan` / `ManagerWorkersExecutionSpan`, + * emit the start event with the invocation inputs, fold the streamed chunks + * into a final state and emit the end event with the run outputs. Returns the + * graph unchanged until the tracing package is ported. + */ +export function patchWithExecutionSpan(graph: T): T { + return graph; +} diff --git a/tsagentspec/src/adapters/langgraph/types.ts b/tsagentspec/src/adapters/langgraph/types.ts new file mode 100644 index 00000000..87469421 --- /dev/null +++ b/tsagentspec/src/adapters/langgraph/types.ts @@ -0,0 +1,54 @@ +/** + * Shared types for the LangGraph adapter. + * + * Runtime contracts (state keys, node names, interrupt payloads) mirror the Python + * `pyagentspec.adapters.langgraph` adapter exactly so that specs behave the same + * across both SDKs. + */ +import type { BaseMessage, BaseMessageLike } from "@langchain/core/messages"; +import type { RunnableConfig } from "@langchain/core/runnables"; +import type { BaseCheckpointSaver } from "@langchain/langgraph"; + +/** Execution metadata produced by every flow node step. */ +export interface NodeExecutionDetails { + should_finish?: boolean; + branch?: string; + generated_messages?: BaseMessageLike[]; +} + +/** Outputs produced by a node execution, keyed by output property title. */ +export type NodeOutputs = Record; + +/** + * Pending inputs for downstream nodes: nodeId -> {inputTitle: value}. + * Flow-level inputs are stored under plain string keys (consumed by the StartNode). + */ +export type NextNodeInputs = Record; + +/** State schema of a compiled AgentSpec Flow graph (keys mirror the Python adapter). */ +export interface FlowState { + inputs: NextNodeInputs; + outputs: NodeOutputs; + messages: BaseMessage[]; + node_execution_details: NodeExecutionDetails; +} + +/** Result of a node executor: outputs plus execution details. */ +export type ExecuteOutput = [NodeOutputs, NodeExecutionDetails]; + +/** + * Registry mapping tool names to runtime implementations: a LangChain structured + * tool or a plain (sync or async) function. MCP tools are cached here under + * `${clientTransportId}::${toolName}` keys. + */ +export type ToolRegistry = Record; + +/** Options threaded through AgentSpec-to-LangGraph conversion. */ +export interface ConvertOptions { + /** Per-call conversion cache keyed by component id; pre-seed to inject fakes. */ + convertedComponents?: Map; + checkpointer?: BaseCheckpointSaver; + config?: RunnableConfig; + /** LangChain agent middleware, forwarded to createAgent in order (index 0 outermost). */ + middleware?: unknown[]; +} diff --git a/tsagentspec/src/serialization/index.ts b/tsagentspec/src/serialization/index.ts index 9eb20102..77c575e6 100644 --- a/tsagentspec/src/serialization/index.ts +++ b/tsagentspec/src/serialization/index.ts @@ -44,5 +44,8 @@ export { export { VERSION_GATED_FIELDS } from "./version-gates.js"; // Main serializer/deserializer -export { AgentSpecSerializer } from "./serializer.js"; +export { + AgentSpecSerializer, + type DisaggregatedComponentsConfig, +} from "./serializer.js"; export { AgentSpecDeserializer } from "./deserializer.js"; diff --git a/tsagentspec/src/serialization/serializer.ts b/tsagentspec/src/serialization/serializer.ts index 68eaa767..c061cbeb 100644 --- a/tsagentspec/src/serialization/serializer.ts +++ b/tsagentspec/src/serialization/serializer.ts @@ -12,6 +12,20 @@ import { SerializationContext } from "./serialization-context.js"; import { BuiltinsComponentSerializationPlugin } from "./builtin-serialization-plugin.js"; import type { SerializedDict, DisaggregatedComponentsDict } from "./types.js"; +/** + * Configuration of the components to disaggregate upon serialization, + * mirroring Python's `DisaggregatedComponentsConfigT`. Each item is either: + * + * - a `ComponentBase`: disaggregated under its own id, or + * - a `[ComponentBase, string]` pair: disaggregated under the custom id. The + * custom id is applied only as the serialization-time mapping key (the + * `$referenced_components` registry key and the `$component_ref` target); + * the component itself keeps its own `id` everywhere it is serialized. + */ +export type DisaggregatedComponentsConfig = ReadonlyArray< + ComponentBase | readonly [ComponentBase, string] +>; + export class AgentSpecSerializer { private plugins: ComponentSerializationPlugin[]; @@ -30,7 +44,7 @@ export class AgentSpecSerializer { component: ComponentBase, options?: { agentspecVersion?: AgentSpecVersion; - disaggregatedComponents?: ComponentBase[]; + disaggregatedComponents?: DisaggregatedComponentsConfig; exportDisaggregatedComponents?: boolean; camelCase?: boolean; includeSensitiveFields?: boolean; @@ -50,15 +64,35 @@ export class AgentSpecSerializer { ); } - // Build ID mapping for disaggregated components + // Normalize the disaggregated config to [component, mappedId] pairs and + // build the id mapping (component id -> registry key). Like Python, a + // custom id is only the serialization-time mapping key: the component + // keeps its own id inside its serialized dump. + const convertedConfig: Array = []; const componentsIdMapping = new Map(); - for (const disag of disaggregated) { - componentsIdMapping.set(disag.id, disag.id); + for (const entry of disaggregated) { + if (Array.isArray(entry)) { + if (entry.length !== 2 || typeof entry[1] !== "string") { + throw new Error( + `Invalid disaggregated_components entry: ${JSON.stringify(entry)}`, + ); + } + const [disagComponent, mappedId] = entry as readonly [ + ComponentBase, + string, + ]; + convertedConfig.push([disagComponent, mappedId]); + componentsIdMapping.set(disagComponent.id, mappedId); + } else { + const disagComponent = entry as ComponentBase; + convertedConfig.push([disagComponent, disagComponent.id]); + componentsIdMapping.set(disagComponent.id, disagComponent.id); + } } // Serialize disaggregated components separately const disaggregatedDict: Record = {}; - for (const disag of disaggregated) { + for (const [disag, mappedId] of convertedConfig) { if (disag === component) { throw new Error("Cannot disaggregate the root component"); } @@ -68,7 +102,7 @@ export class AgentSpecSerializer { includeSensitiveFields: includeSensitive, }); const dump = disagCtx.saveToDict(disag, opts.agentspecVersion); - disaggregatedDict[disag.id] = dump; + disaggregatedDict[mappedId] = dump; } // Serialize the main component @@ -100,7 +134,7 @@ export class AgentSpecSerializer { component: ComponentBase, options?: { agentspecVersion?: AgentSpecVersion; - disaggregatedComponents?: ComponentBase[]; + disaggregatedComponents?: DisaggregatedComponentsConfig; exportDisaggregatedComponents?: boolean; indent?: number; camelCase?: boolean; @@ -124,7 +158,7 @@ export class AgentSpecSerializer { component: ComponentBase, options?: { agentspecVersion?: AgentSpecVersion; - disaggregatedComponents?: ComponentBase[]; + disaggregatedComponents?: DisaggregatedComponentsConfig; exportDisaggregatedComponents?: boolean; camelCase?: boolean; includeSensitiveFields?: boolean; diff --git a/tsagentspec/tests/adapters/common/component-policy.test.ts b/tsagentspec/tests/adapters/common/component-policy.test.ts new file mode 100644 index 00000000..f739b913 --- /dev/null +++ b/tsagentspec/tests/adapters/common/component-policy.test.ts @@ -0,0 +1,200 @@ +/** + * Tests for the adapter component load policy. + * + * Ports `pyagentspec.serialization.componentpolicy` semantics: without an + * allow list everything is allowed unless blocked; with one, only matching + * types load. Concrete entries beat abstract group entries beat the + * `Component` wildcards, and block entries win same-distance ties. The + * loaders block `StdioTransport` by default. + */ +import { describe, expect, it } from "vitest"; +import { + createAgent, + createMCPTool, + createStdioTransport, + createVllmConfig, +} from "../../../src/index.js"; +import { ComponentLoadPolicy } from "../../../src/adapters/common/component-policy.js"; +import { AgentSpecLoader } from "../../../src/adapters/langgraph/agentspec-loader.js"; + +function blockedError(componentType: string): string { + return `Loading Agent Spec component type \`${componentType}\` is in the block list.`; +} + +function notAllowedError(componentType: string): string { + return `Loading Agent Spec component type \`${componentType}\` is not in the allow list.`; +} + +describe("ComponentLoadPolicy.validateComponentType", () => { + it("allows everything by default", () => { + const policy = new ComponentLoadPolicy(); + expect(() => policy.validateComponentType("Agent")).not.toThrow(); + expect(() => policy.validateComponentType("StdioTransport")).not.toThrow(); + expect(() => policy.validateComponentType("SomePluginType")).not.toThrow(); + }); + + it("blocks a concrete component type with the Python error text", () => { + const policy = new ComponentLoadPolicy(undefined, ["StdioTransport"]); + expect(() => policy.validateComponentType("StdioTransport")).toThrow( + blockedError("StdioTransport"), + ); + expect(() => policy.validateComponentType("SSETransport")).not.toThrow(); + }); + + it("restricts loading to the allow list when one is given", () => { + const policy = new ComponentLoadPolicy(["Agent"]); + expect(() => policy.validateComponentType("Agent")).not.toThrow(); + expect(() => policy.validateComponentType("Swarm")).toThrow( + notAllowedError("Swarm"), + ); + }); + + it("accepts a single string as the policy input", () => { + const policy = new ComponentLoadPolicy("Agent", "Swarm"); + expect(() => policy.validateComponentType("Agent")).not.toThrow(); + expect(() => policy.validateComponentType("Swarm")).toThrow( + blockedError("Swarm"), + ); + }); + + it("matches abstract group names against their concrete members", () => { + const allowPolicy = new ComponentLoadPolicy(["LlmConfig"]); + expect(() => allowPolicy.validateComponentType("VllmConfig")).not.toThrow(); + expect(() => allowPolicy.validateComponentType("Agent")).toThrow( + notAllowedError("Agent"), + ); + + const blockPolicy = new ComponentLoadPolicy(undefined, ["Tool"]); + expect(() => blockPolicy.validateComponentType("ServerTool")).toThrow( + blockedError("ServerTool"), + ); + expect(() => blockPolicy.validateComponentType("Agent")).not.toThrow(); + }); + + it("concrete allow entry beats an abstract block entry", () => { + const policy = new ComponentLoadPolicy(["ServerTool"], ["Tool"]); + expect(() => policy.validateComponentType("ServerTool")).not.toThrow(); + expect(() => policy.validateComponentType("ClientTool")).toThrow( + blockedError("ClientTool"), + ); + }); + + it("concrete block entry beats an abstract allow entry", () => { + const policy = new ComponentLoadPolicy(["Tool"], ["ServerTool"]); + expect(() => policy.validateComponentType("ServerTool")).toThrow( + blockedError("ServerTool"), + ); + expect(() => policy.validateComponentType("ClientTool")).not.toThrow(); + }); + + it("block wins same-distance ties", () => { + const concreteTie = new ComponentLoadPolicy(["ServerTool"], ["ServerTool"]); + expect(() => concreteTie.validateComponentType("ServerTool")).toThrow( + blockedError("ServerTool"), + ); + + const wildcardTie = new ComponentLoadPolicy(["Component"], ["Component"]); + expect(() => wildcardTie.validateComponentType("Agent")).toThrow( + blockedError("Agent"), + ); + }); + + it("Component wildcard matches unknown plugin-defined types", () => { + const blockAll = new ComponentLoadPolicy(undefined, ["Component"]); + expect(() => blockAll.validateComponentType("MyPluginType")).toThrow( + blockedError("MyPluginType"), + ); + + const allowAll = new ComponentLoadPolicy(["Component"]); + expect(() => allowAll.validateComponentType("MyPluginType")).not.toThrow(); + }); + + it("ComponentWithIO wildcard only matches IO component types", () => { + const policy = new ComponentLoadPolicy(undefined, ["ComponentWithIO"]); + expect(() => policy.validateComponentType("Agent")).toThrow( + blockedError("Agent"), + ); + // LLM configs do not extend ComponentWithIO. + expect(() => policy.validateComponentType("VllmConfig")).not.toThrow(); + }); + + it("an unknown policy name matches only that exact componentType", () => { + const policy = new ComponentLoadPolicy(undefined, ["SomethingCustom"]); + expect(() => policy.validateComponentType("SomethingCustom")).toThrow( + blockedError("SomethingCustom"), + ); + expect(() => policy.validateComponentType("Agent")).not.toThrow(); + }); + + it("rejects non-string policy entries with the Python error text", () => { + expect( + () => new ComponentLoadPolicy([123 as unknown as string]), + ).toThrow( + "`allowed_components` and `blocked_components` entries must be component " + + "type names or Component classes, got 123.", + ); + }); +}); + +describe("ComponentLoadPolicy.validateComponentTree", () => { + const stdioTransport = createStdioTransport({ + name: "stdio", + command: "echo", + }); + const agentWithNestedTransport = createAgent({ + name: "agent", + systemPrompt: "You are a helpful agent.", + llmConfig: createVllmConfig({ + name: "llm", + url: "http://localhost:8000", + modelId: "m", + }), + tools: [ + createMCPTool({ name: "fooza_tool", clientTransport: stdioTransport }), + ], + }); + + it("catches a blocked component nested deep in the tree", () => { + const policy = new ComponentLoadPolicy(undefined, ["StdioTransport"]); + expect(() => policy.validateComponentTree(agentWithNestedTransport)).toThrow( + blockedError("StdioTransport"), + ); + }); + + it("passes the same tree when nothing is blocked", () => { + const policy = new ComponentLoadPolicy(undefined, []); + expect(() => + policy.validateComponentTree(agentWithNestedTransport), + ).not.toThrow(); + }); + + it("applies an allow list to every nested component", () => { + const policy = new ComponentLoadPolicy([ + "Agent", + "VllmConfig", + "MCPTool", + // StdioTransport intentionally missing. + ]); + expect(() => policy.validateComponentTree(agentWithNestedTransport)).toThrow( + notAllowedError("StdioTransport"), + ); + }); +}); + +describe("loader default policy", () => { + it("blocks StdioTransport by default", () => { + const loader = new AgentSpecLoader(); + expect(loader.blockedComponents).toEqual(["StdioTransport"]); + expect(() => + loader.componentLoadPolicy.validateComponentType("StdioTransport"), + ).toThrow(blockedError("StdioTransport")); + }); + + it("blockedComponents: [] unblocks StdioTransport", () => { + const loader = new AgentSpecLoader({ blockedComponents: [] }); + expect(loader.blockedComponents).toEqual([]); + expect(() => + loader.componentLoadPolicy.validateComponentType("StdioTransport"), + ).not.toThrow(); + }); +}); diff --git a/tsagentspec/tests/adapters/common/json-schema.test.ts b/tsagentspec/tests/adapters/common/json-schema.test.ts new file mode 100644 index 00000000..dc107f3a --- /dev/null +++ b/tsagentspec/tests/adapters/common/json-schema.test.ts @@ -0,0 +1,215 @@ +/** + * Tests for the shared JSON-schema helpers. + * + * `jsonSchemasHaveSameType` ports + * `pyagentspec.property.json_schemas_have_same_type`; + * `buildJsonSchemaFromProperties` builds LangChain tool argument schemas from + * AgentSpec properties (defaults excluded from `required`, mirroring the + * Python generated pydantic models). + */ +import { describe, expect, it } from "vitest"; +import type { JsonSchemaValue } from "../../../src/index.js"; +import { integerProperty, stringProperty } from "../../../src/index.js"; +import { + buildJsonSchemaFromProperties, + jsonSchemasHaveSameType, +} from "../../../src/adapters/common/json-schema.js"; + +describe("jsonSchemasHaveSameType", () => { + it("matches identical basic types and rejects different ones", () => { + expect( + jsonSchemasHaveSameType({ type: "integer" }, { type: "integer" }), + ).toBe(true); + expect( + jsonSchemasHaveSameType({ type: "integer" }, { type: "string" }), + ).toBe(false); + }); + + it("ignores non-type keys such as title and description", () => { + expect( + jsonSchemasHaveSameType( + { title: "a", type: "integer", description: "x" }, + { title: "b", type: "integer" }, + ), + ).toBe(true); + }); + + it("treats anyOf and type lists as equivalent unions, order-insensitively", () => { + const anyOf: JsonSchemaValue = { + anyOf: [{ type: "string" }, { type: "integer" }], + }; + const typeList: JsonSchemaValue = { type: ["integer", "string"] }; + expect(jsonSchemasHaveSameType(anyOf, typeList)).toBe(true); + expect(jsonSchemasHaveSameType(typeList, anyOf)).toBe(true); + expect( + jsonSchemasHaveSameType(anyOf, { type: ["integer", "boolean"] }), + ).toBe(false); + }); + + it("compares array item types", () => { + expect( + jsonSchemasHaveSameType( + { type: "array", items: { type: "string" } }, + { type: "array", items: { type: "string" } }, + ), + ).toBe(true); + expect( + jsonSchemasHaveSameType( + { type: "array", items: { type: "string" } }, + { type: "array", items: { type: "integer" } }, + ), + ).toBe(false); + // Missing items on one side compares against {}. + expect( + jsonSchemasHaveSameType( + { type: "array" }, + { type: "array", items: { type: "string" } }, + ), + ).toBe(false); + }); + + it("compares object property sets and their types", () => { + const a: JsonSchemaValue = { + type: "object", + properties: { x: { type: "integer" }, y: { type: "string" } }, + }; + expect( + jsonSchemasHaveSameType(a, { + type: "object", + properties: { y: { type: "string" }, x: { type: "integer" } }, + }), + ).toBe(true); + expect( + jsonSchemasHaveSameType(a, { + type: "object", + properties: { x: { type: "integer" } }, + }), + ).toBe(false); + expect( + jsonSchemasHaveSameType(a, { + type: "object", + properties: { x: { type: "integer" }, y: { type: "boolean" } }, + }), + ).toBe(false); + }); + + it("compares additionalProperties strictly when boolean", () => { + expect( + jsonSchemasHaveSameType( + { type: "object", additionalProperties: false }, + { type: "object", additionalProperties: false }, + ), + ).toBe(true); + expect( + jsonSchemasHaveSameType( + { type: "object", additionalProperties: false }, + { type: "object" }, + ), + ).toBe(false); + expect( + jsonSchemasHaveSameType( + { type: "object", additionalProperties: { type: "string" } }, + { type: "object", additionalProperties: { type: "integer" } }, + ), + ).toBe(false); + }); + + it("throws on allOf and oneOf", () => { + expect(() => + jsonSchemasHaveSameType({ allOf: [] }, { type: "string" }), + ).toThrow("Support for schemas using allOf is not implemented."); + expect(() => + jsonSchemasHaveSameType({ type: "string" }, { oneOf: [] }), + ).toThrow("Support for schemas using oneOf is not implemented."); + }); + + it("throws when a union has more than 100 member types", () => { + const big: JsonSchemaValue = { + anyOf: Array.from({ length: 101 }, () => ({ type: "string" })), + }; + expect(() => jsonSchemasHaveSameType(big, { type: "string" })).toThrow( + "The schema is the union of more than 100 types.", + ); + }); +}); + +describe("buildJsonSchemaFromProperties", () => { + it("builds an object schema with required for default-less properties", () => { + const schema = buildJsonSchemaFromProperties("myToolArgs", [ + integerProperty({ title: "x" }), + stringProperty({ title: "y" }), + ]); + expect(schema).toEqual({ + title: "myToolArgs", + type: "object", + properties: { + x: { title: "x", type: "integer" }, + y: { title: "y", type: "string" }, + }, + required: ["x", "y"], + }); + }); + + it("includes defaults and drops defaulted properties from required", () => { + const schema = buildJsonSchemaFromProperties("args", [ + integerProperty({ title: "x" }), + integerProperty({ title: "n", default: 3 }), + ]); + expect(schema["required"]).toEqual(["x"]); + expect( + (schema["properties"] as Record)["n"], + ).toEqual({ title: "n", type: "integer", default: 3 }); + }); + + it("omits required entirely when every property has a default", () => { + const schema = buildJsonSchemaFromProperties("args", [ + stringProperty({ title: "s", default: "hello" }), + ]); + expect("required" in schema).toBe(false); + }); + + it("copies the property description into the schema when missing there", () => { + // Hand-built property whose jsonSchema carries no description. + const schema = buildJsonSchemaFromProperties("args", [ + { + title: "s", + description: "a string input", + jsonSchema: { title: "s", type: "string" }, + default: undefined, + type: "string", + }, + ]); + expect( + (schema["properties"] as Record)["s"], + ).toEqual({ + title: "s", + type: "string", + description: "a string input", + }); + }); + + it("keeps an existing schema description over the property description", () => { + const schema = buildJsonSchemaFromProperties("args", [ + { + title: "s", + description: "property description", + jsonSchema: { title: "s", type: "string", description: "schema wins" }, + default: undefined, + type: "string", + }, + ]); + expect( + (schema["properties"] as Record)["s"]?.[ + "description" + ], + ).toBe("schema wins"); + }); + + it("builds an empty schema for no properties", () => { + expect(buildJsonSchemaFromProperties("args", [])).toEqual({ + title: "args", + type: "object", + properties: {}, + }); + }); +}); diff --git a/tsagentspec/tests/adapters/common/templating.test.ts b/tsagentspec/tests/adapters/common/templating.test.ts new file mode 100644 index 00000000..24300f5a --- /dev/null +++ b/tsagentspec/tests/adapters/common/templating.test.ts @@ -0,0 +1,153 @@ +/** + * Tests for the shared adapter template rendering helpers. + * + * Ports `pyagentspec/tests/adapters/test_template_rendering.py` (minus the + * tuple/tuple-key cases, which have no JS equivalent). + * + * Documented divergence exercised here: Python renders values with `str()`; + * TypeScript uses `String()` for primitives and `JSON.stringify` for + * objects/arrays. + */ +import { describe, expect, it } from "vitest"; +import { + renderNestedObjectTemplate, + renderTemplate, + stringifyTemplateValue, +} from "../../../src/adapters/common/templating.js"; + +describe("renderTemplate", () => { + const cases: Array< + [template: string, inputs: Record, expected: string] + > = [ + ["a", {}, "a"], + ["{{a}}", { a: 1 }, "1"], + ["{{ a}} {{b }}", { a: 1, b: 2 }, "1 2"], + ["{{ a} {b }}", { a: 1, b: 2 }, "{{ a} {b }}"], + ["{{ a}{}{b }}", { a: 1, b: 2 }, "{{ a}{}{b }}"], + ["{{ a a a a }}", { a: 1 }, "{{ a a a a }}"], + ["{{a}}{{b}}{{a}}{{a}}", { a: 1, b: 2 }, "1211"], + ["{{ b{{a}} }}{{b1}}", { a: 1, b: 2, b1: 3 }, "{{ b1 }}3"], + ["{{{{a}}}}", { a: " b ", b: 2 }, "{{ b }}"], + // Rendered values are never re-scanned for placeholders. + ["{{a}}{{b}}", { a: "{{b}}", b: 2 }, "{{b}}2"], + ["{{a}}{{b}}", { b: 2, a: "{{b}}" }, "{{b}}2"], + // Input keys are matched literally, never as regular expressions. + ["{{a}}", { ".*": "b" }, "{{a}}"], + ["{{a}}", { "a|b": "c" }, "{{a}}"], + ["{{a}}", { "[abc]": "b" }, "{{a}}"], + ["{{a}}", { "b)": "b" }, "{{a}}"], + [ + "Here is the equation: {{a}} plus {{b}} equals {{c}}", + { a: "{{", b: "}}", plus: "SECRET" }, + "Here is the equation: {{ plus }} equals {{c}}", + ], + [ + "{{a}}{{b}}", + { a: "{{sec", b: "ret}}", secret: "SECRET" }, + "{{secret}}", + ], + ]; + + it.each(cases)("renders %j with %j", (template, inputs, expected) => { + expect(renderTemplate(template, inputs)).toBe(expected); + }); + + it("stringifies object and array values with JSON.stringify (TS divergence)", () => { + expect(renderTemplate("{{a}}", { a: { b: 1 } })).toBe('{"b":1}'); + expect(renderTemplate("{{a}}", { a: [1, "x"] })).toBe('[1,"x"]'); + }); + + it("stringifies primitive values with String", () => { + expect(renderTemplate("{{a}}", { a: true })).toBe("true"); + expect(renderTemplate("{{a}}", { a: null })).toBe("null"); + expect(renderTemplate("{{a}}", { a: 1.5 })).toBe("1.5"); + }); + + it("does not resolve placeholders from the object prototype", () => { + expect(renderTemplate("{{toString}}", {})).toBe("{{toString}}"); + expect(renderTemplate("{{constructor}}", {})).toBe("{{constructor}}"); + }); + + it("stringifies non-string templates without rendering", () => { + expect(renderTemplate(5, {})).toBe("5"); + expect(renderTemplate({ a: "{{x}}" }, { x: 1 })).toBe('{"a":"{{x}}"}'); + }); +}); + +describe("stringifyTemplateValue", () => { + it("uses String for primitives and JSON.stringify for objects", () => { + expect(stringifyTemplateValue("s")).toBe("s"); + expect(stringifyTemplateValue(3)).toBe("3"); + expect(stringifyTemplateValue({ a: 1 })).toBe('{"a":1}'); + expect(stringifyTemplateValue([1, 2])).toBe("[1,2]"); + }); +}); + +describe("renderNestedObjectTemplate", () => { + const cases: Array<[template: unknown, inputs: Record, expected: unknown]> = [ + ["a", {}, "a"], + ["{{a}}", { a: 1 }, "1"], + ["{{ a}} {{b }}", { a: 1, b: 2 }, "1 2"], + [ + { "{{a}}": "{{a}}{{b}}" }, + { a: "{{b}}", b: 2 }, + { "{{b}}": "{{b}}2" }, + ], + [ + { "{{a}}": { "{{a}}{{b}}": { "{{b}}": "{{a}}" } } }, + { a: "{{b}}", b: 2 }, + { "{{b}}": { "{{b}}2": { "2": "{{b}}" } } }, + ], + [ + [{ "id_{{a}}": "v{{b}}" }, { inner: { k: "{{c}}" } }], + { a: 1, b: 2, c: 3 }, + [{ id_1: "v2" }, { inner: { k: "3" } }], + ], + [ + { "{{a}}": [{ "{{b}}": "v{{c}}" }] }, + { a: "A", b: "B", c: "C" }, + { A: [{ B: "vC" }] }, + ], + [ + { l1: [{ l2: [{ l3: "x {{x}}" }] }], "k{{y}}": "v" }, + { x: "X", y: "Y" }, + { l1: [{ l2: [{ l3: "x X" }] }], kY: "v" }, + ], + [ + ["pre {{p}}", { mid: ["{{p}}", { deep: "d{{d}}" }] }, "suf {{s}}"], + { p: "P", d: "D", s: "S" }, + ["pre P", { mid: ["P", { deep: "dD" }] }, "suf S"], + ], + [ + { mix: [null, 0, "{{z}}", { inner: [true, "{{z}}"] }] }, + { z: "Z" }, + { mix: [null, 0, "Z", { inner: [true, "Z"] }] }, + ], + ]; + + it.each(cases)("renders nested %j", (template, inputs, expected) => { + expect(renderNestedObjectTemplate(template, inputs)).toEqual(expected); + }); + + it("renders inside sets", () => { + expect( + renderNestedObjectTemplate({ set: new Set(["a", "s{{x}}"]) }, { x: "X" }), + ).toEqual({ set: new Set(["a", "sX"]) }); + }); + + it("decodes Uint8Array as UTF-8 before rendering (Python bytes)", () => { + const bytes = new TextEncoder().encode("b{{bb}}"); + expect( + renderNestedObjectTemplate({ bytes: [bytes, { k: "{{bb2}}" }] }, { + bb: "BB", + bb2: "B2", + }), + ).toEqual({ bytes: ["bBB", { k: "B2" }] }); + }); + + it("leaves non-plain objects untouched", () => { + const date = new Date(0); + expect(renderNestedObjectTemplate(date, { a: 1 })).toBe(date); + expect(renderNestedObjectTemplate(42, { a: 1 })).toBe(42); + }); +}); diff --git a/tsagentspec/tests/adapters/common/url-validation.test.ts b/tsagentspec/tests/adapters/common/url-validation.test.ts new file mode 100644 index 00000000..1e53ea0b --- /dev/null +++ b/tsagentspec/tests/adapters/common/url-validation.test.ts @@ -0,0 +1,213 @@ +/** + * Tests for the shared URL validation and allow-list helpers. + * + * Ports the behavior of `pyagentspec.adapters._url_validation`: matching + * considers scheme + netloc exactly and path as a prefix, query/fragment are + * ignored, and templated URL destinations without an allow list warn (via + * `console.warn` here, `warnings.warn` in Python). + */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + getUrlDestinationPlaceholderNames, + getUrlMatchParts, + matchesAllowListEntry, + maybeWarnAboutUnrestrictedTemplatedUrl, + validateUrlAgainstAllowList, +} from "../../../src/adapters/common/url-validation.js"; + +const ALLOW_LIST_ERROR = + "Requested URL is not in allowed list. " + + "Please contact the application administrator to help adding your URL to the list."; + +describe("getUrlMatchParts", () => { + it("splits scheme, netloc and path", () => { + expect(getUrlMatchParts("https://example.com/api/x")).toEqual([ + "https", + "example.com", + "/api/x", + ]); + }); + + it("defaults the path to /", () => { + expect(getUrlMatchParts("http://example.com")).toEqual([ + "http", + "example.com", + "/", + ]); + }); + + it("keeps non-default ports and userinfo in the netloc", () => { + expect(getUrlMatchParts("http://user:pw@example.com:8080/p")).toEqual([ + "http", + "user:pw@example.com:8080", + "/p", + ]); + expect(getUrlMatchParts("http://user@example.com/p")).toEqual([ + "http", + "user@example.com", + "/p", + ]); + }); +}); + +describe("matchesAllowListEntry", () => { + it("matches exact scheme + host with path prefix", () => { + expect( + matchesAllowListEntry( + "https://allowed.example.com/api/value", + "https://allowed.example.com/api/", + ), + ).toBe(true); + }); + + it("ignores query parameters and fragments", () => { + expect( + matchesAllowListEntry( + "https://allowed.example.com/api/value?x=1&y=2#frag", + "https://allowed.example.com/api/", + ), + ).toBe(true); + }); + + it("rejects a different host", () => { + expect( + matchesAllowListEntry( + "https://blocked.example.com/api/value", + "https://allowed.example.com/api/", + ), + ).toBe(false); + }); + + it("rejects a different scheme", () => { + expect( + matchesAllowListEntry( + "http://allowed.example.com/api/value", + "https://allowed.example.com/api/", + ), + ).toBe(false); + }); + + it("rejects a different port", () => { + expect( + matchesAllowListEntry( + "https://allowed.example.com:8443/api/value", + "https://allowed.example.com/api/", + ), + ).toBe(false); + }); + + it("rejects a path outside the pattern prefix", () => { + expect( + matchesAllowListEntry( + "https://allowed.example.com/other/value", + "https://allowed.example.com/api/", + ), + ).toBe(false); + }); +}); + +describe("validateUrlAgainstAllowList", () => { + it("allows everything when no allow list is configured", () => { + expect(() => + validateUrlAgainstAllowList("https://anything.example.com/x", undefined), + ).not.toThrow(); + }); + + it("passes when any entry matches", () => { + expect(() => + validateUrlAgainstAllowList("https://allowed.example.com/api/value", [ + "https://other.example.com/", + "https://allowed.example.com/api/", + ]), + ).not.toThrow(); + }); + + it("throws the Python error text on mismatch", () => { + expect(() => + validateUrlAgainstAllowList("https://blocked.example.com/api/value", [ + "https://allowed.example.com/api/", + ]), + ).toThrow(ALLOW_LIST_ERROR); + }); + + it("throws on an empty allow list", () => { + expect(() => + validateUrlAgainstAllowList("https://allowed.example.com/api/value", []), + ).toThrow(ALLOW_LIST_ERROR); + }); +}); + +describe("getUrlDestinationPlaceholderNames", () => { + it("finds placeholders in the host and port", () => { + expect( + getUrlDestinationPlaceholderNames("https://{{host}}:{{port}}/api"), + ).toEqual(["host", "port"]); + }); + + it("finds placeholders in the scheme", () => { + expect(getUrlDestinationPlaceholderNames("{{scheme}}://x.com/api")).toEqual([ + "scheme", + ]); + }); + + it("ignores placeholders in path, query and fragment", () => { + expect( + getUrlDestinationPlaceholderNames( + "https://example.com/{{path}}?q={{query}}#{{frag}}", + ), + ).toEqual([]); + }); + + it("ignores placeholders in the userinfo", () => { + expect( + getUrlDestinationPlaceholderNames("https://{{user}}@{{host}}/x"), + ).toEqual(["host"]); + }); + + it("returns sorted unique names", () => { + expect( + getUrlDestinationPlaceholderNames("https://{{b}}.{{a}}.{{b}}/x"), + ).toEqual(["a", "b"]); + }); +}); + +describe("maybeWarnAboutUnrestrictedTemplatedUrl", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("warns with the component name when the destination is templated and no allow list is set", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + maybeWarnAboutUnrestrictedTemplatedUrl( + "https://{{host}}/api/value", + undefined, + "RemoteTool `lookup`", + ); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + "RemoteTool `lookup` uses placeholders in the URL destination (`host`) " + + "but no `url_allow_list` is configured. Keep the base URL developer-controlled and " + + "template only path, query, or body values when possible.", + ); + }); + + it("does not warn when an allow list is configured (even an empty one)", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + maybeWarnAboutUnrestrictedTemplatedUrl( + "https://{{host}}/api/value", + [], + "RemoteTool `lookup`", + ); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it("does not warn when placeholders only appear outside the destination", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + maybeWarnAboutUnrestrictedTemplatedUrl( + "https://example.com/{{path}}?q={{query}}", + undefined, + "RemoteTool `lookup`", + ); + expect(warnSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/tsagentspec/tests/adapters/langgraph/exporter.test.ts b/tsagentspec/tests/adapters/langgraph/exporter.test.ts new file mode 100644 index 00000000..e06a90d5 --- /dev/null +++ b/tsagentspec/tests/adapters/langgraph/exporter.test.ts @@ -0,0 +1,1030 @@ +/** + * Exporter tests for the LangGraph adapter (LangGraph -> Agent Spec). + * + * Mirrors the offline-able behaviors of the Python suite + * (`pyagentspec/tests/adapters/langgraph/test_langgraph_to_agentspec.py` and + * `test_disaggregated_config.py`): structured tools to ServerTools, chat + * models to LLM configs, langchain react agents to Agents, generic state + * graphs to Flows (plain edges, conditional edges, subgraphs), shared-object + * memoization, disaggregated exports and the documented TS-only rejections + * (swarm graphs and bare compiled agent graphs). All tests run offline: chat + * models are only constructed, never invoked. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { SystemMessage } from "@langchain/core/messages"; +import { tool } from "@langchain/core/tools"; +import { + Annotation, + END, + MemorySaver, + START, + StateGraph, +} from "@langchain/langgraph"; +import { createSwarm } from "@langchain/langgraph-swarm"; +import { ChatOllama } from "@langchain/ollama"; +import { ChatOpenAI } from "@langchain/openai"; +import { createAgent } from "langchain"; +import { + DEFAULT_BRANCH, + DEFAULT_INPUT, + OpenAIAPIType, + createOpenAiCompatibleConfig, + createServerTool, + stringProperty, +} from "../../../src/index.js"; +import type { + Agent, + Flow, + OllamaConfig, + OpenAiCompatibleConfig, + OpenAiConfig, + Property, + ServerTool, +} from "../../../src/index.js"; +import { LangGraphToAgentSpecConverter } from "../../../src/adapters/langgraph/agentspec-converter.js"; +import { AgentSpecExporter } from "../../../src/adapters/langgraph/agentspec-exporter.js"; +import { AgentSpecLoader } from "../../../src/adapters/langgraph/agentspec-loader.js"; +import { + FakeToolCallingChatModel, + makeAgent, + type LoadedReactAgent, +} from "./test-helpers.js"; + +const MODEL_ID = "Llama-3.1-70B-Instruct"; +const LLAMA_URL = "https://url.to.my.llama.model/v1"; + +/** ChatOpenAI pointing at a fake OpenAI-compatible server (never invoked). */ +function makeChatOpenAI(overrides?: { + baseURL?: string; + useResponsesApi?: boolean; +}): ChatOpenAI { + return new ChatOpenAI({ + model: MODEL_ID, + apiKey: "EMPTY", + ...(overrides?.useResponsesApi !== undefined + ? { useResponsesApi: overrides.useResponsesApi } + : {}), + configuration: { baseURL: overrides?.baseURL ?? LLAMA_URL }, + }); +} + +/** The langchain structured weather tool used across the agent tests. */ +function makeWeatherLangChainTool() { + return tool(() => "The weather is sunny.", { + name: "get_weather", + description: "Returns the weather in a certain city", + schema: z.object({ city: z.string().describe("The city to check") }), + }); +} + +/** Structural view of an exported flow node used by the assertions. */ +interface ExportedNodeView { + id: string; + componentType: string; + name: string; + inputs?: Property[]; + outputs?: Property[]; + tool?: ServerTool; + mapping?: Record; + branches?: string[]; + subflow?: Flow; +} + +function nodesOf(flow: Flow): ExportedNodeView[] { + return flow.nodes as unknown as ExportedNodeView[]; +} + +function nodeNamed(flow: Flow, name: string): ExportedNodeView { + const node = nodesOf(flow).find((candidate) => candidate.name === name); + if (node === undefined) { + throw new Error(`Flow has no node named '${name}'.`); + } + return node; +} + +function controlFlowNames(flow: Flow): string[] { + return flow.controlFlowConnections.map((edge) => edge.name ?? ""); +} + +function dataFlowNames(flow: Flow): string[] { + return (flow.dataFlowConnections ?? []).map((edge) => edge.name ?? ""); +} + +/** Keys listed by a synthetic `state` property. */ +function statePropertyKeys(property: Property): string[] { + return Object.keys( + (property.jsonSchema["properties"] as Record) ?? {}, + ); +} + +describe("AgentSpecExporter: structured tools", () => { + it("converts a zod structured tool into a ServerTool with typed inputs", () => { + const exporter = new AgentSpecExporter(); + const weatherTool = tool(() => "sunny", { + name: "get_weather", + description: "Returns the weather in a certain city", + schema: z.object({ + city: z.string().describe("The city to check"), + days: z.number().int().default(3), + }), + }); + + const serverTool = exporter.toComponent(weatherTool) as ServerTool; + + expect(serverTool.componentType).toBe("ServerTool"); + expect(serverTool.name).toBe("get_weather"); + expect(serverTool.description).toBe("Returns the weather in a certain city"); + expect(serverTool.inputs).toHaveLength(2); + const [city, days] = serverTool.inputs as [Property, Property]; + expect(city.title).toBe("city"); + expect(city.type).toBe("string"); + expect(city.description).toBe("The city to check"); + expect(city.jsonSchema["title"]).toBe("city"); + expect(days.title).toBe("days"); + expect(days.type).toBe("integer"); + expect(days.default).toBe(3); + }); + + it("converts a raw JSON-schema structured tool into a ServerTool", () => { + const exporter = new AgentSpecExporter(); + const rawTool = tool(() => "ok", { + name: "raw_weather", + description: "Raw-schema weather tool", + schema: { + type: "object", + properties: { + city: { type: "string", description: "City name" }, + unit: { type: "string", default: "celsius" }, + }, + required: ["city"], + } as const, + }); + + const serverTool = exporter.toComponent(rawTool) as ServerTool; + + expect(serverTool.componentType).toBe("ServerTool"); + expect(serverTool.name).toBe("raw_weather"); + expect(serverTool.description).toBe("Raw-schema weather tool"); + expect(serverTool.inputs).toHaveLength(2); + const [city, unit] = serverTool.inputs as [Property, Property]; + expect(city.title).toBe("city"); + expect(city.type).toBe("string"); + expect(city.description).toBe("City name"); + expect(unit.title).toBe("unit"); + expect(unit.default).toBe("celsius"); + // The synthesized title lands in the json schema as well. + expect(unit.jsonSchema["title"]).toBe("unit"); + }); + + it("exports a tool argument without a JSON-schema type as a permissive property", () => { + // Python exports an untyped Property here; the TS SDK Property model + // requires `type` or `anyOf`, so the exporter synthesizes the closest + // representable "any" union instead of failing the export. + const exporter = new AgentSpecExporter(); + const anyTool = tool(() => "ok", { + name: "any_tool", + schema: { + type: "object", + properties: { mystery: { description: "no type here" } }, + } as const, + }); + + const serverTool = exporter.toComponent(anyTool) as ServerTool; + expect(serverTool.inputs).toHaveLength(1); + const mystery = serverTool.inputs![0]!; + expect(mystery.title).toBe("mystery"); + expect(mystery.description).toBe("no type here"); + expect(mystery.type).toBeUndefined(); + expect(mystery.jsonSchema["anyOf"]).toEqual([ + { type: "object" }, + { type: "array" }, + { type: "string" }, + { type: "number" }, + { type: "integer" }, + { type: "boolean" }, + { type: "null" }, + ]); + }); + + it("exports a tool with a z.any() argument instead of failing", () => { + const exporter = new AgentSpecExporter(); + const anyTool = tool(() => "ok", { + name: "zany_tool", + description: "any arg", + schema: z.object({ payload: z.any() }), + }); + + expect(() => exporter.toComponent(anyTool)).not.toThrow(); + }); +}); + +describe("AgentSpecExporter: chat models", () => { + it("converts ChatOpenAI with the api.openai.com base URL to OpenAiConfig", () => { + const exporter = new AgentSpecExporter(); + const model = new ChatOpenAI({ + model: "gpt-4o-mini", + apiKey: "sk-test", + configuration: { baseURL: "https://api.openai.com/v1" }, + }); + + const config = exporter.toComponent(model) as OpenAiConfig; + + expect(config.componentType).toBe("OpenAiConfig"); + expect(config.modelId).toBe("gpt-4o-mini"); + expect(config.name).toBe("gpt-4o-mini"); + expect(config.apiType).toBe(OpenAIAPIType.CHAT_COMPLETIONS); + expect("url" in config).toBe(false); + }); + + it("converts ChatOpenAI with a custom base URL to OpenAiCompatibleConfig", () => { + const exporter = new AgentSpecExporter(); + const model = makeChatOpenAI(); + + const config = exporter.toComponent(model) as OpenAiCompatibleConfig; + + expect(config.componentType).toBe("OpenAiCompatibleConfig"); + expect(config.modelId).toBe(MODEL_ID); + expect(config.url).toBe(LLAMA_URL); + expect(config.apiType).toBe(OpenAIAPIType.CHAT_COMPLETIONS); + }); + + it("converts ChatOpenAI without a base URL to OpenAiCompatibleConfig with an empty url", () => { + // Python parity: `(model.openai_api_base or "").startswith(...)` routes an + // unset base URL to OpenAiCompatibleConfig(url=""). + const exporter = new AgentSpecExporter(); + const model = new ChatOpenAI({ model: "gpt-4o-mini", apiKey: "sk-test" }); + + const config = exporter.toComponent(model) as OpenAiCompatibleConfig; + + expect(config.componentType).toBe("OpenAiCompatibleConfig"); + expect(config.modelId).toBe("gpt-4o-mini"); + expect(config.url).toBe(""); + }); + + it("detects the responses API on ChatOpenAI", () => { + const exporter = new AgentSpecExporter(); + const model = makeChatOpenAI({ useResponsesApi: true }); + + const config = exporter.toComponent(model) as OpenAiCompatibleConfig; + + expect(config.apiType).toBe(OpenAIAPIType.RESPONSES); + }); + + it("converts ChatOllama to OllamaConfig with base url and model id", () => { + const exporter = new AgentSpecExporter(); + const model = new ChatOllama({ + model: "llama3.1", + baseUrl: "http://ollama.example.com:11434", + }); + + const config = exporter.toComponent(model) as OllamaConfig; + + expect(config.componentType).toBe("OllamaConfig"); + expect(config.modelId).toBe("llama3.1"); + expect(config.name).toBe("llama3.1"); + expect(config.url).toBe("http://ollama.example.com:11434"); + }); + + it("keeps the ChatOllama default base url", () => { + const exporter = new AgentSpecExporter(); + const config = exporter.toComponent( + new ChatOllama({ model: "llama3.1" }), + ) as OllamaConfig; + + expect(config.url).toBe("http://127.0.0.1:11434"); + }); + + it("rejects unsupported chat model types with the Python error text", () => { + const exporter = new AgentSpecExporter(); + const model = new FakeToolCallingChatModel({ responses: [] }); + + expect(() => exporter.toComponent(model)).toThrow( + "The LLM instance provided is of an unsupported type `FakeToolCallingChatModel`.", + ); + }); +}); + +describe("AgentSpecExporter: react agents", () => { + it("converts a langchain react agent with tools into an Agent Spec Agent", () => { + const exporter = new AgentSpecExporter(); + const agent = createAgent({ + model: makeChatOpenAI(), + tools: [makeWeatherLangChainTool()], + systemPrompt: "You are a helpful assistant.", + name: "weather_agent", + }); + + const agentSpecAgent = exporter.toComponent(agent) as Agent; + + expect(agentSpecAgent.componentType).toBe("Agent"); + expect(agentSpecAgent.name).toBe("weather_agent"); + expect(agentSpecAgent.systemPrompt).toBe("You are a helpful assistant."); + const config = agentSpecAgent.llmConfig as OpenAiCompatibleConfig; + expect(config.componentType).toBe("OpenAiCompatibleConfig"); + expect(config.modelId).toBe(MODEL_ID); + expect(config.url).toBe(LLAMA_URL); + expect(agentSpecAgent.tools).toHaveLength(1); + const exportedTool = agentSpecAgent.tools[0] as ServerTool; + expect(exportedTool.componentType).toBe("ServerTool"); + expect(exportedTool.name).toBe("get_weather"); + expect(exportedTool.description).toBe( + "Returns the weather in a certain city", + ); + expect(exportedTool.inputs.map((input) => input.title)).toEqual(["city"]); + }); + + it("converts a react agent without tools", () => { + const exporter = new AgentSpecExporter(); + const agent = createAgent({ model: makeChatOpenAI(), tools: [] }); + + const agentSpecAgent = exporter.toComponent(agent) as Agent; + + expect(agentSpecAgent.componentType).toBe("Agent"); + expect(agentSpecAgent.tools).toHaveLength(0); + const config = agentSpecAgent.llmConfig as OpenAiCompatibleConfig; + expect(config.modelId).toBe(MODEL_ID); + expect(config.url).toBe(LLAMA_URL); + }); + + it("falls back to the default agent name when none is set", () => { + const exporter = new AgentSpecExporter(); + const agent = createAgent({ model: makeChatOpenAI(), tools: [] }); + + const agentSpecAgent = exporter.toComponent(agent) as Agent; + + expect(agentSpecAgent.name).toBe("LangGraph Agent"); + }); + + it("extracts the system prompt from a SystemMessage", () => { + const exporter = new AgentSpecExporter(); + const agent = createAgent({ + model: makeChatOpenAI(), + tools: [], + systemPrompt: new SystemMessage("Prompt from a message."), + }); + + const agentSpecAgent = exporter.toComponent(agent) as Agent; + + expect(agentSpecAgent.systemPrompt).toBe("Prompt from a message."); + }); + + it("does not export the responseFormat of a structured-output agent", () => { + // The TS adapter ignores responseFormat on export (structured outputs are + // not reconstructed into Agent outputs). + const exporter = new AgentSpecExporter(); + const agent = createAgent({ + model: makeChatOpenAI(), + tools: [makeWeatherLangChainTool()], + systemPrompt: "Report the weather.", + name: "structured_agent", + responseFormat: z.object({ answer: z.string() }), + }); + + const agentSpecAgent = exporter.toComponent(agent) as Agent; + + expect(agentSpecAgent.componentType).toBe("Agent"); + expect(agentSpecAgent.name).toBe("structured_agent"); + expect(agentSpecAgent.outputs ?? []).toHaveLength(0); + expect(agentSpecAgent.tools).toHaveLength(1); + }); + + it("rejects an agent created from a model identifier string", () => { + const exporter = new AgentSpecExporter(); + const agent = createAgent({ model: "openai:gpt-4o", tools: [] }); + + expect(() => exporter.toComponent(agent)).toThrow( + "Exporting an agent created from a model identifier string is not " + + "supported; pass a chat model instance to createAgent instead.", + ); + }); + + it("rejects a bare compiled agent graph", () => { + const exporter = new AgentSpecExporter(); + const agent = createAgent({ + model: makeChatOpenAI(), + tools: [makeWeatherLangChainTool()], + name: "weather_agent", + }); + + expect(() => exporter.toComponent(agent.graph)).toThrow( + "Exporting a compiled agent graph is not supported by the TypeScript " + + "adapter: the compiled graph does not retain its chat model or " + + "system prompt. Export the langchain ReactAgent instance (the " + + "createAgent result) instead.", + ); + }); +}); + +describe("AgentSpecExporter: swarm graphs", () => { + function makeSwarmBuilder() { + const model = makeChatOpenAI(); + const alice = createAgent({ model, tools: [], name: "alice" }); + const bob = createAgent({ model, tools: [], name: "bob" }); + return createSwarm({ + agents: [alice.graph, bob.graph], + defaultActiveAgent: "alice", + }); + } + + it("rejects a compiled swarm graph", () => { + const exporter = new AgentSpecExporter(); + const compiledSwarm = makeSwarmBuilder().compile({ + checkpointer: new MemorySaver(), + name: "swarm", + }); + + expect(() => exporter.toComponent(compiledSwarm)).toThrow( + "Exporting a LangGraph swarm is not supported by the TypeScript " + + "adapter: the compiled per-agent graphs do not retain their chat " + + "model or system prompt.", + ); + }); + + it("rejects an uncompiled swarm builder", () => { + const exporter = new AgentSpecExporter(); + + expect(() => exporter.toComponent(makeSwarmBuilder())).toThrow( + "Exporting a LangGraph swarm is not supported", + ); + }); +}); + +describe("AgentSpecExporter: state graph flows", () => { + const CodeGenState = Annotation.Root({ + language: Annotation, + request: Annotation, + output: Annotation, + }); + + it("converts a linear compiled graph into a Flow", () => { + const exporter = new AgentSpecExporter(); + const graph = new StateGraph(CodeGenState) + .addNode("llm_code_gen", () => ({ output: "generated" })) + .addEdge(START, "llm_code_gen") + .addEdge("llm_code_gen", END); + const compiled = graph.compile({ name: "CodeGen Assistant" }); + + const flow = exporter.toComponent(compiled) as Flow; + + expect(flow.componentType).toBe("Flow"); + expect(flow.name).toBe("CodeGen Assistant"); + // llm_code_gen + synthesized __start__ + __end__ + expect(flow.nodes).toHaveLength(3); + expect( + nodesOf(flow).map((node) => [node.componentType, node.name]), + ).toEqual([ + ["ToolNode", "llm_code_gen"], + ["StartNode", "__start__"], + ["EndNode", "__end__"], + ]); + // One ctrl+data pair per LangGraph edge, with the Python edge names. + expect(controlFlowNames(flow)).toEqual([ + "__start___to_llm_code_gen", + "llm_code_gen_to___end__", + ]); + expect(dataFlowNames(flow)).toEqual([ + "__start___to_llm_code_gen_data_edge", + "llm_code_gen_to___end___data_edge", + ]); + + // The synthetic tool mirrors the node, over a single `state` property + // listing the channel keys. + const toolNode = nodeNamed(flow, "llm_code_gen"); + expect(toolNode.tool?.componentType).toBe("ServerTool"); + expect(toolNode.tool?.name).toBe("llm_code_gen_tool"); + const toolInput = toolNode.tool?.inputs[0] as Property; + expect(toolInput.title).toBe("state"); + expect(toolInput.type).toBe("object"); + expect(statePropertyKeys(toolInput)).toEqual([ + "language", + "request", + "output", + ]); + + // Flow inputs/outputs are inferred from the synthesized start/end nodes. + expect(flow.inputs?.map((input) => input.title)).toEqual(["state"]); + expect(flow.outputs?.map((output) => output.title)).toEqual(["state"]); + expect(statePropertyKeys(flow.inputs?.[0] as Property)).toEqual([ + "language", + "request", + "output", + ]); + }); + + it("converts an uncompiled builder into a Flow with the default name", () => { + const exporter = new AgentSpecExporter(); + const graph = new StateGraph(CodeGenState) + .addNode("llm_code_gen", () => ({ output: "generated" })) + .addEdge(START, "llm_code_gen") + .addEdge("llm_code_gen", END); + + const flow = exporter.toComponent(graph) as Flow; + + expect(flow.componentType).toBe("Flow"); + expect(flow.name).toBe("LangGraph Flow"); + }); + + it("synthesizes END edges for sink nodes without outgoing edges", () => { + const exporter = new AgentSpecExporter(); + const graph = new StateGraph(CodeGenState) + .addNode("sink", () => ({})) + .addEdge(START, "sink"); + + const flow = exporter.toComponent(graph.compile()) as Flow; + + expect(flow.nodes).toHaveLength(3); + expect(controlFlowNames(flow)).toEqual([ + "__start___to_sink", + "sink_to___end__", + ]); + expect(dataFlowNames(flow)).toEqual([ + "__start___to_sink_data_edge", + "sink_to___end___data_edge", + ]); + }); + + it("converts a graph with distinct input/output/node schemas", () => { + // Per-node `input` options are the JS equivalent of the Python function + // annotations the Python adapter introspects. + const exporter = new AgentSpecExporter(); + const InputSchema = Annotation.Root({ city: Annotation }); + const OutputSchema = Annotation.Root({ response: Annotation }); + const WeatherSchema = Annotation.Root({ + weather_data: Annotation, + }); + const InternalState = Annotation.Root({ + city: Annotation, + weather_data: Annotation, + response: Annotation, + }); + const graph = new StateGraph({ + state: InternalState, + input: InputSchema, + output: OutputSchema, + }) + .addNode("get_weather", () => ({ weather_data: "sunny" }), { + input: InputSchema, + }) + .addNode("llm_node", () => ({ response: "reformulated" }), { + input: WeatherSchema, + }) + .addEdge(START, "get_weather") + .addEdge("get_weather", "llm_node") + .addEdge("llm_node", END); + + const flow = exporter.toComponent(graph.compile({ name: "Weather Flow" })) as Flow; + + expect(flow.name).toBe("Weather Flow"); + // get_weather + llm_node + __start__ + __end__ + expect(flow.nodes).toHaveLength(4); + expect(flow.controlFlowConnections).toHaveLength(3); + expect(flow.dataFlowConnections).toHaveLength(3); + const startNode = nodeNamed(flow, "__start__"); + const endNode = nodeNamed(flow, "__end__"); + expect(statePropertyKeys(startNode.outputs?.[0] as Property)).toEqual([ + "city", + ]); + expect(statePropertyKeys(endNode.outputs?.[0] as Property)).toEqual([ + "response", + ]); + const getWeatherNode = nodeNamed(flow, "get_weather"); + expect(statePropertyKeys(getWeatherNode.inputs?.[0] as Property)).toEqual([ + "city", + ]); + expect(statePropertyKeys(getWeatherNode.outputs?.[0] as Property)).toEqual([ + "weather_data", + ]); + }); + + it("expands a conditional edge into a conditional ToolNode plus a BranchingNode", () => { + const exporter = new AgentSpecExporter(); + const CaseState = Annotation.Root({ sentence: Annotation }); + const graph = new StateGraph(CaseState) + .addNode("lowercase", () => ({})) + .addNode("uppercase", () => ({})) + .addNode("messycase", () => ({})) + .addConditionalEdges(START, () => "lowercase", { + lowercase: "lowercase", + uppercase: "uppercase", + messycase: "messycase", + }); + + const flow = exporter.toComponent( + graph.compile({ name: "Casecheck Flow" }), + ) as Flow; + + expect(flow.name).toBe("Casecheck Flow"); + // 3 case nodes + __start__ + __end__ + conditional node + branching node + expect(flow.nodes).toHaveLength(7); + + // The conditional ToolNode computes the branch name (LangGraph JS names + // every conditional branch "condition"). + const conditionalNode = nodeNamed(flow, "condition"); + expect(conditionalNode.componentType).toBe("ToolNode"); + expect(conditionalNode.tool?.name).toBe("condition_tool"); + expect(conditionalNode.tool?.outputs.map((output) => output.title)).toEqual( + [DEFAULT_INPUT], + ); + + const branchingNode = nodeNamed(flow, "condition_branching_node"); + expect(branchingNode.componentType).toBe("BranchingNode"); + expect(branchingNode.mapping).toEqual({ + lowercase: "lowercase", + uppercase: "uppercase", + messycase: "messycase", + }); + expect(new Set(branchingNode.branches)).toEqual( + new Set([DEFAULT_BRANCH, "lowercase", "uppercase", "messycase"]), + ); + + // Control edges: source -> conditional -> branching -> per-branch targets + // plus the default fall-through to END and auto-END edges for the sinks. + const edgesWithBranch = flow.controlFlowConnections.map((edge) => [ + edge.name, + edge.fromBranch, + ]); + expect(edgesWithBranch).toEqual([ + ["__start___to_condition", undefined], + ["condition_to_condition_branching_node", undefined], + ["condition_branching_node_to_lowercase", "lowercase"], + ["condition_branching_node_to_uppercase", "uppercase"], + ["condition_branching_node_to_messycase", "messycase"], + ["condition_branching_node_to___end__", DEFAULT_BRANCH], + ["lowercase_to___end__", undefined], + ["uppercase_to___end__", undefined], + ["messycase_to___end__", undefined], + ]); + + const dataNames = dataFlowNames(flow); + expect(dataNames).toContain("__start___to_condition_data_edge"); + expect(dataNames).toContain( + "condition_to_condition_branching_node_data_edge", + ); + expect(dataNames).toContain("data___start___to_lowercase"); + const branchingDataEdge = (flow.dataFlowConnections ?? []).find( + (edge) => edge.name === "condition_to_condition_branching_node_data_edge", + ); + expect(branchingDataEdge?.sourceOutput).toBe(DEFAULT_INPUT); + expect(branchingDataEdge?.destinationInput).toBe(DEFAULT_INPUT); + }); + + it("keeps a real node named 'condition' distinct from the synthetic conditional node", () => { + // LangGraph JS stores every conditional edge's branch under the fixed key + // "condition"; a user node with that literal name must not be overwritten + // by the synthetic conditional ToolNode. The synthetic names are suffixed + // instead (only in the colliding case). + const exporter = new AgentSpecExporter(); + const CaseState = Annotation.Root({ sentence: Annotation }); + const graph = new StateGraph(CaseState) + .addNode("condition", () => ({})) + .addNode("other", () => ({})) + .addConditionalEdges(START, () => "condition", { + condition: "condition", + other: "other", + }); + + const flow = exporter.toComponent( + graph.compile({ name: "Collision Flow" }), + ) as Flow; + + // 2 real nodes + __start__ + __end__ + conditional node + branching node. + expect(flow.nodes).toHaveLength(6); + + const realNode = nodeNamed(flow, "condition"); + expect(realNode.componentType).toBe("ToolNode"); + expect(realNode.tool?.name).toBe("condition_tool"); + + const conditionalNode = nodeNamed(flow, "condition_1"); + expect(conditionalNode.componentType).toBe("ToolNode"); + expect(conditionalNode.tool?.name).toBe("condition_1_tool"); + + const branchingNode = nodeNamed(flow, "condition_1_branching_node"); + expect(branchingNode.componentType).toBe("BranchingNode"); + expect(branchingNode.mapping).toEqual({ + condition: "condition", + other: "other", + }); + + // The branch-target edge is wired to the REAL node, not the synthetic one. + const branchTargetEdge = flow.controlFlowConnections.find( + (edge) => edge.name === "condition_1_branching_node_to_condition", + ); + expect(branchTargetEdge?.fromBranch).toBe("condition"); + expect((branchTargetEdge?.toNode as unknown as ExportedNodeView).id).toBe( + realNode.id, + ); + + // And the real node stays connected downstream (auto edge to END). + expect(controlFlowNames(flow)).toContain("condition_to___end__"); + }); + + it("rejects a conditional edge without a path map", () => { + const exporter = new AgentSpecExporter(); + const CaseState = Annotation.Root({ sentence: Annotation }); + const graph = new StateGraph(CaseState) + .addNode("lowercase", () => ({})) + .addConditionalEdges(START, () => "lowercase"); + + expect(() => exporter.toComponent(graph.compile())).toThrow( + "Mapping for condition not found.\n" + + " Make sure to add proper return type hints to the branching function.", + ); + }); + + it("rejects multiple conditional edges with the same source node", () => { + const exporter = new AgentSpecExporter(); + const CaseState = Annotation.Root({ sentence: Annotation }); + const graph = new StateGraph(CaseState) + .addNode("node_a", () => ({})) + .addNode("node_b", () => ({})) + .addConditionalEdges(START, () => "node_a", { go: "node_a" }); + // LangGraph JS names every conditional branch "condition" and refuses a + // second one on the same source, so the runtime shape the exporter guards + // against is reproduced on the builder directly. + const branches = ( + graph as unknown as { + branches: Record>; + } + ).branches; + branches[START]!["condition2"] = branches[START]!["condition"]!; + + expect(() => exporter.toComponent(graph)).toThrow( + "Conversion of multiple conditional edges with the same source node is not yet supported", + ); + }); + + it("converts subgraph nodes into FlowNodes recursively", () => { + const exporter = new AgentSpecExporter(); + const SubState = Annotation.Root({ foo: Annotation }); + const subgraph = new StateGraph(SubState) + .addNode("subgraph_node_1", (state) => ({ foo: `hi! ${state.foo}` })) + .addEdge(START, "subgraph_node_1") + .compile(); + const parent = new StateGraph(SubState) + .addNode("node_1", subgraph) + .addEdge(START, "node_1"); + const compiled = parent.compile({ name: "GraphWithSubgraph" }); + + const flow = exporter.toComponent(compiled) as Flow; + + expect(flow.componentType).toBe("Flow"); + expect(flow.name).toBe("GraphWithSubgraph"); + const flowNodes = nodesOf(flow).filter( + (node) => node.componentType === "FlowNode", + ); + expect(flowNodes).toHaveLength(1); + expect(flowNodes[0]!.name).toBe("node_1"); + + const subflow = flowNodes[0]!.subflow as Flow; + expect(subflow.componentType).toBe("Flow"); + // Both levels synthesize __start__/__end__ around their single node. + expect(flow.nodes).toHaveLength(3); + expect(subflow.nodes).toHaveLength(3); + expect( + nodesOf(subflow).map((node) => [node.componentType, node.name]), + ).toEqual([ + ["ToolNode", "subgraph_node_1"], + ["StartNode", "__start__"], + ["EndNode", "__end__"], + ]); + // Explicit edge + implicit edge to END at each level. + expect(controlFlowNames(flow)).toEqual([ + "__start___to_node_1", + "node_1_to___end__", + ]); + expect(controlFlowNames(subflow as Flow)).toEqual([ + "__start___to_subgraph_node_1", + "subgraph_node_1_to___end__", + ]); + }); +}); + +describe("AgentSpecExporter: shared components and disaggregation", () => { + it("memoizes a chat model and tool shared by two agents", () => { + const converter = new LangGraphToAgentSpecConverter(); + const referencedObjects = new Map(); + const model = makeChatOpenAI(); + const sharedTool = makeWeatherLangChainTool(); + const first = converter.convert( + createAgent({ model, tools: [sharedTool], name: "first" }), + referencedObjects, + ) as Agent; + const second = converter.convert( + createAgent({ model, tools: [sharedTool], name: "second" }), + referencedObjects, + ) as Agent; + + // The runtime objects converted once: both agents reference components + // with the same ids (fresh conversions would generate fresh ids). + expect(first.llmConfig.id).toBe(second.llmConfig.id); + expect(first.tools[0]!.id).toBe(second.tools[0]!.id); + }); + + it("replaces disaggregated components with $component_ref in the export", () => { + const exporter = new AgentSpecExporter(); + const model = makeChatOpenAI(); + const agent = createAgent({ + model, + tools: [makeWeatherLangChainTool()], + systemPrompt: "You are a helpful assistant.", + name: "weather_agent", + }); + + const [mainDict, disagDict] = exporter.toDict(agent, { + disaggregatedComponents: [[model, "llm_config_id"]], + exportDisaggregatedComponents: true, + }) as [Record, Record]; + + expect(mainDict["component_type"]).toBe("Agent"); + expect(mainDict["llm_config"]).toEqual({ $component_ref: "llm_config_id" }); + const referenced = disagDict["$referenced_components"] as Record< + string, + Record + >; + expect(Object.keys(disagDict)).toEqual(["$referenced_components"]); + expect(Object.keys(referenced)).toContain("llm_config_id"); + expect(referenced["llm_config_id"]!["component_type"]).toBe( + "OpenAiCompatibleConfig", + ); + }); + + it("keeps the component's own id in the disaggregated dump; a custom id is only the mapping key", () => { + // Python parity: a custom disaggregation id is applied only as the + // serialization-time mapping key ($referenced_components key and + // $component_ref target). The converted component keeps its own id, so + // the same component object/id is used in the root tree and the + // disaggregated registry (no re-keyed copy). + const exporter = new AgentSpecExporter(); + const model = makeChatOpenAI(); + const sharedTool = makeWeatherLangChainTool(); + const agent = createAgent({ + model, + tools: [sharedTool], + systemPrompt: "You are a helpful assistant.", + name: "weather_agent", + }); + + const [mainDict, disagDict] = exporter.toDict(agent, { + disaggregatedComponents: [[model, "llm_config_id"], sharedTool], + exportDisaggregatedComponents: true, + }) as [Record, Record]; + + const referenced = disagDict["$referenced_components"] as Record< + string, + Record + >; + + // Custom-id entry: registry keyed by the custom id, component id kept. + const llmEntry = referenced["llm_config_id"]!; + expect(typeof llmEntry["id"]).toBe("string"); + expect(llmEntry["id"]).not.toBe("llm_config_id"); + expect(mainDict["llm_config"]).toEqual({ $component_ref: "llm_config_id" }); + + // Bare entry: registry keyed by the component's own id, referenced from + // the root under that same id. + const toolRefs = mainDict["tools"] as Array>; + expect(toolRefs).toHaveLength(1); + const toolRefId = toolRefs[0]!["$component_ref"] as string; + expect(toolRefId).not.toBe("llm_config_id"); + expect(referenced[toolRefId]!["id"]).toBe(toolRefId); + expect(referenced[toolRefId]!["component_type"]).toBe("ServerTool"); + }); +}); + +describe("AgentSpecExporter: export output shapes", () => { + function makeExportableAgent() { + return createAgent({ + model: makeChatOpenAI(), + tools: [makeWeatherLangChainTool()], + systemPrompt: "You are a helpful assistant.", + name: "weather_agent", + }); + } + + it("toJson returns a JSON string of the serialized component", () => { + const exporter = new AgentSpecExporter(); + const json = exporter.toJson(makeExportableAgent()); + + expect(typeof json).toBe("string"); + const parsed = JSON.parse(json as string) as Record; + expect(parsed["component_type"]).toBe("Agent"); + expect(parsed["name"]).toBe("weather_agent"); + expect(parsed["system_prompt"]).toBe("You are a helpful assistant."); + }); + + it("toYaml returns a YAML string of the serialized component", () => { + const exporter = new AgentSpecExporter(); + const yaml = exporter.toYaml(makeExportableAgent()); + + expect(typeof yaml).toBe("string"); + expect(yaml as string).toContain("component_type"); + expect(yaml as string).toContain("Agent"); + }); + + it("toDict returns the serialized dictionary", () => { + const exporter = new AgentSpecExporter(); + const dict = exporter.toDict(makeExportableAgent()) as Record< + string, + unknown + >; + + expect(Array.isArray(dict)).toBe(false); + expect(dict["component_type"]).toBe("Agent"); + expect(dict["name"]).toBe("weather_agent"); + }); + + it("returns [main, referenced] pairs for disaggregated json and yaml exports", () => { + const exporter = new AgentSpecExporter(); + const model = makeChatOpenAI(); + const agent = createAgent({ + model, + tools: [], + systemPrompt: "You are a helpful assistant.", + name: "weather_agent", + }); + const options = { + disaggregatedComponents: [[model, "llm_config_id"] as const], + exportDisaggregatedComponents: true, + }; + + const [mainJson, disagJson] = exporter.toJson(agent, options) as [ + string, + string, + ]; + expect(mainJson).toContain('"component_type": "Agent"'); + expect(mainJson).toContain("llm_config_id"); + expect(disagJson).toContain("$referenced_components"); + expect(disagJson).toContain("llm_config_id"); + + const [mainYaml, disagYaml] = exporter.toYaml(agent, options) as [ + string, + string, + ]; + expect(mainYaml).toContain("component_type"); + expect(mainYaml).toContain("Agent"); + expect(mainYaml).toContain("llm_config_id"); + expect(disagYaml).toContain("$referenced_components"); + expect(disagYaml).toContain("llm_config_id"); + }); +}); + +describe("AgentSpecExporter: loader round trip", () => { + it("re-exports a loaded Agent spec with equivalent llm, prompt and tools", async () => { + const llmConfig = createOpenAiCompatibleConfig({ + name: "llama", + url: LLAMA_URL, + modelId: MODEL_ID, + }); + const weatherTool = createServerTool({ + name: "get_weather", + description: "Returns the weather in a certain city", + inputs: [stringProperty({ title: "city" })], + outputs: [stringProperty({ title: "weather" })], + }); + const spec = makeAgent({ + name: "weather_agent", + systemPrompt: "You are a helpful assistant.", + llmConfig, + tools: [weatherTool], + }); + + const loader = new AgentSpecLoader({ + toolRegistry: { get_weather: () => "sunny" }, + }); + const loaded = (await loader.loadComponent(spec)) as LoadedReactAgent; + expect( + (loaded.options["model"] as { constructor: { name: string } }).constructor + .name, + ).toBe("ChatOpenAI"); + + const exporter = new AgentSpecExporter(); + const exported = exporter.toComponent(loaded) as Agent; + + expect(exported.componentType).toBe("Agent"); + expect(exported.name).toBe("weather_agent"); + expect(exported.systemPrompt).toBe("You are a helpful assistant."); + const exportedConfig = exported.llmConfig as OpenAiCompatibleConfig; + expect(exportedConfig.componentType).toBe("OpenAiCompatibleConfig"); + expect(exportedConfig.modelId).toBe(MODEL_ID); + expect(exportedConfig.url).toBe(LLAMA_URL); + expect(exported.tools).toHaveLength(1); + const exportedTool = exported.tools[0] as ServerTool; + expect(exportedTool.name).toBe("get_weather"); + expect(exportedTool.description).toBe( + "Returns the weather in a certain city", + ); + expect(exportedTool.inputs.map((input) => input.title)).toEqual(["city"]); + expect(exportedTool.inputs[0]!.type).toBe("string"); + }); +}); + +describe("AgentSpecExporter: unsupported runtime components", () => { + it("rejects values that match no supported runtime shape", () => { + const exporter = new AgentSpecExporter(); + + expect(() => exporter.toComponent(42)).toThrow( + "Conversion for 42 not implemented yet", + ); + }); +}); diff --git a/tsagentspec/tests/adapters/langgraph/flow-nodes.test.ts b/tsagentspec/tests/adapters/langgraph/flow-nodes.test.ts new file mode 100644 index 00000000..e243fde1 --- /dev/null +++ b/tsagentspec/tests/adapters/langgraph/flow-nodes.test.ts @@ -0,0 +1,1434 @@ +/** + * Per-node flow execution tests for the LangGraph adapter. + * + * Mirrors `pyagentspec/tests/adapters/langgraph/flows/` (test_toolnode, + * test_branchingnode, test_llmnode, test_agentnode, test_flownode, + * test_catchexceptionode, test_inputmessagenode, test_outputmessagenode, + * test_mapnode, test_apinode) with fake chat models and a mocked fetch so + * every test runs offline. + * + * Documented divergences exercised here: + * - Tuples do not exist in JS: arrays map positionally onto multiple declared + * tool-node outputs (Python restricts positional mapping to tuples). + * - The TS SDK ApiNode has no `urlAllowList` field yet, so the Python + * allow-list rejection test has no TS equivalent (the adapter always calls + * the validation helper with `undefined`). + * + * Note on node construction: the Python SDK infers the missing IO side of + * Start/End nodes, so Python specs always carry both sides on the wire; the + * TS factories default the missing side to `[]`, so these tests pass both + * sides explicitly, matching the serialized wire format. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { AIMessage, type BaseMessage } from "@langchain/core/messages"; +import { Command, MemorySaver } from "@langchain/langgraph"; +import { + createAgentNode, + createBranchingNode, + createCatchExceptionNode, + createClientTool, + createControlFlowEdge, + createDataFlowEdge, + createEndNode, + createFlow, + createFlowNode, + createInputMessageNode, + createLlmNode, + createMapNode, + createOutputMessageNode, + createServerTool, + createStartNode, + createToolNode, + createApiNode, + integerProperty, + listProperty, + nullProperty, + numberProperty, + objectProperty, + stringProperty, + unionProperty, + type ComponentWithIO, + type EndNode, + type Flow, + type LlmConfig, + type Property, + type ServerTool, + type StartNode, +} from "../../../src/index.js"; +import { DEFAULT_HTTP_REQUEST_TIMEOUT_MS } from "../../../src/adapters/common/tools-common.js"; +import { AgentSpecLoader } from "../../../src/adapters/langgraph/agentspec-loader.js"; +import { AgentSpecToLangGraphConverter } from "../../../src/adapters/langgraph/langgraph-converter.js"; +import { + FakeToolCallingChatModel, + getInterrupts, + installMockFetch, + loadWithFakeLlm, + makeAgent, + makeLlmConfig, + threadConfig, + toolCallMessage, + type MockFetchController, +} from "./test-helpers.js"; + +/** The invocable surface of a compiled flow graph. */ +interface CompiledFlow { + invoke( + input: unknown, + config?: unknown, + ): Promise>; +} + +/** A StartNode declaring the same properties as inputs and outputs. */ +function ioStartNode(name: string, props: Property[] = []): StartNode { + return createStartNode({ name, inputs: props, outputs: props }); +} + +/** An EndNode declaring the same properties as inputs and outputs. */ +function ioEndNode( + name: string, + props: Property[] = [], + branchName?: string, +): EndNode { + return createEndNode({ + name, + inputs: props, + outputs: props, + ...(branchName !== undefined ? { branchName } : {}), + }); +} + +function ctrl( + fromNode: Record, + toNode: Record, + fromBranch?: string, +) { + return createControlFlowEdge({ + name: `${String(fromNode["name"])}_to_${String(toNode["name"])}${ + fromBranch !== undefined ? `_${fromBranch}` : "" + }`, + fromNode, + toNode, + ...(fromBranch !== undefined ? { fromBranch } : {}), + }); +} + +function dataEdge( + sourceNode: ComponentWithIO, + destinationNode: ComponentWithIO, + sourceOutput: string, + destinationInput: string = sourceOutput, +) { + return createDataFlowEdge({ + name: `${sourceNode.name}.${sourceOutput}_to_${destinationNode.name}.${destinationInput}`, + sourceNode, + sourceOutput, + destinationNode, + destinationInput, + }); +} + +function outputsOf(result: Record): Record { + return result["outputs"] as Record; +} + +function messagesOf(result: Record): BaseMessage[] { + return result["messages"] as BaseMessage[]; +} + +function detailsOf(result: Record): Record { + return result["node_execution_details"] as Record; +} + +async function loadFlow( + flow: Flow, + options?: { + toolRegistry?: Record; + checkpointer?: MemorySaver; + }, +): Promise { + const loader = new AgentSpecLoader({ + ...(options?.toolRegistry !== undefined + ? { toolRegistry: options.toolRegistry } + : {}), + ...(options?.checkpointer !== undefined + ? { checkpointer: options.checkpointer } + : {}), + }); + return (await loader.loadComponent(flow)) as CompiledFlow; +} + +describe("ToolNode output-mapping matrix", () => { + /** Python's `_build_flow_with_client_tool`: start -> ClientTool -> end. */ + function buildClientToolFlow( + inputProp: Property, + outputProps: Property[], + ): Flow { + const start = ioStartNode("start", [inputProp]); + const clientTool = createClientTool({ + name: "echo_tool", + description: "Client-side tool used for testing", + inputs: [inputProp], + outputs: outputProps, + }); + const toolNode = createToolNode({ name: "tool", tool: clientTool }); + const end = ioEndNode("end", outputProps); + return createFlow({ + name: "tool_output_flow", + startNode: start, + nodes: [start, toolNode, end], + controlFlowConnections: [ctrl(start, toolNode), ctrl(toolNode, end)], + dataFlowConnections: [ + dataEdge(start, toolNode, inputProp.title), + ...outputProps.map((prop) => dataEdge(toolNode, end, prop.title)), + ], + }); + } + + /** Interrupt at the client tool, then resume with the given payload. */ + async function runFlowAndResume( + flow: Flow, + resumePayload: unknown, + ): Promise> { + const graph = await loadFlow(flow, { checkpointer: new MemorySaver() }); + const config = threadConfig("t"); + const first = await graph.invoke( + { inputs: { [flow.inputs![0]!.title]: 123 } }, + config, + ); + expect(getInterrupts(first)).toHaveLength(1); + const resumed = await graph.invoke( + new Command({ resume: resumePayload }), + config, + ); + return outputsOf(resumed); + } + + it("interrupts with the client_tool_request payload and resumes with the value", async () => { + const inputProp = numberProperty({ title: "input" }); + const outputProp = numberProperty({ title: "input_square" }); + const squareTool = createClientTool({ + name: "square_tool", + description: "Computes the square of a number", + inputs: [inputProp], + outputs: [outputProp], + }); + const start = ioStartNode("subflow_start", [inputProp]); + const toolNode = createToolNode({ name: "square_tool_node", tool: squareTool }); + const end = ioEndNode("subflow_end", [outputProp]); + const flow = createFlow({ + name: "Square number flow", + startNode: start, + nodes: [start, toolNode, end], + controlFlowConnections: [ctrl(start, toolNode), ctrl(toolNode, end)], + dataFlowConnections: [ + dataEdge(start, toolNode, "input"), + dataEdge(toolNode, end, "input_square"), + ], + }); + + const graph = await loadFlow(flow, { checkpointer: new MemorySaver() }); + const config = threadConfig("1"); + const first = await graph.invoke({ inputs: { input: 4 } }, config); + const interrupts = getInterrupts(first); + expect(interrupts).toHaveLength(1); + expect(interrupts[0]!.value).toEqual({ + type: "client_tool_request", + name: "square_tool", + description: "Computes the square of a number", + inputs: { args: [], kwargs: { input: 4 } }, + }); + + const resumed = await graph.invoke(new Command({ resume: 16 }), config); + expect(outputsOf(resumed)["input_square"]).toBe(16); + }); + + it("single ObjectProperty output wraps a multi-key dict under the declared key", async () => { + const flow = buildClientToolFlow(numberProperty({ title: "x" }), [ + objectProperty({ title: "out_dict", properties: {} }), + ]); + const outputs = await runFlowAndResume(flow, { a: 1, b: 2 }); + expect(outputs).toEqual({ out_dict: { a: 1, b: 2 } }); + }); + + it("single ObjectProperty output wraps a single-key dict under the declared key", async () => { + const flow = buildClientToolFlow(numberProperty({ title: "x" }), [ + objectProperty({ title: "out_dict", properties: {} }), + ]); + const outputs = await runFlowAndResume(flow, { a: 1 }); + expect(outputs).toEqual({ out_dict: { a: 1 } }); + }); + + it("single output uses a dict keyed by the declared title as-is", async () => { + const flow = buildClientToolFlow(numberProperty({ title: "x" }), [ + objectProperty({ title: "out_dict", properties: {} }), + ]); + const outputs = await runFlowAndResume(flow, { out_dict: 1 }); + expect(outputs).toEqual({ out_dict: 1 }); + }); + + it("scalar output passes through under the declared key", async () => { + const flow = buildClientToolFlow(numberProperty({ title: "x" }), [ + stringProperty({ title: "out_string" }), + ]); + const outputs = await runFlowAndResume(flow, "value"); + expect(outputs).toEqual({ out_string: "value" }); + }); + + it("multiple outputs filter the dict and defaults fill missing keys", async () => { + const flow = buildClientToolFlow(numberProperty({ title: "x" }), [ + numberProperty({ title: "a" }), + numberProperty({ title: "b", default: 0 }), + ]); + const outputs = await runFlowAndResume(flow, { a: 5 }); + expect(outputs).toEqual({ a: 5, b: 0 }); + }); + + it("list output maps to a single declared list output", async () => { + const flow = buildClientToolFlow(numberProperty({ title: "x" }), [ + listProperty({ title: "out", itemType: numberProperty({ title: "item" }) }), + ]); + const outputs = await runFlowAndResume(flow, [1, 2, 3]); + expect(outputs).toEqual({ out: [1, 2, 3] }); + }); + + it("scalar output maps to a single declared number output", async () => { + const flow = buildClientToolFlow(numberProperty({ title: "x" }), [ + numberProperty({ title: "out_number" }), + ]); + const outputs = await runFlowAndResume(flow, 42); + expect(outputs).toEqual({ out_number: 42 }); + }); + + it("array output onto a single declared string output is stringified", async () => { + // Python (tuple payload) stringifies via json.dumps -> "[1, 2]"; the TS + // cast mirrors json.dumps formatting (", " separator, not "[1,2]"). + const flow = buildClientToolFlow(numberProperty({ title: "x" }), [ + stringProperty({ title: "out" }), + ]); + const outputs = await runFlowAndResume(flow, [1, 2]); + expect(outputs).toEqual({ out: "[1, 2]" }); + }); + + it("array output shorter than the declared outputs raises like Python", async () => { + // Python raises IndexError instead of silently mapping undefined. + const flow = buildClientToolFlow(numberProperty({ title: "x" }), [ + numberProperty({ title: "a" }), + stringProperty({ title: "b" }), + ]); + await expect(runFlowAndResume(flow, [7])).rejects.toThrow( + "Tool node `tool` returned 1 value(s) but declares 2 outputs; " + + "no value for output `b`.", + ); + }); + + it("content-block list shorter than the declared outputs raises like Python", async () => { + const flow = buildClientToolFlow(numberProperty({ title: "x" }), [ + stringProperty({ title: "text_out" }), + stringProperty({ title: "image_out" }), + ]); + await expect( + runFlowAndResume(flow, [{ type: "text", text: "hello" }]), + ).rejects.toThrow( + "Tool node `tool` returned 1 content block(s) but declares 2 outputs; " + + "no value for output `image_out`.", + ); + }); + + it("array output maps positionally onto multiple outputs", async () => { + const flow = buildClientToolFlow(numberProperty({ title: "x" }), [ + numberProperty({ title: "a" }), + stringProperty({ title: "b" }), + ]); + const outputs = await runFlowAndResume(flow, [7, "ok"]); + expect(outputs).toEqual({ a: 7, b: "ok" }); + }); + + it("mixed array output maps positionally onto number/object/array outputs", async () => { + const flow = buildClientToolFlow(numberProperty({ title: "x" }), [ + numberProperty({ title: "num" }), + objectProperty({ title: "obj", properties: {} }), + listProperty({ title: "array", itemType: numberProperty({ title: "elem" }) }), + ]); + const outputs = await runFlowAndResume(flow, [7, { key: "val" }, [1]]); + expect(outputs).toEqual({ num: 7, obj: { key: "val" }, array: [1] }); + }); + + it("MCP content-block lists extract payloads positionally", async () => { + const flow = buildClientToolFlow(numberProperty({ title: "x" }), [ + stringProperty({ title: "text_out" }), + stringProperty({ title: "image_out" }), + ]); + const outputs = await runFlowAndResume(flow, [ + { type: "text", text: "hello" }, + { type: "image", base64: "imgdata" }, + ]); + expect(outputs).toEqual({ text_out: "hello", image_out: "imgdata" }); + }); +}); + +describe("BranchingNode", () => { + it("routes on the mapping, falls back to the default branch, and keeps defaults on untaken paths", async () => { + const customInput = stringProperty({ title: "custom_input" }); + const outputA = stringProperty({ title: "output_a", default: "no_value" }); + const outputB = stringProperty({ title: "output_b", default: "no_value" }); + const branchingNode = createBranchingNode({ + name: "branching", + mapping: { a: "branch_a", b: "branch_b" }, + inputs: [customInput], + }); + const start = ioStartNode("start", [customInput]); + const endA = ioEndNode("end_a", [outputA]); + const endB = ioEndNode("end_b", [outputB]); + const endDefault = ioEndNode("end_default"); + + const flow = createFlow({ + name: "flow", + startNode: start, + nodes: [start, branchingNode, endA, endB, endDefault], + controlFlowConnections: [ + ctrl(start, branchingNode), + ctrl(branchingNode, endA, "branch_a"), + ctrl(branchingNode, endB, "branch_b"), + ctrl(branchingNode, endDefault, "default"), + ], + dataFlowConnections: [ + dataEdge(start, branchingNode, "custom_input"), + dataEdge(start, endB, "custom_input", "output_b"), + dataEdge(start, endA, "custom_input", "output_a"), + ], + outputs: [outputA, outputB], + }); + + const graph = await loadFlow(flow); + + let result = await graph.invoke({ inputs: { custom_input: "a" } }); + expect(outputsOf(result)).toEqual({ output_a: "a", output_b: "no_value" }); + expect(result).toHaveProperty("messages"); + + result = await graph.invoke({ inputs: { custom_input: "b" } }); + expect(outputsOf(result)).toEqual({ output_a: "no_value", output_b: "b" }); + + result = await graph.invoke({ inputs: { custom_input: "no_match" } }); + expect(outputsOf(result)).toEqual({ + output_a: "no_value", + output_b: "no_value", + }); + }); + + it("raises the missing-input error when nothing feeds the branching input", async () => { + const customInput = stringProperty({ title: "custom_input" }); + const branchingNode = createBranchingNode({ + name: "branching", + mapping: { a: "branch_a" }, + inputs: [customInput], + }); + const start = ioStartNode("start"); + const endA = ioEndNode("end_a"); + const endDefault = ioEndNode("end_default"); + const flow = createFlow({ + name: "flow", + startNode: start, + nodes: [start, branchingNode, endA, endDefault], + controlFlowConnections: [ + ctrl(start, branchingNode), + ctrl(branchingNode, endA, "branch_a"), + ctrl(branchingNode, endDefault, "default"), + ], + dataFlowConnections: [], + }); + + const graph = await loadFlow(flow); + await expect(graph.invoke({ inputs: {} })).rejects.toThrow( + "Expected node `branching` to have a value for property `custom_input`, but none was found.", + ); + }); +}); + +/** Duck-typed chat-model fake for LlmNode tests. */ +function makeChatModelFake(opts: { + reply?: string; + structured?: Record; +}) { + const captured = { + prompts: [] as unknown[], + structuredSchemas: [] as Record[], + }; + const model = { + invoke: async (input: unknown) => { + captured.prompts.push(input); + return new AIMessage(opts.reply ?? ""); + }, + withStructuredOutput: (schema: Record) => { + captured.structuredSchemas.push(schema); + return { + invoke: async (input: unknown) => { + captured.prompts.push(input); + if (opts.structured === undefined) { + throw new Error("No structured response configured."); + } + return opts.structured; + }, + }; + }, + }; + return { model, captured }; +} + +describe("LlmNode", () => { + const nationality = stringProperty({ title: "nationality" }); + const car = stringProperty({ title: "car" }); + + function buildLlmFlow(outputs: Property[]): Flow { + const llmNode = createLlmNode({ + name: "llm_node", + llmConfig: makeLlmConfig(), + promptTemplate: + "Answer in one short sentence. What is the fastest {{nationality}} car?", + inputs: [nationality], + outputs, + }); + const start = ioStartNode("start", [nationality]); + const end = ioEndNode("end", outputs); + return createFlow({ + name: "flow", + startNode: start, + nodes: [start, llmNode, end], + controlFlowConnections: [ctrl(start, llmNode), ctrl(llmNode, end)], + dataFlowConnections: [ + dataEdge(start, llmNode, "nationality"), + ...outputs.map((prop) => dataEdge(llmNode, end, prop.title)), + ], + outputs, + }); + } + + it("unstructured: a single string output takes the message content of the rendered prompt call", async () => { + const { model, captured } = makeChatModelFake({ reply: "The Ferrari." }); + const { agent } = await loadWithFakeLlm(buildLlmFlow([car]), () => model); + + const result = await agent.invoke({ inputs: { nationality: "italian" } }); + expect(outputsOf(result)).toEqual({ car: "The Ferrari." }); + + // The prompt template was rendered against the node inputs. + expect(captured.structuredSchemas).toHaveLength(0); + expect(captured.prompts).toHaveLength(1); + const promptMessages = captured.prompts[0] as Array<{ + role: string; + content: string; + }>; + expect(promptMessages).toEqual([ + { + role: "user", + content: + "Answer in one short sentence. What is the fastest italian car?", + }, + ]); + }); + + it("structured: multiple outputs use withStructuredOutput with the built JSON schema", async () => { + const rating = integerProperty({ title: "rating" }); + const { model, captured } = makeChatModelFake({ + structured: { car: "Ferrari", rating: 9 }, + }); + const { agent } = await loadWithFakeLlm( + buildLlmFlow([car, rating]), + () => model, + ); + + const result = await agent.invoke({ inputs: { nationality: "italian" } }); + expect(outputsOf(result)).toEqual({ car: "Ferrari", rating: 9 }); + + expect(captured.structuredSchemas).toHaveLength(1); + expect(captured.structuredSchemas[0]).toEqual({ + title: "structured_output", + type: "object", + properties: { + car: car.jsonSchema, + rating: rating.jsonSchema, + }, + }); + }); + + it("structured: a flattened single-property result is rewrapped under the declared title", async () => { + const wrapped = objectProperty({ title: "wrapped", properties: {} }); + const { model } = makeChatModelFake({ structured: { inner: 1 } }); + const { agent } = await loadWithFakeLlm( + buildLlmFlow([wrapped]), + () => model, + ); + + const result = await agent.invoke({ inputs: { nationality: "italian" } }); + expect(outputsOf(result)).toEqual({ wrapped: { inner: 1 } }); + }); +}); + +describe("AgentNode in a flow", () => { + const nationality = stringProperty({ title: "nationality" }); + const car = stringProperty({ title: "car" }); + + function buildAgentFlow(): Flow { + const agentSpec = makeAgent({ + name: "agent", + systemPrompt: "What is the fastest {{nationality}} car?", + inputs: [nationality], + outputs: [car], + }); + const agentNode = createAgentNode({ name: "agent_node", agent: agentSpec }); + const start = ioStartNode("start", [nationality]); + const end = ioEndNode("end", [car]); + return createFlow({ + name: "flow", + startNode: start, + nodes: [start, agentNode, end], + controlFlowConnections: [ctrl(start, agentNode), ctrl(agentNode, end)], + dataFlowConnections: [ + dataEdge(start, agentNode, "nationality"), + dataEdge(agentNode, end, "car"), + ], + outputs: [car], + }); + } + + it("renders the system prompt from node inputs and extracts declared outputs", async () => { + const { agent, loader } = await loadWithFakeLlm(buildAgentFlow(), [ + toolCallMessage("AgentOutputModel", { car: "Ferrari 296" }), + ]); + + const result = await agent.invoke({ inputs: { nationality: "italian" } }); + expect(outputsOf(result)).toEqual({ car: "Ferrari 296" }); + + // The compiled react agent received the RENDERED system prompt (langchain + // v1 normalizes the prompt into a content-blocks array). + const fakeModel = loader.getFakeModel(); + const systemMessage = fakeModel.calls[0]![0]!; + expect(systemMessage.getType()).toBe("system"); + const systemText = JSON.stringify(systemMessage.content); + expect(systemText).toContain("What is the fastest italian car?"); + // No placeholder survives rendering. + expect(systemText).not.toContain("{{"); + }); + + it("emits the agent's answer as an assistant message when the node declares no outputs", async () => { + const chatAgent = makeAgent({ name: "chat_agent", systemPrompt: "Say hi." }); + const agentNode = createAgentNode({ name: "agent_node", agent: chatAgent }); + const start = ioStartNode("start"); + const end = ioEndNode("end"); + const flow = createFlow({ + name: "flow", + startNode: start, + nodes: [start, agentNode, end], + controlFlowConnections: [ctrl(start, agentNode), ctrl(agentNode, end)], + }); + + const { agent } = await loadWithFakeLlm(flow, [new AIMessage("Ciao!")]); + const result = await agent.invoke({ inputs: {} }); + + const messages = messagesOf(result); + expect(messages).toHaveLength(1); + expect(messages[0]!.getType()).toBe("ai"); + expect(messages[0]!.content).toBe("Ciao!"); + expect(outputsOf(result)).toEqual({}); + }); + + it("caches the compiled agent per rendered system prompt across invokes", async () => { + /** Converter that counts react-agent compilations and injects a fake LLM. */ + class CountingFakeConverter extends AgentSpecToLangGraphConverter { + compileCount = 0; + + constructor(private readonly model: unknown) { + super(); + } + + protected override async convertLlmConfig( + _llmConfig: LlmConfig, + ): Promise { + return this.model; + } + + protected override async createReactAgentWithGivenInfo( + info: unknown, + context: unknown, + ): Promise { + this.compileCount += 1; + return super.createReactAgentWithGivenInfo( + info as never, + context as never, + ); + } + } + + const fakeModel = new FakeToolCallingChatModel({ + responses: [toolCallMessage("AgentOutputModel", { car: "Ferrari" })], + }); + const converter = new CountingFakeConverter(fakeModel); + const graph = (await converter.convert(buildAgentFlow(), {})) as CompiledFlow; + + // Compilation is lazy: nothing is compiled at load time. + expect(converter.compileCount).toBe(0); + + let result = await graph.invoke({ inputs: { nationality: "italian" } }); + expect(outputsOf(result)["car"]).toBe("Ferrari"); + expect(converter.compileCount).toBe(1); + + // Same rendered prompt: the cached agent is reused. + result = await graph.invoke({ inputs: { nationality: "italian" } }); + expect(converter.compileCount).toBe(1); + + // A different rendered prompt compiles a new agent. + result = await graph.invoke({ inputs: { nationality: "french" } }); + expect(outputsOf(result)["car"]).toBe("Ferrari"); + expect(converter.compileCount).toBe(2); + }); +}); + +describe("FlowNode", () => { + it("executes the subflow and passes its outputs through", async () => { + const customProp = stringProperty({ title: "custom_prop" }); + const subStart = ioStartNode("start", [customProp]); + const subEnd = ioEndNode("end", [customProp]); + const subflow = createFlow({ + name: "subflow", + startNode: subStart, + nodes: [subStart, subEnd], + controlFlowConnections: [ctrl(subStart, subEnd)], + dataFlowConnections: [dataEdge(subStart, subEnd, "custom_prop")], + inputs: [customProp], + outputs: [customProp], + }); + + const flowNode = createFlowNode({ name: "flow_node", subflow }); + const start = ioStartNode("start", [customProp]); + const end = ioEndNode("end", [customProp]); + const flow = createFlow({ + name: "outer", + startNode: start, + nodes: [start, flowNode, end], + controlFlowConnections: [ctrl(start, flowNode), ctrl(flowNode, end)], + dataFlowConnections: [ + dataEdge(start, flowNode, "custom_prop"), + dataEdge(flowNode, end, "custom_prop"), + ], + inputs: [customProp], + outputs: [customProp], + }); + + const graph = await loadFlow(flow); + const result = await graph.invoke({ inputs: { custom_prop: "custom" } }); + expect(result).toHaveProperty("messages"); + expect(outputsOf(result)).toEqual({ custom_prop: "custom" }); + }); +}); + +describe("CatchExceptionNode", () => { + const inp = integerProperty({ title: "x" }); + const outp = stringProperty({ title: "y", default: "" }); + + function makeErrorInfoProperty(): Property { + return unionProperty({ + title: "error_info", + anyOf: [ + stringProperty({ title: "error_info" }), + nullProperty({ title: "error_info" }), + ], + default: null, + }); + } + + function buildToolSubflow(tool: ServerTool, endBranch?: string): Flow { + const subStart = ioStartNode("sub_start", [inp]); + const toolNode = createToolNode({ name: `${tool.name}_node`, tool }); + const subEnd = ioEndNode("sub_end", [outp], endBranch); + return createFlow({ + name: `${tool.name}_subflow`, + startNode: subStart, + nodes: [subStart, toolNode, subEnd], + controlFlowConnections: [ctrl(subStart, toolNode), ctrl(toolNode, subEnd)], + dataFlowConnections: [ + dataEdge(subStart, toolNode, "x"), + dataEdge(toolNode, subEnd, "y"), + ], + inputs: [inp], + outputs: [outp], + }); + } + + it("routes exceptions to the caught_exception_branch with default outputs and caught_exception_info", async () => { + const flakyTool = createServerTool({ + name: "flaky_tool", + description: "Raises for negative inputs", + inputs: [inp], + outputs: [outp], + }); + const subflow = buildToolSubflow(flakyTool); + const catchNode = createCatchExceptionNode({ name: "catch", subflow }); + const errorInfo = makeErrorInfoProperty(); + const start = ioStartNode("start", [inp]); + const end = ioEndNode("end", [outp]); + const errorEnd = ioEndNode("error_end", [errorInfo], "ERROR"); + const flow = createFlow({ + name: "outer", + startNode: start, + nodes: [start, catchNode, end, errorEnd], + controlFlowConnections: [ + ctrl(start, catchNode), + ctrl(catchNode, end), + ctrl(catchNode, errorEnd, "caught_exception_branch"), + ], + dataFlowConnections: [ + dataEdge(start, catchNode, "x"), + dataEdge(catchNode, end, "y"), + dataEdge(catchNode, errorEnd, "caught_exception_info", "error_info"), + ], + inputs: [inp], + outputs: [outp, errorInfo], + }); + + const graph = await loadFlow(flow, { + toolRegistry: { + flaky_tool: (input: unknown) => { + const { x } = input as { x: number }; + if (x < 0) { + throw new Error("x must be non-negative"); + } + return "ok"; + }, + }, + }); + + // Case 1: no exception -> subflow output passes through. + let result = await graph.invoke({ inputs: { x: 1 } }); + expect(outputsOf(result)["y"]).toBe("ok"); + expect(outputsOf(result)["error_info"]).toBeNull(); + + // Case 2: exception -> default output value, ERROR end branch, and the + // exception message routed through caught_exception_info. + result = await graph.invoke({ inputs: { x: -1 } }); + expect(outputsOf(result)["y"]).toBe(""); + expect(detailsOf(result)["branch"]).toBe("ERROR"); + const caught = outputsOf(result)["error_info"]; + expect(typeof caught).toBe("string"); + expect(String(caught)).toContain("x must be non-negative"); + }); + + it("propagates a custom subflow end branch on success with null exception info", async () => { + const okTool = createServerTool({ + name: "ok_tool", + description: "Always returns ok", + inputs: [inp], + outputs: [outp], + }); + const subflow = buildToolSubflow(okTool, "OK"); + const catchNode = createCatchExceptionNode({ name: "catch", subflow }); + const errorInfo = makeErrorInfoProperty(); + const start = ioStartNode("start", [inp]); + const okEnd = ioEndNode("ok_end", [outp, errorInfo]); + const otherEnd = ioEndNode("other_end"); + const flow = createFlow({ + name: "outer", + startNode: start, + nodes: [start, catchNode, okEnd, otherEnd], + controlFlowConnections: [ + ctrl(start, catchNode), + ctrl(catchNode, okEnd, "OK"), + ctrl(catchNode, otherEnd), + ], + dataFlowConnections: [ + dataEdge(start, catchNode, "x"), + dataEdge(catchNode, okEnd, "y"), + dataEdge(catchNode, okEnd, "caught_exception_info", "error_info"), + ], + inputs: [inp], + outputs: [outp, errorInfo], + }); + + const graph = await loadFlow(flow, { + toolRegistry: { ok_tool: () => "ok" }, + }); + const result = await graph.invoke({ inputs: { x: 7 } }); + expect(detailsOf(result)["branch"]).toBe("next"); + expect(outputsOf(result)["y"]).toBe("ok"); + expect(outputsOf(result)["error_info"]).toBeNull(); + }); + + it("uses the default next branch on success with null exception info", async () => { + const okTool = createServerTool({ + name: "ok_tool_default", + description: "Always returns ok", + inputs: [inp], + outputs: [outp], + }); + const subflow = buildToolSubflow(okTool); + const catchNode = createCatchExceptionNode({ name: "catch", subflow }); + const errorInfo = makeErrorInfoProperty(); + const start = ioStartNode("start", [inp]); + const nextEnd = ioEndNode("next_end", [outp, errorInfo]); + const flow = createFlow({ + name: "outer", + startNode: start, + nodes: [start, catchNode, nextEnd], + controlFlowConnections: [ctrl(start, catchNode), ctrl(catchNode, nextEnd)], + dataFlowConnections: [ + dataEdge(start, catchNode, "x"), + dataEdge(catchNode, nextEnd, "y"), + dataEdge(catchNode, nextEnd, "caught_exception_info", "error_info"), + ], + inputs: [inp], + outputs: [outp, errorInfo], + }); + + const graph = await loadFlow(flow, { + toolRegistry: { ok_tool_default: () => "ok" }, + }); + const result = await graph.invoke({ inputs: { x: 5 } }); + expect(detailsOf(result)["branch"]).toBe("next"); + expect(outputsOf(result)["y"]).toBe("ok"); + expect(outputsOf(result)["error_info"]).toBeNull(); + }); +}); + +describe("InputMessageNode", () => { + it("interrupts with an empty payload; the resume value becomes the output and a user message", async () => { + const customInput = stringProperty({ title: "custom_input" }); + const inputMessageNode = createInputMessageNode({ + name: "input_message", + outputs: [customInput], + }); + const start = ioStartNode("start"); + const end = ioEndNode("end", [customInput]); + const flow = createFlow({ + name: "flow", + startNode: start, + nodes: [start, inputMessageNode, end], + controlFlowConnections: [ + ctrl(start, inputMessageNode), + ctrl(inputMessageNode, end), + ], + dataFlowConnections: [dataEdge(inputMessageNode, end, "custom_input")], + outputs: [customInput], + }); + + const graph = await loadFlow(flow, { checkpointer: new MemorySaver() }); + const config = threadConfig("1"); + + const first = await graph.invoke({}, config); + const interrupts = getInterrupts(first); + expect(interrupts).toHaveLength(1); + expect(interrupts[0]!.value).toBe(""); + + const result = await graph.invoke(new Command({ resume: "3" }), config); + expect(outputsOf(result)).toEqual({ custom_input: "3" }); + + const messages = messagesOf(result); + expect(messages).toHaveLength(1); + expect(messages[0]!.getType()).toBe("human"); + expect(messages[0]!.content).toBe("3"); + }); +}); + +describe("OutputMessageNode", () => { + it("emits the rendered template as an assistant message", async () => { + const customInput = stringProperty({ title: "custom_input" }); + const outputMessageNode = createOutputMessageNode({ + name: "output_message", + message: "Hey {{custom_input}}", + inputs: [customInput], + }); + const start = ioStartNode("start", [customInput]); + const end = ioEndNode("end"); + const flow = createFlow({ + name: "flow", + startNode: start, + nodes: [start, outputMessageNode, end], + controlFlowConnections: [ + ctrl(start, outputMessageNode), + ctrl(outputMessageNode, end), + ], + dataFlowConnections: [dataEdge(start, outputMessageNode, "custom_input")], + inputs: [customInput], + }); + + const graph = await loadFlow(flow); + const result = await graph.invoke({ inputs: { custom_input: "custom" } }); + + expect(result).toHaveProperty("outputs"); + const messages = messagesOf(result); + expect(messages).toHaveLength(1); + expect(messages[0]!.getType()).toBe("ai"); + expect(messages[0]!.content).toBe("Hey custom"); + }); +}); + +describe("MapNode", () => { + function buildSquareSubflow(): Flow { + const xProp = numberProperty({ title: "input" }); + const xSquareProp = numberProperty({ title: "input_square" }); + const squareTool = createServerTool({ + name: "square_tool", + description: "Computes the square of a number", + inputs: [xProp], + outputs: [xSquareProp], + }); + const subStart = ioStartNode("subflow_start", [xProp]); + const toolNode = createToolNode({ name: "square_tool_node", tool: squareTool }); + const subEnd = ioEndNode("subflow_end", [xSquareProp]); + return createFlow({ + name: "Square number flow", + startNode: subStart, + nodes: [subStart, toolNode, subEnd], + controlFlowConnections: [ctrl(subStart, toolNode), ctrl(toolNode, subEnd)], + dataFlowConnections: [ + dataEdge(subStart, toolNode, "input"), + dataEdge(toolNode, subEnd, "input_square"), + ], + }); + } + + const iteratedInput = unionProperty({ + title: "iterated_input", + anyOf: [ + numberProperty({ title: "input" }), + listProperty({ title: "input", itemType: numberProperty({ title: "item" }) }), + ], + }); + const collectedSquare = listProperty({ + title: "collected_input_square", + itemType: numberProperty({ title: "item" }), + }); + const squareRegistry = { + square_tool: (input: unknown) => { + const { input: value } = input as { input: number }; + return value * value; + }, + }; + + it("iterates the subflow over the list input and collects the outputs", async () => { + const mapNode = createMapNode({ + name: "square_number_map_node", + subflow: buildSquareSubflow(), + inputs: [iteratedInput], + outputs: [collectedSquare], + }); + const inputList = listProperty({ + title: "input_list", + itemType: numberProperty({ title: "item" }), + }); + const start = ioStartNode("outer_start", [inputList]); + const end = ioEndNode("outer_end", [collectedSquare]); + const flow = createFlow({ + name: "flow to square all elements of a list", + startNode: start, + nodes: [start, mapNode, end], + controlFlowConnections: [ctrl(start, mapNode), ctrl(mapNode, end)], + dataFlowConnections: [ + dataEdge(start, mapNode, "input_list", "iterated_input"), + dataEdge(mapNode, end, "collected_input_square"), + ], + }); + + const graph = await loadFlow(flow, { toolRegistry: squareRegistry }); + const result = await graph.invoke({ inputs: { input_list: [1, 2, 3, 4] } }); + expect(outputsOf(result)).toEqual({ + collected_input_square: [1, 4, 9, 16], + }); + }); + + it("raises when iterated inputs have different lengths", async () => { + const aProp = numberProperty({ title: "a" }); + const bProp = numberProperty({ title: "b" }); + const totalProp = numberProperty({ title: "total" }); + const sumTool = createServerTool({ + name: "sum_tool", + description: "Adds two numbers", + inputs: [aProp, bProp], + outputs: [totalProp], + }); + const subStart = ioStartNode("sum_start", [aProp, bProp]); + const toolNode = createToolNode({ name: "sum_tool_node", tool: sumTool }); + const subEnd = ioEndNode("sum_end", [totalProp]); + const sumSubflow = createFlow({ + name: "sum_subflow", + startNode: subStart, + nodes: [subStart, toolNode, subEnd], + controlFlowConnections: [ctrl(subStart, toolNode), ctrl(toolNode, subEnd)], + dataFlowConnections: [ + dataEdge(subStart, toolNode, "a"), + dataEdge(subStart, toolNode, "b"), + dataEdge(toolNode, subEnd, "total"), + ], + inputs: [aProp, bProp], + outputs: [totalProp], + }); + + const iteratedA = unionProperty({ + title: "iterated_a", + anyOf: [ + numberProperty({ title: "a" }), + listProperty({ title: "a", itemType: numberProperty({ title: "item" }) }), + ], + }); + const iteratedB = unionProperty({ + title: "iterated_b", + anyOf: [ + numberProperty({ title: "b" }), + listProperty({ title: "b", itemType: numberProperty({ title: "item" }) }), + ], + }); + const collectedTotal = listProperty({ + title: "collected_total", + itemType: numberProperty({ title: "item" }), + }); + const mapNode = createMapNode({ + name: "sum_map_node", + subflow: sumSubflow, + inputs: [iteratedA, iteratedB], + outputs: [collectedTotal], + }); + + const listA = listProperty({ + title: "list_a", + itemType: numberProperty({ title: "item" }), + }); + const listB = listProperty({ + title: "list_b", + itemType: numberProperty({ title: "item" }), + }); + const start = ioStartNode("outer_start", [listA, listB]); + const end = ioEndNode("outer_end", [collectedTotal]); + const flow = createFlow({ + name: "sum_map_flow", + startNode: start, + nodes: [start, mapNode, end], + controlFlowConnections: [ctrl(start, mapNode), ctrl(mapNode, end)], + dataFlowConnections: [ + dataEdge(start, mapNode, "list_a", "iterated_a"), + dataEdge(start, mapNode, "list_b", "iterated_b"), + dataEdge(mapNode, end, "collected_total"), + ], + }); + + const graph = await loadFlow(flow, { + toolRegistry: { + sum_tool: (input: unknown) => { + const { a, b } = input as { a: number; b: number }; + return a + b; + }, + }, + }); + await expect( + graph.invoke({ inputs: { list_a: [1, 2], list_b: [10, 20, 30] } }), + ).rejects.toThrow("Found inputs to iterate with different sizes"); + }); + + it("raises when no data-flow edge selects an input to iterate", async () => { + const mapNode = createMapNode({ + name: "square_map_scalar", + subflow: buildSquareSubflow(), + inputs: [iteratedInput], + outputs: [collectedSquare], + }); + // The edge feeds a SCALAR into iterated_input, so the converter finds no + // list-typed source matching the subflow input and selects nothing. + const singleX = numberProperty({ title: "single_x" }); + const start = ioStartNode("outer_start", [singleX]); + const end = ioEndNode("outer_end", [collectedSquare]); + const flow = createFlow({ + name: "scalar_map_flow", + startNode: start, + nodes: [start, mapNode, end], + controlFlowConnections: [ctrl(start, mapNode), ctrl(mapNode, end)], + dataFlowConnections: [ + dataEdge(start, mapNode, "single_x", "iterated_input"), + dataEdge(mapNode, end, "collected_input_square"), + ], + }); + + const graph = await loadFlow(flow, { toolRegistry: squareRegistry }); + await expect(graph.invoke({ inputs: { single_x: 3 } })).rejects.toThrow( + "MapNode has no inputs to iterate", + ); + }); +}); + +describe("ApiNode", () => { + let mockFetch: MockFetchController | undefined; + + afterEach(() => { + mockFetch?.restore(); + mockFetch = undefined; + vi.restoreAllMocks(); + }); + + function buildApiFlow( + apiNode: Record, + inputProps: Property[], + outputProps: Property[], + ): Flow { + const start = ioStartNode("start", inputProps); + const end = ioEndNode("end", outputProps); + return createFlow({ + name: "api_flow", + startNode: start, + nodes: [start, apiNode, end], + controlFlowConnections: [ctrl(start, apiNode), ctrl(apiNode, end)], + dataFlowConnections: [ + ...inputProps.map((prop) => + dataEdge(start, apiNode as unknown as ComponentWithIO, prop.title), + ), + ...outputProps.map((prop) => + dataEdge(apiNode as unknown as ComponentWithIO, end, prop.title), + ), + ], + inputs: inputProps, + outputs: outputProps, + }); + } + + it("GET: templates the URL, query params and headers, and maps the JSON response", async () => { + // Templated URL destination without an allow list warns per Python rules. + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const inputProps = [ + stringProperty({ title: "host" }), + stringProperty({ title: "order_id" }), + stringProperty({ title: "flag" }), + stringProperty({ title: "token" }), + ]; + const status = stringProperty({ title: "status" }); + const apiNode = createApiNode({ + name: "api", + url: "https://{{host}}/orders/{{order_id}}", + httpMethod: "GET", + queryParams: { verbose: "{{flag}}" }, + headers: { "X-Auth": "Bearer {{token}}" }, + inputs: inputProps, + outputs: [status], + }); + const flow = buildApiFlow(apiNode, inputProps, [status]); + const graph = await loadFlow(flow); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("ApiNode `api` uses placeholders in the URL destination"), + ); + + mockFetch = installMockFetch(() => ({ status: "ok" })); + const result = await graph.invoke({ + inputs: { + host: "allowed.example.com", + order_id: "123", + flag: "yes", + token: "tok-1", + }, + }); + + expect(outputsOf(result)).toEqual({ status: "ok" }); + expect(mockFetch.calls).toHaveLength(1); + expect(mockFetch.calls[0]!.url).toBe( + "https://allowed.example.com/orders/123?verbose=yes", + ); + const init = mockFetch.calls[0]!.init!; + expect(init.method).toBe("GET"); + expect((init.headers as Record)["X-Auth"]).toBe( + "Bearer tok-1", + ); + expect(init.body).toBeUndefined(); + }); + + it("GET: warns when declared request data is dropped (fetch forbids GET bodies)", async () => { + // Python's httpx sends the body on GET; fetch cannot, so the adapter + // must at least warn instead of silently discarding the declared data. + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const inputProps = [stringProperty({ title: "term" })]; + const echo = stringProperty({ title: "echo" }); + const apiNode = createApiNode({ + name: "api", + url: "https://api.example.com/search", + httpMethod: "GET", + data: { q: "{{term}}" }, + inputs: inputProps, + outputs: [echo], + }); + const flow = buildApiFlow(apiNode, inputProps, [echo]); + const graph = await loadFlow(flow); + + mockFetch = installMockFetch(() => ({ echo: "done" })); + await graph.invoke({ inputs: { term: "boots" } }); + + expect(mockFetch.calls[0]!.init!.body).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + "ApiNode `api` declares request data for HTTP method GET", + ), + ); + }); + + it("GET: does not warn about a dropped body for the default empty data", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const echo = stringProperty({ title: "echo" }); + const apiNode = createApiNode({ + name: "api", + url: "https://api.example.com/plain", + httpMethod: "GET", + outputs: [echo], + }); + const flow = buildApiFlow(apiNode, [], [echo]); + const graph = await loadFlow(flow); + + mockFetch = installMockFetch(() => ({ echo: "done" })); + await graph.invoke({ inputs: {} }); + + expect(warnSpy).not.toHaveBeenCalledWith( + expect.stringContaining("declares request data"), + ); + }); + + it("POST: templated dict data is sent as a JSON body with a JSON content type", async () => { + const inputProps = [ + stringProperty({ title: "order_id" }), + stringProperty({ title: "tag" }), + ]; + const echo = stringProperty({ title: "echo" }); + const apiNode = createApiNode({ + name: "api", + url: "https://api.example.com/orders", + httpMethod: "POST", + data: { order: { id: "{{order_id}}" }, tags: ["{{tag}}", "static"] }, + inputs: inputProps, + outputs: [echo], + }); + const flow = buildApiFlow(apiNode, inputProps, [echo]); + const graph = await loadFlow(flow); + + mockFetch = installMockFetch(() => ({ echo: "done" })); + const result = await graph.invoke({ + inputs: { order_id: "777", tag: "blue" }, + }); + + expect(outputsOf(result)).toEqual({ echo: "done" }); + const init = mockFetch.calls[0]!.init!; + expect(init.method).toBe("POST"); + expect( + (init.headers as Record)["Content-Type"], + ).toBe("application/json"); + expect(JSON.parse(String(init.body))).toEqual({ + order: { id: "777" }, + tags: ["blue", "static"], + }); + }); + + it("POST: an urlencoded content type sends dict data as a form body and templates header keys", async () => { + const inputProps = [ + stringProperty({ title: "a" }), + stringProperty({ title: "key_name" }), + stringProperty({ title: "key_val" }), + ]; + const echo = stringProperty({ title: "echo" }); + const apiNode = createApiNode({ + name: "api", + url: "https://api.example.com/form", + httpMethod: "POST", + data: { a: "{{a}}", b: "static" }, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-{{key_name}}": "{{key_val}}", + }, + inputs: inputProps, + outputs: [echo], + }); + const flow = buildApiFlow(apiNode, inputProps, [echo]); + const graph = await loadFlow(flow); + + mockFetch = installMockFetch(() => ({ echo: "done" })); + const result = await graph.invoke({ + inputs: { a: "1", key_name: "Trace", key_val: "on" }, + }); + + expect(outputsOf(result)).toEqual({ echo: "done" }); + const init = mockFetch.calls[0]!.init!; + const headers = init.headers as Record; + expect(headers["Content-Type"]).toBe("application/x-www-form-urlencoded"); + expect(headers["X-Trace"]).toBe("on"); + expect(init.body).toBeInstanceOf(URLSearchParams); + expect(String(init.body)).toBe("a=1&b=static"); + }); + + it("does not follow redirects: a 3xx response body maps to the node outputs like any status", async () => { + // Python's httpx does not follow redirects (follow_redirects defaults to + // False) and parses the returned 3xx body like any other status; the + // adapter uses redirect: "manual" so undici returns the 3xx response + // itself instead of requesting the Location target. + const echo = stringProperty({ title: "echo" }); + const apiNode = createApiNode({ + name: "api", + url: "https://api.example.com/redirecting", + httpMethod: "GET", + outputs: [echo], + }); + const flow = buildApiFlow(apiNode, [], [echo]); + const graph = await loadFlow(flow); + + mockFetch = installMockFetch( + () => + new Response('{"echo": "from-redirect-response"}', { + status: 302, + headers: { + "Content-Type": "application/json", + Location: "https://attacker.example/exfil", + }, + }), + ); + const result = await graph.invoke({ inputs: {} }); + + expect(outputsOf(result)).toEqual({ echo: "from-redirect-response" }); + expect(mockFetch.calls).toHaveLength(1); + expect(mockFetch.calls[0]!.init!.redirect).toBe("manual"); + }); + + it("attaches the default httpx-parity timeout and names the node on a timeout abort", async () => { + const echo = stringProperty({ title: "echo" }); + const apiNode = createApiNode({ + name: "api", + url: "https://api.example.com/slow", + httpMethod: "GET", + outputs: [echo], + }); + const flow = buildApiFlow(apiNode, [], [echo]); + const graph = await loadFlow(flow); + + mockFetch = installMockFetch(() => { + throw new DOMException( + "The operation was aborted due to timeout", + "TimeoutError", + ); + }); + + await expect(graph.invoke({ inputs: {} })).rejects.toThrow( + `ApiNode \`api\` HTTP request timed out after ${DEFAULT_HTTP_REQUEST_TIMEOUT_MS}ms.`, + ); + expect(mockFetch.calls).toHaveLength(1); + expect(mockFetch.calls[0]!.init!.signal).toBeInstanceOf(AbortSignal); + }); + + it("POST: string data is sent as a raw body without forcing a content type", async () => { + const inputProps = [stringProperty({ title: "val" })]; + const echo = stringProperty({ title: "echo" }); + const apiNode = createApiNode({ + name: "api", + url: "https://api.example.com/raw", + httpMethod: "POST", + data: "payload={{val}}", + inputs: inputProps, + outputs: [echo], + }); + const flow = buildApiFlow(apiNode, inputProps, [echo]); + const graph = await loadFlow(flow); + + mockFetch = installMockFetch(() => ({ echo: "done" })); + const result = await graph.invoke({ inputs: { val: "hello" } }); + + expect(outputsOf(result)).toEqual({ echo: "done" }); + const init = mockFetch.calls[0]!.init!; + expect(init.body).toBe("payload=hello"); + const headerKeys = Object.keys(init.headers as Record); + expect( + headerKeys.some((key) => key.toLowerCase() === "content-type"), + ).toBe(false); + }); +}); diff --git a/tsagentspec/tests/adapters/langgraph/flow-state.test.ts b/tsagentspec/tests/adapters/langgraph/flow-state.test.ts new file mode 100644 index 00000000..e7db93fb --- /dev/null +++ b/tsagentspec/tests/adapters/langgraph/flow-state.test.ts @@ -0,0 +1,378 @@ +/** + * Flow state contract tests for the LangGraph adapter. + * + * Mirrors the state-shape behaviors of the Python suite + * (`pyagentspec/tests/adapters/langgraph/flows/`): the `{inputs}` in / + * `{outputs, messages, node_execution_details}` out contract, StartNode + * consumption of flow-level inputs (defaults, casting, missing-required + * error), automatically generated data-flow edges when the flow declares no + * `dataFlowConnections`, and explicit data-flow edges routing between + * differently-named properties. All tests run offline. + * + * Note on node construction: the Python SDK infers the missing IO side of + * Start/End nodes (`_get_inferred_inputs/outputs` return `inputs or outputs`), + * so Python specs always carry both on the wire. The TS factories do not infer + * (`createStartNode`/`createEndNode` default the missing side to `[]`), so + * these tests pass both sides explicitly, matching the serialized wire format. + */ +import { describe, expect, it } from "vitest"; +import type { BaseMessage } from "@langchain/core/messages"; +import { + createControlFlowEdge, + createDataFlowEdge, + createEndNode, + createFlow, + createServerTool, + createStartNode, + createToolNode, + integerProperty, + numberProperty, + stringProperty, + type ComponentWithIO, + type EndNode, + type Flow, + type Property, + type StartNode, +} from "../../../src/index.js"; +import { AgentSpecLoader } from "../../../src/adapters/langgraph/agentspec-loader.js"; + +/** The invocable surface of a compiled flow graph. */ +interface CompiledFlow { + invoke( + input: unknown, + config?: unknown, + ): Promise>; +} + +/** A StartNode declaring the same properties as inputs and outputs. */ +function ioStartNode(name: string, props: Property[] = []): StartNode { + return createStartNode({ name, inputs: props, outputs: props }); +} + +/** An EndNode declaring the same properties as inputs and outputs. */ +function ioEndNode( + name: string, + props: Property[] = [], + branchName?: string, +): EndNode { + return createEndNode({ + name, + inputs: props, + outputs: props, + ...(branchName !== undefined ? { branchName } : {}), + }); +} + +function ctrl( + fromNode: Record, + toNode: Record, + fromBranch?: string, +) { + return createControlFlowEdge({ + name: `${String(fromNode["name"])}_to_${String(toNode["name"])}${ + fromBranch !== undefined ? `_${fromBranch}` : "" + }`, + fromNode, + toNode, + ...(fromBranch !== undefined ? { fromBranch } : {}), + }); +} + +function dataEdge( + sourceNode: ComponentWithIO, + destinationNode: ComponentWithIO, + sourceOutput: string, + destinationInput: string = sourceOutput, +) { + return createDataFlowEdge({ + name: `${sourceNode.name}.${sourceOutput}_to_${destinationNode.name}.${destinationInput}`, + sourceNode, + sourceOutput, + destinationNode, + destinationInput, + }); +} + +async function loadFlow(flow: Flow, toolRegistry?: Record) { + const loader = new AgentSpecLoader( + toolRegistry !== undefined ? { toolRegistry } : undefined, + ); + return (await loader.loadComponent(flow)) as CompiledFlow; +} + +/** A start -> end pass-through flow over the given properties. */ +function passThroughFlow(props: Property[], endBranchName?: string): Flow { + const start = ioStartNode("start", props); + const end = ioEndNode("end", props, endBranchName); + return createFlow({ + name: "pass_through_flow", + startNode: start, + nodes: [start, end], + controlFlowConnections: [ctrl(start, end)], + dataFlowConnections: props.map((prop) => dataEdge(start, end, prop.title)), + }); +} + +describe("flow state contract", () => { + it("takes {inputs} in and returns {outputs, messages, node_execution_details}", async () => { + const graph = await loadFlow( + passThroughFlow([stringProperty({ title: "x" })]), + ); + const result = await graph.invoke({ inputs: { x: "v" } }); + + expect(result).toHaveProperty("outputs"); + expect(result).toHaveProperty("messages"); + expect(result).toHaveProperty("node_execution_details"); + // `inputs` is internal routing state and must not leak out. + expect("inputs" in result).toBe(false); + + expect(result["outputs"]).toEqual({ x: "v" }); + expect(result["messages"]).toEqual([]); + expect(result["node_execution_details"]).toEqual({ + branch: "next", + generated_messages: [], + should_finish: true, + }); + }); + + it("exposes a custom EndNode branchName in node_execution_details.branch", async () => { + const graph = await loadFlow( + passThroughFlow([stringProperty({ title: "x" })], "DONE"), + ); + const result = await graph.invoke({ inputs: { x: "v" } }); + expect( + (result["node_execution_details"] as Record)["branch"], + ).toBe("DONE"); + expect( + (result["node_execution_details"] as Record)[ + "should_finish" + ], + ).toBe(true); + }); + + it("drops flow-level inputs that no start-node property declares", async () => { + const graph = await loadFlow( + passThroughFlow([stringProperty({ title: "x" })]), + ); + const result = await graph.invoke({ + inputs: { x: "v", undeclared: "ignored" }, + }); + expect(result["outputs"]).toEqual({ x: "v" }); + }); +}); + +describe("StartNode input consumption", () => { + it("applies declared defaults when the invocation omits inputs entirely", async () => { + const graph = await loadFlow( + passThroughFlow([stringProperty({ title: "x", default: "fallback" })]), + ); + const result = await graph.invoke({}); + expect(result["outputs"]).toEqual({ x: "fallback" }); + }); + + it("casts values to the declared property types", async () => { + const graph = await loadFlow( + passThroughFlow([ + integerProperty({ title: "count" }), + stringProperty({ title: "text" }), + numberProperty({ title: "ratio" }), + ]), + ); + const result = await graph.invoke({ + inputs: { count: " 5 ", text: 7, ratio: "2.5" }, + }); + // Numeric strings parse into integer/number properties; non-strings are + // JSON-serialized into string properties. + expect(result["outputs"]).toEqual({ count: 5, text: "7", ratio: 2.5 }); + }); + + it("raises the Python error text for a missing required input", async () => { + const graph = await loadFlow( + passThroughFlow([stringProperty({ title: "x" })]), + ); + await expect(graph.invoke({ inputs: {} })).rejects.toThrow( + "Expected node `start` to have a value for property `x`, but none was found.", + ); + }); + + it("raises Python's int() error for an unparsable integer string", async () => { + // Python does `int(value.strip())` and its error-message guard never + // matches int()'s text, so unparsable integer strings abort the flow. + const graph = await loadFlow( + passThroughFlow([integerProperty({ title: "count" })]), + ); + await expect(graph.invoke({ inputs: { count: "3.5" } })).rejects.toThrow( + 'invalid literal for int() with base 10: "3.5"', + ); + await expect(graph.invoke({ inputs: { count: "abc" } })).rejects.toThrow( + 'invalid literal for int() with base 10: "abc"', + ); + }); + + it("accepts underscore digit separators in integer strings like int()", async () => { + const graph = await loadFlow( + passThroughFlow([integerProperty({ title: "count" })]), + ); + const result = await graph.invoke({ inputs: { count: "1_000" } }); + expect(result["outputs"]).toEqual({ count: 1000 }); + }); + + it("matches Python's float() coercion matrix for number strings", async () => { + const graph = await loadFlow( + passThroughFlow([numberProperty({ title: "ratio" })]), + ); + // Hex/binary/octal literals stay strings (Python float() rejects them + // and the error is swallowed, leaving the value as-is). + let result = await graph.invoke({ inputs: { ratio: "0x10" } }); + expect(result["outputs"]).toEqual({ ratio: "0x10" }); + // inf/nan/underscore separators convert like Python's float(). + result = await graph.invoke({ inputs: { ratio: "inf" } }); + expect(result["outputs"]).toEqual({ ratio: Infinity }); + result = await graph.invoke({ inputs: { ratio: "-Infinity" } }); + expect(result["outputs"]).toEqual({ ratio: -Infinity }); + result = await graph.invoke({ inputs: { ratio: "1_000.5" } }); + expect(result["outputs"]).toEqual({ ratio: 1000.5 }); + result = await graph.invoke({ inputs: { ratio: "nan" } }); + expect(result["outputs"]).toEqual({ ratio: NaN }); + }); + + it("stringifies non-string values with json.dumps formatting", async () => { + // Python casts container values into string properties via json.dumps, + // whose separators include spaces: "[1, 2]", not JSON.stringify's + // "[1,2]". + const graph = await loadFlow( + passThroughFlow([stringProperty({ title: "text" })]), + ); + let result = await graph.invoke({ inputs: { text: [1, 2] } }); + expect(result["outputs"]).toEqual({ text: "[1, 2]" }); + result = await graph.invoke({ inputs: { text: { a: 1, b: [true, null] } } }); + expect(result["outputs"]).toEqual({ text: '{"a": 1, "b": [true, null]}' }); + // ensure_ascii escapes non-ASCII text. + result = await graph.invoke({ inputs: { text: ["café"] } }); + expect(result["outputs"]).toEqual({ text: '["caf\\u00e9"]' }); + }); +}); + +describe("control-flow edges", () => { + it("treats an empty-string fromBranch as the default next branch", async () => { + // Python coerces a falsy from_branch ("" included) to "next"; the TS + // adapter must not keep "" as a distinct branch key. + const prop = stringProperty({ title: "x" }); + const start = ioStartNode("start", [prop]); + const end = ioEndNode("end", [prop]); + const flow = createFlow({ + name: "empty_branch_flow", + startNode: start, + nodes: [start, end], + controlFlowConnections: [ctrl(start, end, "")], + dataFlowConnections: [dataEdge(start, end, "x")], + }); + const graph = await loadFlow(flow); + const result = await graph.invoke({ inputs: { x: "v" } }); + expect(result["outputs"]).toEqual({ x: "v" }); + }); +}); + +describe("data-flow edges", () => { + const doubleToolSpec = createServerTool({ + name: "double_tool", + description: "Doubles a number", + inputs: [numberProperty({ title: "x" })], + outputs: [numberProperty({ title: "y" })], + }); + const double = (input: unknown): number => (input as { x: number }).x * 2; + + it("auto-generates edges by matching titles when dataFlowConnections is undefined", async () => { + const start = ioStartNode("start", [numberProperty({ title: "x" })]); + const toolNode = createToolNode({ name: "double", tool: doubleToolSpec }); + const end = ioEndNode("end", [numberProperty({ title: "y" })]); + const flow = createFlow({ + name: "auto_edges_flow", + startNode: start, + nodes: [start, toolNode, end], + controlFlowConnections: [ctrl(start, toolNode), ctrl(toolNode, end)], + // No dataFlowConnections: the adapter wires start.x -> double.x and + // double.y -> end.y automatically. + }); + expect(flow.dataFlowConnections).toBeUndefined(); + + const graph = await loadFlow(flow, { double_tool: double }); + const result = await graph.invoke({ inputs: { x: 3 } }); + expect(result["outputs"]).toEqual({ y: 6 }); + }); + + it("explicit edges route between differently-named properties", async () => { + const renamedToolSpec = createServerTool({ + name: "double_tool", + description: "Doubles a number", + inputs: [numberProperty({ title: "value" })], + outputs: [numberProperty({ title: "doubled" })], + }); + const start = ioStartNode("start", [numberProperty({ title: "x" })]); + const toolNode = createToolNode({ name: "double", tool: renamedToolSpec }); + const end = ioEndNode("end", [numberProperty({ title: "y" })]); + const flow = createFlow({ + name: "explicit_edges_flow", + startNode: start, + nodes: [start, toolNode, end], + controlFlowConnections: [ctrl(start, toolNode), ctrl(toolNode, end)], + dataFlowConnections: [ + dataEdge(start, toolNode, "x", "value"), + dataEdge(toolNode, end, "doubled", "y"), + ], + }); + + const graph = await loadFlow(flow, { + double_tool: (input: unknown) => (input as { value: number }).value * 2, + }); + const result = await graph.invoke({ inputs: { x: 4 } }); + expect(result["outputs"]).toEqual({ y: 8 }); + }); + + it("accumulates routed values across successive nodes", async () => { + // start.x flows through TWO chained tool nodes: each node's update must + // accumulate into (not replace) the pending-inputs routing table. + const secondToolSpec = createServerTool({ + name: "add_tool", + description: "Adds two numbers", + inputs: [numberProperty({ title: "y" }), numberProperty({ title: "x" })], + outputs: [numberProperty({ title: "sum" })], + }); + const start = ioStartNode("start", [numberProperty({ title: "x" })]); + const doubleNode = createToolNode({ name: "double", tool: doubleToolSpec }); + const addNode = createToolNode({ name: "add", tool: secondToolSpec }); + const end = ioEndNode("end", [numberProperty({ title: "sum" })]); + const flow = createFlow({ + name: "accumulate_flow", + startNode: start, + nodes: [start, doubleNode, addNode, end], + controlFlowConnections: [ + ctrl(start, doubleNode), + ctrl(doubleNode, addNode), + ctrl(addNode, end), + ], + dataFlowConnections: [ + dataEdge(start, doubleNode, "x"), + // start routes x directly to the LATER add node: the intermediate + // double node's state update must keep this pending value alive. + dataEdge(start, addNode, "x"), + dataEdge(doubleNode, addNode, "y"), + dataEdge(addNode, end, "sum"), + ], + }); + + const graph = await loadFlow(flow, { + double_tool: double, + add_tool: (input: unknown) => { + const { x, y } = input as { x: number; y: number }; + return x + y; + }, + }); + const result = await graph.invoke({ inputs: { x: 3 } }); + // double(3) = 6, add(6, 3) = 9 + expect(result["outputs"]).toEqual({ sum: 9 }); + expect( + (result["messages"] as BaseMessage[]).length, + ).toBe(0); + }); +}); diff --git a/tsagentspec/tests/adapters/langgraph/llm.test.ts b/tsagentspec/tests/adapters/langgraph/llm.test.ts new file mode 100644 index 00000000..54f2c6c7 --- /dev/null +++ b/tsagentspec/tests/adapters/langgraph/llm.test.ts @@ -0,0 +1,307 @@ +/** + * LLM config conversion tests for the LangGraph adapter. + * + * Mirrors `pyagentspec/tests/adapters/langgraph/llms/test_llm_conversion.py` + * (URL normalization matrix, ChatOpenAI/ChatOllama mapping, responses-API + * flag, generation parameter forwarding) plus the OciGenAiConfig rejection. + * All tests run offline: models are constructed, never invoked. + * + * Documented divergences (see the adapter README / llm.ts header): + * - conversion is async; + * - the TS SDK LlmConfig has no retryPolicy, so the Python retry mapping and + * its NotImplementedError paths have no TS equivalent; + * - OciGenAiConfig is rejected outright (no langchain-oci JS package). + */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ChatOllama } from "@langchain/ollama"; +import { ChatOpenAI } from "@langchain/openai"; +import { + OpenAIAPIType, + createOciClientConfigWithApiKey, + createOciGenAiConfig, + createOllamaConfig, + createOpenAiCompatibleConfig, + createOpenAiConfig, + createVllmConfig, + type LlmConfig, + type LlmGenerationConfig, +} from "../../../src/index.js"; +import { + convertLlmConfig, + generationConfigFromAgentSpec, + prepareOpenAiCompatibleUrl, +} from "../../../src/adapters/langgraph/llm.js"; + +/** Runtime surface of ChatOpenAI inspected by these tests. */ +interface ChatOpenAiProbe { + model: string; + apiKey?: string; + temperature?: number; + maxTokens?: number; + topP?: number; + presencePenalty?: number; + useResponsesApi: boolean; + modelKwargs?: Record; + clientConfig: { baseURL?: string }; +} + +async function convertToChatOpenAi(config: LlmConfig): Promise { + const model = await convertLlmConfig(config); + expect(model).toBeInstanceOf(ChatOpenAI); + return model as unknown as ChatOpenAiProbe; +} + +const DEFAULT_GENERATION_PARAMETERS: LlmGenerationConfig = { + temperature: 0.2, + maxTokens: 128, + topP: 0.8, +}; + +describe("prepareOpenAiCompatibleUrl", () => { + const cases: Array<[raw: string, expected: string]> = [ + // Ported from the Python parametrized cases. + ["localhost:8000", "http://localhost:8000/v1"], + ["127.0.0.1:5000", "http://127.0.0.1:5000/v1"], + ["https://api.example.com", "https://api.example.com/v1"], + ["http://my-host/api/v2", "http://my-host/v1"], + [" my-host:9999 ", "http://my-host:9999/v1"], + // Query parameters and fragments are stripped. + ["http://host:1234/path?query=1#frag", "http://host:1234/v1"], + ["https://api.example.com?key=value", "https://api.example.com/v1"], + // An already-normalized URL is preserved. + ["https://api.example.com/v1", "https://api.example.com/v1"], + ]; + + it.each(cases)("formats %j as %j", (raw, expected) => { + expect(prepareOpenAiCompatibleUrl(raw)).toBe(expected); + }); +}); + +describe("generationConfigFromAgentSpec", () => { + it("returns an empty config when no parameters are given", () => { + expect(generationConfigFromAgentSpec(undefined)).toEqual({}); + }); + + it("copies only the parameters that are set", () => { + expect(generationConfigFromAgentSpec({ temperature: 0.5 })).toEqual({ + temperature: 0.5, + }); + expect( + generationConfigFromAgentSpec(DEFAULT_GENERATION_PARAMETERS), + ).toEqual({ temperature: 0.2, maxTokens: 128, topP: 0.8 }); + }); + + it("ignores unsupported extra parameters", () => { + expect( + generationConfigFromAgentSpec({ + temperature: 0.2, + presencePenalty: 1.0, + } as LlmGenerationConfig), + ).toEqual({ temperature: 0.2 }); + }); +}); + +describe("convertLlmConfig for OpenAI-compatible configs", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("maps VllmConfig to ChatOpenAI with a normalized base URL", async () => { + vi.stubEnv("OPENAI_API_KEY", ""); + const model = await convertToChatOpenAi( + createVllmConfig({ + name: "llm", + modelId: "meta-llama/Meta-Llama-3.1-8B-Instruct", + url: "localhost:8000", // missing scheme on purpose + defaultGenerationParameters: DEFAULT_GENERATION_PARAMETERS, + }), + ); + expect(model.model).toBe("meta-llama/Meta-Llama-3.1-8B-Instruct"); + expect(model.clientConfig.baseURL).toBe("http://localhost:8000/v1"); + expect(model.useResponsesApi).toBe(false); + expect(model.temperature).toBe(0.2); + expect(model.maxTokens).toBe(128); + expect(model.topP).toBe(0.8); + }); + + it("maps OpenAiCompatibleConfig to ChatOpenAI with the /v1 base URL", async () => { + vi.stubEnv("OPENAI_API_KEY", "DUMMY_KEY"); + const model = await convertToChatOpenAi( + createOpenAiCompatibleConfig({ + name: "oaic", + modelId: "gpt-4o-mini", + url: "https://api.compatible", + defaultGenerationParameters: DEFAULT_GENERATION_PARAMETERS, + }), + ); + expect(model.model).toBe("gpt-4o-mini"); + expect(model.clientConfig.baseURL).toBe("https://api.compatible/v1"); + expect(model.maxTokens).toBe(128); + expect(model.temperature).toBe(0.2); + }); + + it.each([ + [OpenAIAPIType.RESPONSES, true], + [OpenAIAPIType.CHAT_COMPLETIONS, false], + ])("sets useResponsesApi for apiType %j", async (apiType, expectedFlag) => { + vi.stubEnv("OPENAI_API_KEY", "DUMMY_KEY"); + const model = await convertToChatOpenAi( + createOpenAiCompatibleConfig({ + name: "oaic", + modelId: "gpt-4o-mini", + url: "https://api.compatible", + apiType, + }), + ); + expect(model.useResponsesApi).toBe(expectedFlag); + }); + + it("maps OpenAiConfig to ChatOpenAI without a base URL", async () => { + vi.stubEnv("OPENAI_API_KEY", "DUMMY_KEY"); + const model = await convertToChatOpenAi( + createOpenAiConfig({ + name: "openai", + modelId: "gpt-4o-mini", + apiType: OpenAIAPIType.RESPONSES, + }), + ); + expect(model.model).toBe("gpt-4o-mini"); + expect(model.clientConfig.baseURL).toBeUndefined(); + expect(model.useResponsesApi).toBe(true); + }); + + it("does not forward extra generation fields", async () => { + vi.stubEnv("OPENAI_API_KEY", "DUMMY_KEY"); + const model = await convertToChatOpenAi( + createOpenAiConfig({ + name: "openai", + modelId: "gpt-4o-mini", + defaultGenerationParameters: { + temperature: 0.2, + maxTokens: 128, + topP: 0.8, + presencePenalty: 1.0, + } as LlmGenerationConfig, + }), + ); + expect(model.temperature).toBe(0.2); + expect(model.maxTokens).toBe(128); + expect(model.presencePenalty).toBeUndefined(); + expect(model.modelKwargs ?? {}).not.toHaveProperty("presence_penalty"); + }); + + it("leaves generation parameters unset without defaultGenerationParameters", async () => { + vi.stubEnv("OPENAI_API_KEY", "DUMMY_KEY"); + const model = await convertToChatOpenAi( + createOpenAiConfig({ name: "openai", modelId: "gpt-4o-mini" }), + ); + expect(model.temperature).toBeUndefined(); + expect(model.maxTokens).toBeUndefined(); + expect(model.topP).toBeUndefined(); + }); + + it("prefers the config api key over the environment", async () => { + vi.stubEnv("OPENAI_API_KEY", "env-key"); + const model = await convertToChatOpenAi( + createOpenAiConfig({ + name: "openai", + modelId: "gpt-4o-mini", + apiKey: "sk-config", + }), + ); + expect(model.apiKey).toBe("sk-config"); + }); + + it("falls back to OPENAI_API_KEY when the config has no api key", async () => { + vi.stubEnv("OPENAI_API_KEY", "env-key"); + const model = await convertToChatOpenAi( + createOpenAiConfig({ name: "openai", modelId: "gpt-4o-mini" }), + ); + expect(model.apiKey).toBe("env-key"); + }); + + it('falls back to the fake "EMPTY" key when neither is set', async () => { + // An empty env value falls through, matching Python `or` semantics. + vi.stubEnv("OPENAI_API_KEY", ""); + const model = await convertToChatOpenAi( + createOpenAiConfig({ name: "openai", modelId: "gpt-4o-mini" }), + ); + expect(model.apiKey).toBe("EMPTY"); + }); + + it("uses an explicit empty-string api key without substituting the environment key", async () => { + // The spec's key must never be silently replaced by the developer's + // environment credential (Python falls back only when api_key is None). + vi.stubEnv("OPENAI_API_KEY", "env-key"); + const model = await convertToChatOpenAi( + createOpenAiConfig({ + name: "openai", + modelId: "gpt-4o-mini", + apiKey: "", + }), + ); + expect(model.apiKey).toBe(""); + }); +}); + +describe("convertLlmConfig for OllamaConfig", () => { + it("maps generation parameters onto the ChatOllama names", async () => { + const model = (await convertLlmConfig( + createOllamaConfig({ + name: "oll", + modelId: "llama3.1", + url: "http://ollama.local:11434", + defaultGenerationParameters: DEFAULT_GENERATION_PARAMETERS, + }), + )) as ChatOllama; + expect(model).toBeInstanceOf(ChatOllama); + // The Ollama URL is used verbatim (no /v1 normalization). + expect(model.baseUrl).toBe("http://ollama.local:11434"); + expect(model.model).toBe("llama3.1"); + expect(model.temperature).toBe(0.2); + expect(model.numPredict).toBe(128); + expect(model.topP).toBe(0.8); + }); + + it("leaves generation parameters unset without defaultGenerationParameters", async () => { + const model = (await convertLlmConfig( + createOllamaConfig({ + name: "oll", + modelId: "llama3.2", + url: "http://localhost:11434", + }), + )) as ChatOllama; + expect(model.temperature).toBeUndefined(); + expect(model.numPredict).toBeUndefined(); + expect(model.topP).toBeUndefined(); + }); +}); + +describe("convertLlmConfig rejections", () => { + it("rejects OciGenAiConfig (no langchain-oci package for JS)", async () => { + const ociConfig = createOciGenAiConfig({ + name: "oci", + modelId: "meta.llama-3.1-70b-instruct", + compartmentId: "ocid1.compartment.oc1..x", + clientConfig: createOciClientConfigWithApiKey({ + name: "client", + serviceEndpoint: "https://inference.generativeai.example.com", + authProfile: "DEFAULT", + authFileLocation: "~/.oci/config", + }), + }); + await expect(convertLlmConfig(ociConfig)).rejects.toThrow( + "The Agent Spec type 'OciGenAiConfig' is not supported by the LangGraph TypeScript adapter yet.", + ); + }); + + it("rejects unknown LLM config component types", async () => { + const bogus = { + componentType: "MadeUpConfig", + name: "x", + } as unknown as LlmConfig; + await expect(convertLlmConfig(bogus)).rejects.toThrow( + "The Agent Spec type 'MadeUpConfig' is not yet supported for conversion.", + ); + }); +}); diff --git a/tsagentspec/tests/adapters/langgraph/loader-agent.test.ts b/tsagentspec/tests/adapters/langgraph/loader-agent.test.ts new file mode 100644 index 00000000..10578640 --- /dev/null +++ b/tsagentspec/tests/adapters/langgraph/loader-agent.test.ts @@ -0,0 +1,639 @@ +/** + * Loader + agent tests for the LangGraph adapter. + * + * Mirrors the offline-able behaviors of the Python suite + * (`pyagentspec/tests/adapters/langgraph/`): load entry points, agent + + * ServerTool round trips through a fake LLM, tool registry semantics, the + * ClientTool interrupt protocol, `requiresConfirmation` human-in-the-loop, + * structured outputs, middleware plumbing, disaggregated configurations and + * the component load policy. All tests run offline. + */ +import { describe, expect, it, vi } from "vitest"; +import { AIMessage, type BaseMessage } from "@langchain/core/messages"; +import { tool } from "@langchain/core/tools"; +import type { StructuredToolInterface } from "@langchain/core/tools"; +import { Command, MemorySaver } from "@langchain/langgraph"; +import { ChatOpenAI } from "@langchain/openai"; +import { createMiddleware } from "langchain"; +import { + AgentSpecSerializer, + createClientTool, + createMCPTool, + createServerTool, + createStdioTransport, + createVllmConfig, + integerProperty, + stringProperty, +} from "../../../src/index.js"; +import { AgentSpecLoader } from "../../../src/adapters/langgraph/agentspec-loader.js"; +import { + FakeLlmAgentSpecLoader, + FakeToolCallingChatModel, + approveCommand, + getInterrupts, + loadWithFakeLlm, + makeAgent, + makeLlmConfig, + rejectCommand, + threadConfig, + toolCallMessage, + type LoadedReactAgent, +} from "./test-helpers.js"; + +const STRUCTURED_OUTPUT_PROMPT_SUFFIX = + "\n\n" + + "After using the available tools, provide the final result by calling the " + + "structured output tool. Do not respond with a plain-text final answer."; + +function makeWeatherServerTool(overrides?: { requiresConfirmation?: boolean }) { + return createServerTool({ + name: "get_weather", + description: "Returns the weather for a city", + inputs: [stringProperty({ title: "city" })], + outputs: [stringProperty({ title: "weather" })], + ...(overrides?.requiresConfirmation !== undefined + ? { requiresConfirmation: overrides.requiresConfirmation } + : {}), + }); +} + +function getWeather(input: unknown): string { + const { city } = input as { city: string }; + return `The weather in ${city} is sunny.`; +} + +function messagesOf(result: Record): BaseMessage[] { + return result["messages"] as BaseMessage[]; +} + +describe("AgentSpecLoader load entry points", () => { + const agentSpec = makeAgent({ name: "weather_agent" }); + const serializer = new AgentSpecSerializer(); + + function assertLoadedAgent(loaded: unknown): void { + const agent = loaded as LoadedReactAgent; + expect(typeof agent.invoke).toBe("function"); + expect(agent.graph.lg_is_pregel).toBe(true); + expect(agent.graph.getName()).toBe("weather_agent"); + } + + it("loadYaml returns a compiled react agent preserving the agent name", async () => { + const yaml = serializer.toYaml(agentSpec) as string; + const loader = new FakeLlmAgentSpecLoader([new AIMessage("hello")]); + assertLoadedAgent(await loader.loadYaml(yaml)); + }); + + it("loadJson returns a compiled react agent preserving the agent name", async () => { + const json = serializer.toJson(agentSpec) as string; + const loader = new FakeLlmAgentSpecLoader([new AIMessage("hello")]); + assertLoadedAgent(await loader.loadJson(json)); + }); + + it("loadDict returns a compiled react agent preserving the agent name", async () => { + const dict = JSON.parse(serializer.toJson(agentSpec) as string) as Record< + string, + unknown + >; + const loader = new FakeLlmAgentSpecLoader([new AIMessage("hello")]); + assertLoadedAgent(await loader.loadDict(dict)); + }); +}); + +describe("agent with server tool", () => { + it("runs the tool loop: tool_call -> ToolMessage -> final answer", async () => { + const agentSpec = makeAgent({ + name: "weather_agent", + tools: [makeWeatherServerTool()], + }); + const { agent, loader } = await loadWithFakeLlm( + agentSpec, + [ + toolCallMessage("get_weather", { city: "Agadir" }), + new AIMessage("It is sunny in Agadir."), + ], + { toolRegistry: { get_weather: getWeather } }, + ); + + const result = await agent.invoke({ + messages: [{ role: "user", content: "What is the weather in Agadir?" }], + }); + + const messages = messagesOf(result); + expect(messages.length).toBeGreaterThan(2); + const finalMessage = messages[messages.length - 1]!; + expect(finalMessage.getType()).toBe("ai"); + expect(finalMessage.content).toBe("It is sunny in Agadir."); + const toolMessage = messages[messages.length - 2]!; + expect(toolMessage.getType()).toBe("tool"); + expect(String(toolMessage.content)).toContain( + "The weather in Agadir is sunny.", + ); + + // The converted tool was bound onto the model by createAgent. + const fakeModel = loader.getFakeModel(); + const boundNames = fakeModel.bound.map( + (boundTool) => (boundTool as { name?: string }).name, + ); + expect(boundNames).toContain("get_weather"); + }); +}); + +describe("tool registry semantics", () => { + const doubleToolSpec = createServerTool({ + name: "double_tool", + description: "Doubles input", + inputs: [integerProperty({ title: "x" })], + outputs: [integerProperty({ title: "result" })], + }); + + it("converts a plain sync function using the spec name/description/schema", async () => { + const loader = new AgentSpecLoader({ + toolRegistry: { double_tool: (input: unknown) => (input as { x: number }).x * 2 }, + }); + const converted = (await loader.loadComponent( + doubleToolSpec, + )) as StructuredToolInterface; + expect(converted.name).toBe("double_tool"); + expect(converted.description).toBe("Doubles input"); + expect(await converted.invoke({ x: 5 })).toBe(10); + }); + + it("converts an async function", async () => { + const loader = new AgentSpecLoader({ + toolRegistry: { + double_tool: async (input: unknown) => (input as { x: number }).x * 2, + }, + }); + const converted = (await loader.loadComponent( + doubleToolSpec, + )) as StructuredToolInterface; + expect(await converted.invoke({ x: 7 })).toBe(14); + }); + + it("reuses name, description and schema of a registered structured tool", async () => { + const argsSchema = { + title: "RegisteredArgs", + type: "object", + properties: { x: { title: "x", type: "integer" } }, + required: ["x"], + }; + const registered = tool( + (input: unknown) => (input as { x: number }).x * 2, + { + name: "registered_double", + description: "Registered description", + schema: argsSchema, + }, + ); + const loader = new AgentSpecLoader({ + toolRegistry: { double_tool: registered }, + }); + const converted = (await loader.loadComponent( + doubleToolSpec, + )) as StructuredToolInterface; + expect(converted.name).toBe("registered_double"); + expect(converted.description).toBe("Registered description"); + expect(converted.schema).toBe(argsSchema); + expect(await converted.invoke({ x: 6 })).toBe(12); + }); + + it("raises the Python error text for a tool missing from the registry", async () => { + const loader = new AgentSpecLoader(); + await expect(loader.loadComponent(doubleToolSpec)).rejects.toThrow( + "The Agent Spec representation includes a tool 'double_tool' " + + "but this tool does not appear in the tool registry", + ); + }); + + it("raises the missing-registry error for tool names on Object.prototype", async () => { + // Registry membership must be own-keys only (Python dict semantics): a + // spec-controlled name like "constructor" must not resolve to the + // inherited Object function. + const adversarialSpec = createServerTool({ + name: "constructor", + description: "Adversarially named tool", + }); + const loader = new AgentSpecLoader({ toolRegistry: {} }); + await expect(loader.loadComponent(adversarialSpec)).rejects.toThrow( + "The Agent Spec representation includes a tool 'constructor' " + + "but this tool does not appear in the tool registry", + ); + }); + + it("injects declared input defaults into the function input like Python", async () => { + // Python's pydantic args models fill Property defaults before the tool + // body runs; langchain JS does not apply JSON-schema defaults, so the + // adapter injects them itself. + const received: unknown[] = []; + const searchSpec = createServerTool({ + name: "search_tool", + description: "Searches", + inputs: [ + stringProperty({ title: "query" }), + integerProperty({ title: "limit", default: 10 }), + ], + outputs: [integerProperty({ title: "result" })], + }); + const loader = new AgentSpecLoader({ + toolRegistry: { + search_tool: (input: unknown) => { + received.push(input); + return 1; + }, + }, + }); + const converted = (await loader.loadComponent( + searchSpec, + )) as StructuredToolInterface; + await converted.invoke({ query: "abc" }); + expect(received).toEqual([{ query: "abc", limit: 10 }]); + }); + + it("raises for an unsupported registry entry type", async () => { + const loader = new AgentSpecLoader({ toolRegistry: { double_tool: 42 } }); + await expect(loader.loadComponent(doubleToolSpec)).rejects.toThrow( + "Unsupported tool type for 'double_tool': number. " + + "Expected callable, StructuredTool, or supported BaseTool.", + ); + }); +}); + +describe("client tool interrupt protocol", () => { + const clientToolSpec = createClientTool({ + name: "get_weather", + description: "Ask the client for the weather", + inputs: [stringProperty({ title: "city" })], + }); + + it("interrupts with the client_tool_request payload and resumes with the value", async () => { + const agentSpec = makeAgent({ + name: "weather_agent", + tools: [clientToolSpec], + }); + const { agent } = await loadWithFakeLlm( + agentSpec, + [ + toolCallMessage("get_weather", { city: "Agadir" }), + new AIMessage("It is sunny in Agadir."), + ], + { checkpointer: new MemorySaver() }, + ); + const config = threadConfig("client-tool-1"); + + const first = await agent.invoke( + { messages: [{ role: "user", content: "Weather in Agadir?" }] }, + config, + ); + const interrupts = getInterrupts(first); + expect(interrupts).toHaveLength(1); + expect(interrupts[0]!.value).toEqual({ + type: "client_tool_request", + name: "get_weather", + description: "Ask the client for the weather", + inputs: { args: [], kwargs: { city: "Agadir" } }, + }); + + const second = await agent.invoke(new Command({ resume: "sunny" }), config); + const messages = messagesOf(second); + const toolMessage = messages[messages.length - 2]!; + expect(toolMessage.getType()).toBe("tool"); + expect(String(toolMessage.content)).toContain("sunny"); + expect(messages[messages.length - 1]!.content).toBe( + "It is sunny in Agadir.", + ); + }); + + it("requires a checkpointer at load time", async () => { + const agentSpec = makeAgent({ tools: [clientToolSpec] }); + const loader = new FakeLlmAgentSpecLoader([new AIMessage("hello")]); + await expect(loader.loadComponent(agentSpec)).rejects.toThrow( + "A Checkpointer is required when using ClientTool 'get_weather'.", + ); + }); +}); + +describe("requiresConfirmation human-in-the-loop", () => { + function makeConfirmationFixture() { + const called = { count: 0 }; + const doubleToolSpec = createServerTool({ + name: "double_tool", + description: "Doubles input", + inputs: [integerProperty({ title: "x" })], + outputs: [integerProperty({ title: "result" })], + requiresConfirmation: true, + }); + const agentSpec = makeAgent({ tools: [doubleToolSpec] }); + const registry = { + double_tool: (input: unknown) => { + called.count += 1; + return (input as { x: number }).x * 2; + }, + }; + return { agentSpec, registry, called }; + } + + async function invokeUntilInterrupt( + agent: LoadedReactAgent, + config: { configurable: { thread_id: string } }, + ): Promise> { + const result = await agent.invoke( + { messages: [{ role: "user", content: "Double 5" }] }, + config, + ); + const interrupts = getInterrupts(result); + expect(interrupts).toHaveLength(1); + return interrupts[0]!.value as Record; + } + + it("interrupts with action_requests and executes the tool on approve", async () => { + const { agentSpec, registry, called } = makeConfirmationFixture(); + const { agent } = await loadWithFakeLlm( + agentSpec, + [toolCallMessage("double_tool", { x: 5 }), new AIMessage("Done")], + { toolRegistry: registry, checkpointer: new MemorySaver() }, + ); + const config = threadConfig("confirm-approve"); + + const payload = await invokeUntilInterrupt(agent, config); + const actionRequests = payload["action_requests"] as Array< + Record + >; + expect(actionRequests[0]!["name"]).toBe("double_tool"); + expect(actionRequests[0]!["arguments"]).toEqual({ x: 5 }); + expect(String(actionRequests[0]!["description"])).toContain( + "Tool execution pending approval", + ); + const reviewConfigs = payload["review_configs"] as Array< + Record + >; + expect(reviewConfigs[0]!["action_name"]).toBe("double_tool"); + expect(reviewConfigs[0]!["allowed_decisions"]).toEqual([ + "approve", + "reject", + ]); + + const result = await agent.invoke(approveCommand(), config); + expect(called.count).toBe(1); + const messages = messagesOf(result); + const toolMessage = messages[messages.length - 2]!; + expect(toolMessage.getType()).toBe("tool"); + expect(String(toolMessage.content)).toContain("10"); + expect(messages[messages.length - 1]!.content).toBe("Done"); + }); + + it("raises the denial error on reject and does not execute the tool", async () => { + const { agentSpec, registry, called } = makeConfirmationFixture(); + const { agent } = await loadWithFakeLlm( + agentSpec, + [toolCallMessage("double_tool", { x: 5 }), new AIMessage("Done")], + { toolRegistry: registry, checkpointer: new MemorySaver() }, + ); + const config = threadConfig("confirm-reject"); + + await invokeUntilInterrupt(agent, config); + await expect(agent.invoke(rejectCommand("no"), config)).rejects.toThrow( + "Tool 'double_tool' was denied by the user (reason: no).", + ); + expect(called.count).toBe(0); + }); + + it("raises the Python validation error on a malformed resume payload", async () => { + const { agentSpec, registry, called } = makeConfirmationFixture(); + const { agent } = await loadWithFakeLlm( + agentSpec, + [toolCallMessage("double_tool", { x: 5 }), new AIMessage("Done")], + { toolRegistry: registry, checkpointer: new MemorySaver() }, + ); + const config = threadConfig("confirm-malformed"); + + await invokeUntilInterrupt(agent, config); + await expect( + agent.invoke(new Command({ resume: { not_decisions: [] } }), config), + ).rejects.toThrow( + "Tool confirmation result for tool double_tool is not valid, " + + "should be a dict with a 'decisions' key", + ); + expect(called.count).toBe(0); + }); + + it("requires a checkpointer at load time", async () => { + const { agentSpec, registry } = makeConfirmationFixture(); + const loader = new FakeLlmAgentSpecLoader([new AIMessage("hello")], { + toolRegistry: registry, + }); + await expect(loader.loadComponent(agentSpec)).rejects.toThrow( + "A Checkpointer is required for tool 'double_tool' because requires_confirmation=True", + ); + }); +}); + +describe("structured outputs", () => { + const outputs = [ + integerProperty({ title: "temperature_rating" }), + stringProperty({ title: "weather" }), + ]; + + it("configures a response format and appends the structured-output sentence", async () => { + const systemPrompt = "You are a helpful agent."; + const agentSpec = makeAgent({ systemPrompt, outputs }); + const { agent } = await loadWithFakeLlm(agentSpec, [ + toolCallMessage("AgentOutputModel", { + temperature_rating: 8, + weather: "sunny", + }), + ]); + + expect(agent.options.responseFormat).toBeDefined(); + expect(agent.options.systemPrompt).toBe( + systemPrompt + STRUCTURED_OUTPUT_PROMPT_SUFFIX, + ); + expect(Object.keys(agent.graph.builder.channels)).toContain( + "structuredResponse", + ); + + const result = await agent.invoke({ + messages: [{ role: "user", content: "Rate the weather" }], + }); + expect(result["structuredResponse"]).toEqual({ + temperature_rating: 8, + weather: "sunny", + }); + }); + + it("does not alter the prompt nor add a response format without outputs", async () => { + const systemPrompt = "You are a helpful agent."; + const agentSpec = makeAgent({ systemPrompt }); + const { agent } = await loadWithFakeLlm(agentSpec, [new AIMessage("hi")]); + + expect(agent.options.responseFormat).toBeUndefined(); + expect(agent.options.systemPrompt).toBe(systemPrompt); + expect(Object.keys(agent.graph.builder.channels)).not.toContain( + "structuredResponse", + ); + }); +}); + +describe("middleware plumbing", () => { + it("does not pass the middleware option when omitted", async () => { + const { agent } = await loadWithFakeLlm(makeAgent(), [new AIMessage("hi")]); + expect("middleware" in agent.options).toBe(false); + }); + + it("does not pass the middleware option when empty", async () => { + const { agent } = await loadWithFakeLlm(makeAgent(), [new AIMessage("hi")], { + middleware: [], + }); + expect("middleware" in agent.options).toBe(false); + }); + + it("forwards middleware in order", async () => { + const middlewareA = createMiddleware({ name: "MwA" }); + const middlewareB = createMiddleware({ name: "MwB" }); + const { agent } = await loadWithFakeLlm(makeAgent(), [new AIMessage("hi")], { + middleware: [middlewareA, middlewareB], + }); + expect(agent.options.middleware).toHaveLength(2); + expect(agent.options.middleware![0]).toBe(middlewareA); + expect(agent.options.middleware![1]).toBe(middlewareB); + }); + + it("copies the middleware list at construction time", async () => { + const middlewareA = createMiddleware({ name: "MwA" }); + const middlewareList: unknown[] = [middlewareA]; + const loader = new FakeLlmAgentSpecLoader([new AIMessage("hi")], { + middleware: middlewareList, + }); + middlewareList.push(createMiddleware({ name: "MwLate" })); + const agent = (await loader.loadComponent(makeAgent())) as LoadedReactAgent; + expect(agent.options.middleware).toHaveLength(1); + expect(agent.options.middleware![0]).toBe(middlewareA); + }); + + it("middleware hooks execute during agent runs", async () => { + const hookCalls: string[] = []; + const recordingMiddleware = createMiddleware({ + name: "RecordingMw", + beforeModel: () => { + hookCalls.push("beforeModel"); + return undefined; + }, + }); + const { agent } = await loadWithFakeLlm(makeAgent(), [new AIMessage("hi")], { + middleware: [recordingMiddleware], + }); + expect(Object.keys(agent.graph.builder.nodes)).toContain( + "RecordingMw.before_model", + ); + await agent.invoke({ messages: [{ role: "user", content: "Hello" }] }); + expect(hookCalls).toEqual(["beforeModel"]); + }); +}); + +describe("disaggregated configurations", () => { + const llmConfig = makeLlmConfig({ name: "llm_config" }); + const agentSpec = makeAgent({ name: "disagg_agent", llmConfig }); + const [mainYaml, disagYaml] = new AgentSpecSerializer().toYaml(agentSpec, { + disaggregatedComponents: [llmConfig], + exportDisaggregatedComponents: true, + }) as [string, string]; + + it("importOnlyReferencedComponents loads the referenced components alone", async () => { + const loader = new FakeLlmAgentSpecLoader([new AIMessage("hi")]); + const runtimeComponents = (await loader.loadYaml(disagYaml, { + importOnlyReferencedComponents: true, + })) as Record; + expect(Object.keys(runtimeComponents)).toEqual([llmConfig.id]); + expect(runtimeComponents[llmConfig.id]).toBeInstanceOf( + FakeToolCallingChatModel, + ); + }); + + it("componentsRegistry can swap in a fresh Agent Spec LLM config", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const replacement = createVllmConfig({ + name: "llm_config", + url: "http://localhost:9000", + modelId: "swapped-model", + }); + const loader = new FakeLlmAgentSpecLoader([new AIMessage("hi")]); + const agent = (await loader.loadYaml(mainYaml, { + componentsRegistry: { [llmConfig.id]: replacement }, + })) as LoadedReactAgent; + expect(agent.graph.lg_is_pregel).toBe(true); + expect(agent.graph.getName()).toBe("disagg_agent"); + expect( + loader.convertedLlmConfigs.map((config) => config.modelId), + ).toContain("swapped-model"); + } finally { + warnSpy.mockRestore(); + } + }); + + it("componentsRegistry accepts a runtime chat model", async () => { + const chatModel = new ChatOpenAI({ + model: "exported-model", + apiKey: "test-key", + configuration: { baseURL: "http://localhost:8000/v1" }, + }); + const loader = new FakeLlmAgentSpecLoader([new AIMessage("hi")]); + const agent = (await loader.loadYaml(mainYaml, { + componentsRegistry: { [llmConfig.id]: chatModel }, + })) as LoadedReactAgent; + expect(agent.graph.lg_is_pregel).toBe(true); + expect( + loader.convertedLlmConfigs.map((config) => config.modelId), + ).toContain("exported-model"); + }); +}); + +describe("component load policy", () => { + const stdioTransport = createStdioTransport({ + name: "stdio_transport", + command: "echo", + }); + const mcpToolSpec = createMCPTool({ + name: "fooza_tool", + clientTransport: stdioTransport, + }); + + it("blocks StdioTransport by default", async () => { + const loader = new AgentSpecLoader(); + await expect(loader.loadComponent(mcpToolSpec)).rejects.toThrow( + "Loading Agent Spec component type `StdioTransport` is in the block list.", + ); + }); + + it("blockedComponents: [] unblocks StdioTransport", async () => { + const cachedTool = tool( + (input: unknown) => { + const { a, b } = input as { a: number; b: number }; + return a * 2 + b * 3 - 1; + }, + { + name: "fooza_tool", + description: "fooza", + schema: { + title: "FoozaArgs", + type: "object", + properties: { + a: { title: "a", type: "number" }, + b: { title: "b", type: "number" }, + }, + required: ["a", "b"], + }, + }, + ); + // Pre-seeding the `${transportId}::${toolName}` registry cache keeps the + // test offline: the adapter reuses cached MCP tools without connecting. + const loader = new AgentSpecLoader({ + blockedComponents: [], + toolRegistry: { [`${stdioTransport.id}::fooza_tool`]: cachedTool }, + }); + const converted = (await loader.loadComponent( + mcpToolSpec, + )) as StructuredToolInterface; + expect(converted).toBe(cachedTool); + expect(await converted.invoke({ a: 2, b: 5 })).toBe(18); + }); +}); diff --git a/tsagentspec/tests/adapters/langgraph/manager-workers.test.ts b/tsagentspec/tests/adapters/langgraph/manager-workers.test.ts new file mode 100644 index 00000000..cb49437b --- /dev/null +++ b/tsagentspec/tests/adapters/langgraph/manager-workers.test.ts @@ -0,0 +1,735 @@ +/** + * ManagerWorkers tests for the LangGraph adapter. + * + * Mirrors `pyagentspec/tests/adapters/langgraph/test_managerworkers.py`: + * worker-name slug normalization + collision rejection, the workers roster + * appended to the manager prompt (exact format), the hierarchical graph + * topology, per-worker `__delegate_to__` tools on the manager, the manager + * router (END / one Send per delegation / non-worker suffixes ignored), the + * full delegation round trip with per-agent fake LLMs (single and multiple + * delegations per turn), nested ManagerWorkers, non-Agent group manager + * rejection, ManagerWorkers as a flow AgentNode step, and the + * `DELEGATE_TOOL_PREFIX` / `isDelegationToolName` contract. All tests run + * offline. Since the Python suite reaches its private helpers directly + * (`_safe_node_name`, `_append_workers_roster`, `_make_manager_router`), the + * equivalents here are asserted through the compiled graph: node names, the + * system message received by the fake manager model, and the conditional-edge + * branch function registered on `__manager__`. + */ +import { describe, expect, it } from "vitest"; +import { + AIMessage, + HumanMessage, + ToolMessage, + type BaseMessage, +} from "@langchain/core/messages"; +import { END, MemorySaver, START, Send } from "@langchain/langgraph"; +import { + createAgent as createAgentSpecAgent, + createAgentNode, + createControlFlowEdge, + createDataFlowEdge, + createEndNode, + createFlow, + createManagerWorkers, + createStartNode, + stringProperty, +} from "../../../src/index.js"; +import type { Agent, Flow, ManagerWorkers, Property } from "../../../src/index.js"; +import { AgentSpecLoader } from "../../../src/adapters/langgraph/agentspec-loader.js"; +import { + DELEGATE_TOOL_PREFIX, + isDelegationToolName, +} from "../../../src/adapters/langgraph/manager-workers.js"; +import { + FakeLlmAgentSpecLoader, + makeLlmConfig, + threadConfig, + type FakeLlmResponses, +} from "./test-helpers.js"; + +const MANAGER_NODE_KEY = "__manager__"; +const DELEGATE_TASK_KEY = "__delegate_task__"; +const DELEGATE_CALL_ID_KEY = "__delegate_tool_call_id__"; + +/** Conditional-edge branch shape on a StateGraph builder. */ +interface BranchLike { + path: { func: (state: Record) => unknown }; + ends?: Record; +} + +/** Structural surface of a compiled LangGraph used by these tests. */ +interface CompiledGraphLike { + lg_is_pregel?: boolean; + builder: { + nodes: Record; + edges: Set<[string, string]>; + branches: Record | undefined>; + }; + invoke(input: unknown, config?: unknown): Promise>; +} + +/** Python's `_agent` fixture: a minimal Agent with a named LLM config. */ +function mwAgent(opts: { + name: string; + llmName: string; + description?: string; + systemPrompt?: string; + outputs?: Property[]; + id?: string; +}): Agent { + return createAgentSpecAgent({ + name: opts.name, + llmConfig: makeLlmConfig({ name: opts.llmName }), + systemPrompt: opts.systemPrompt ?? ".", + ...(opts.description !== undefined ? { description: opts.description } : {}), + ...(opts.outputs !== undefined ? { outputs: opts.outputs } : {}), + ...(opts.id !== undefined ? { id: opts.id } : {}), + }); +} + +/** + * Python's `_load_with_fake_llms`: compile a ManagerWorkers offline, answering + * each LLM config (keyed by `llmConfig.name`) with a queued fake. + */ +async function loadWithFakeLlms( + managerWorkers: ManagerWorkers | Flow, + responses: FakeLlmResponses, +): Promise<{ graph: CompiledGraphLike; loader: FakeLlmAgentSpecLoader }> { + const loader = new FakeLlmAgentSpecLoader(responses, { + checkpointer: new MemorySaver(), + }); + const graph = (await loader.loadComponent(managerWorkers)) as CompiledGraphLike; + return { graph, loader }; +} + +/** Tool names registered on the `tools` node of a compiled react agent. */ +function toolNamesOf(agentGraph: unknown): string[] { + const nodes = (agentGraph as CompiledGraphLike).builder.nodes; + const toolsNode = nodes["tools"]?.runnable as + | { tools?: Array<{ name?: unknown }> } + | undefined; + return (toolsNode?.tools ?? []).map((registered) => String(registered.name)); +} + +/** The [from, to] pairs of a compiled graph's plain edges. */ +function edgePairs(graph: CompiledGraphLike): string[] { + return [...graph.builder.edges].map(([from, to]) => `${from}->${to}`); +} + +/** The router function registered as the manager's conditional edge. */ +function managerRouter( + graph: CompiledGraphLike, +): (state: Record) => unknown { + const branchMap = graph.builder.branches[MANAGER_NODE_KEY]; + expect(branchMap).toBeDefined(); + const branch = branchMap!["condition"]; + expect(branch).toBeDefined(); + return branch!.path.func; +} + +/** The messages of an invoke result. */ +function messagesOf(result: Record): BaseMessage[] { + return result["messages"] as BaseMessage[]; +} + +/** + * The plain text of a message: langchain JS may deliver the system prompt as + * a `[{type: "text", text}]` content-blocks array instead of a plain string. + */ +function textOf(message: BaseMessage): string { + const content = message.content as unknown; + if (typeof content === "string") { + return content; + } + return (content as Array<{ type?: string; text?: string }>) + .map((block) => block.text ?? "") + .join(""); +} + +/** The ToolMessages of an invoke result, in order. */ +function toolMessagesOf(result: Record): ToolMessage[] { + return messagesOf(result).filter( + (message): message is ToolMessage => message.getType() === "tool", + ); +} + +/** The ResearchTeam fixture of the Python topology test. */ +function researchTeamSpec(): ManagerWorkers { + return createManagerWorkers({ + name: "ResearchTeam", + groupManager: mwAgent({ + name: "Coordinator", + llmName: "manager_llm", + systemPrompt: "Coordinate the team.", + }), + workers: [ + mwAgent({ + name: "Research Helper", + llmName: "worker_a_llm", + description: "Handles research", + }), + mwAgent({ + name: "Drafter", + llmName: "worker_b_llm", + description: "Drafts text", + }), + ], + }); +} + +describe("delegation tool name contract", () => { + it("exposes the __delegate_to__ prefix constant", () => { + expect(DELEGATE_TOOL_PREFIX).toBe("__delegate_to__"); + }); + + it("matches only names starting with the synthetic prefix", () => { + expect(isDelegationToolName("__delegate_to__research_helper")).toBe(true); + expect(isDelegationToolName("get_weather")).toBe(false); + // A real tool plausibly named delegate_to_ is not a delegation. + expect(isDelegationToolName("delegate_to_someone")).toBe(false); + // Nor is one merely containing the prefix mid-name. + expect(isDelegationToolName("please__delegate_to__someone")).toBe(false); + expect(isDelegationToolName(undefined)).toBe(false); + expect(isDelegationToolName(null)).toBe(false); + expect(isDelegationToolName(123)).toBe(false); + }); +}); + +describe("worker node name normalization", () => { + it("slugifies worker names, falling back to the id and then a constant", async () => { + const spec = createManagerWorkers({ + name: "T", + groupManager: mwAgent({ name: "M", llmName: "manager_llm" }), + workers: [ + mwAgent({ name: "Research Helper", llmName: "w1_llm" }), + mwAgent({ name: "My-Worker!! v2", llmName: "w2_llm" }), + // Name slugifies to empty -> normalized id. + mwAgent({ name: "!!!", llmName: "w3_llm", id: "sub-1" }), + // Name and id both slugify to empty -> constant fallback. + mwAgent({ name: "!!!", llmName: "w4_llm", id: "???" }), + ], + }); + const { graph } = await loadWithFakeLlms(spec, []); + + const nodeNames = Object.keys(graph.builder.nodes); + expect(nodeNames).toContain("research_helper"); + expect(nodeNames).toContain("my_worker_v2"); + expect(nodeNames).toContain("sub_1"); + expect(nodeNames).toContain("worker"); + }); + + it("rejects workers whose names collide after normalization", async () => { + // Both worker names normalize to "helper_a"; they would silently + // overwrite each other in the parent graph. + const spec = createManagerWorkers({ + name: "T", + groupManager: mwAgent({ name: "M", llmName: "m_llm" }), + workers: [ + mwAgent({ name: "Helper A", llmName: "a_llm" }), + mwAgent({ name: "helper-a", llmName: "b_llm" }), + ], + }); + const loader = new AgentSpecLoader(); + await expect(loader.loadComponent(spec)).rejects.toThrow( + /collide after normalization/, + ); + }); +}); + +describe("workers roster", () => { + it("appends one roster line per worker to the manager system prompt", async () => { + const { graph, loader } = await loadWithFakeLlms(researchTeamSpec(), { + manager_llm: [new AIMessage("Done.")], + worker_a_llm: [], + worker_b_llm: [], + }); + await graph.invoke( + { messages: [new HumanMessage("hi")] }, + threadConfig("mw-roster"), + ); + + const managerModel = loader.getFakeModel("manager_llm"); + const firstCall = managerModel.calls[0]!; + expect(firstCall[0]!.getType()).toBe("system"); + expect(textOf(firstCall[0]!)).toBe( + "Coordinate the team.\n\n" + + "Available workers:\n" + + "- research_helper: Handles research\n" + + "- drafter: Drafts text", + ); + }); + + it("flattens multiline descriptions so the one-line-per-worker shape survives", async () => { + const spec = createManagerWorkers({ + name: "T", + groupManager: mwAgent({ + name: "M", + llmName: "manager_llm", + systemPrompt: "Coordinate.", + }), + workers: [ + mwAgent({ + name: "helper", + llmName: "worker_llm", + description: "First line\nsecond line\n third line ", + }), + ], + }); + const { graph, loader } = await loadWithFakeLlms(spec, { + manager_llm: [new AIMessage("Done.")], + worker_llm: [], + }); + await graph.invoke( + { messages: [new HumanMessage("hi")] }, + threadConfig("mw-roster-flat"), + ); + + const firstCall = loader.getFakeModel("manager_llm").calls[0]!; + expect(textOf(firstCall[0]!)).toBe( + "Coordinate.\n\nAvailable workers:\n- helper: First line second line third line", + ); + }); + + it("renders the roster alone when the manager prompt is empty", async () => { + const spec = createManagerWorkers({ + name: "T", + groupManager: mwAgent({ + name: "M", + llmName: "manager_llm", + systemPrompt: "", + }), + workers: [ + mwAgent({ name: "helper", llmName: "worker_llm", description: "Helps" }), + ], + }); + const { graph, loader } = await loadWithFakeLlms(spec, { + manager_llm: [new AIMessage("Done.")], + worker_llm: [], + }); + await graph.invoke( + { messages: [new HumanMessage("hi")] }, + threadConfig("mw-roster-empty"), + ); + + const firstCall = loader.getFakeModel("manager_llm").calls[0]!; + expect(textOf(firstCall[0]!)).toBe("Available workers:\n- helper: Helps"); + }); +}); + +describe("graph topology", () => { + it("compiles to a hierarchical graph: START -> manager, workers loop back", async () => { + const { graph } = await loadWithFakeLlms(researchTeamSpec(), [ + new AIMessage("Done."), + ]); + + const nodeNames = Object.keys(graph.builder.nodes); + expect(nodeNames).toContain(MANAGER_NODE_KEY); + expect(nodeNames).toContain("research_helper"); + expect(nodeNames).toContain("drafter"); + + // START -> manager; every worker -> manager (loop). + const edges = edgePairs(graph); + expect(edges).toContain(`${START}->${MANAGER_NODE_KEY}`); + expect(edges).toContain(`research_helper->${MANAGER_NODE_KEY}`); + expect(edges).toContain(`drafter->${MANAGER_NODE_KEY}`); + + // Manager -> worker is a conditional edge whose path map covers every + // worker plus END. + const branchMap = graph.builder.branches[MANAGER_NODE_KEY]; + expect(branchMap).toBeTruthy(); + expect(branchMap!["condition"]!.ends).toEqual({ + research_helper: "research_helper", + drafter: "drafter", + [END]: END, + }); + }); + + it("registers a __delegate_to__ tool per worker on the manager react agent", async () => { + const spec = createManagerWorkers({ + name: "Team", + groupManager: mwAgent({ name: "Coordinator", llmName: "manager_llm" }), + workers: [ + mwAgent({ + name: "Research Helper", + llmName: "worker_llm", + description: "Handles research tasks", + }), + ], + }); + const { graph } = await loadWithFakeLlms(spec, [new AIMessage("Done.")]); + + // The delegation tool the roster advertises is registered on the manager + // react-agent's tools node, so the LLM has the matching contract. + const managerSubgraph = graph.builder.nodes[MANAGER_NODE_KEY]!.runnable; + expect(toolNamesOf(managerSubgraph)).toContain( + "__delegate_to__research_helper", + ); + }); +}); + +describe("manager router", () => { + async function compileRouter(): Promise< + (state: Record) => unknown + > { + const { graph } = await loadWithFakeLlms(researchTeamSpec(), []); + return managerRouter(graph); + } + + it("returns END when the manager did not delegate", async () => { + const route = await compileRouter(); + const notDelegating = new AIMessage({ content: "Done.", tool_calls: [] }); + expect(route({ messages: [notDelegating] })).toBe(END); + expect(route({ messages: [] })).toBe(END); + }); + + it("fans out one Send per delegation, carrying the task and tool_call_id", async () => { + const route = await compileRouter(); + const message = new AIMessage({ + content: "", + tool_calls: [ + { name: "some_other_tool", args: {}, id: "c0", type: "tool_call" }, + { + name: "__delegate_to__drafter", + args: { task: "x" }, + id: "c1", + type: "tool_call", + }, + { + name: "__delegate_to__research_helper", + args: { task: "y" }, + id: "c2", + type: "tool_call", + }, + ], + }); + + const sends = route({ messages: [message] }) as Send[]; + // Every delegation gets its own Send carrying the task and the + // tool_call_id its reply must answer. The non-delegation tool call + // already ran inside the manager's react loop and is ignored by routing. + expect(Array.isArray(sends)).toBe(true); + expect(sends.every((send) => send instanceof Send)).toBe(true); + expect(sends.map((send) => send.node)).toEqual([ + "drafter", + "research_helper", + ]); + expect( + sends.map((send) => (send.args as Record)[DELEGATE_TASK_KEY]), + ).toEqual(["x", "y"]); + expect( + sends.map( + (send) => (send.args as Record)[DELEGATE_CALL_ID_KEY], + ), + ).toEqual(["c1", "c2"]); + }); + + it("ignores a prefixed tool call whose suffix is not a worker", async () => { + // A tool call that merely looks like a delegation must not be routed: its + // suffix is not a worker node, so a Send would target a non-existing + // node. It already ran as a plain tool inside the react loop. + const route = await compileRouter(); + const message = new AIMessage({ + content: "", + tool_calls: [ + { + name: "__delegate_to__nobody", + args: { task: "x" }, + id: "c1", + type: "tool_call", + }, + ], + }); + expect(route({ messages: [message] })).toBe(END); + }); +}); + +describe("delegation round trip", () => { + it("delegates, routes the worker answer back as a ToolMessage, and terminates", async () => { + const spec = createManagerWorkers({ + name: "Team", + groupManager: mwAgent({ + name: "Coordinator", + llmName: "manager_llm", + systemPrompt: "You coordinate.", + }), + workers: [ + mwAgent({ + name: "Research Helper", + llmName: "worker_llm", + description: "Handles research", + }), + ], + }); + + // Manager turn 1: delegate. Manager turn 2: final answer (no tool call + // -> END). + const { graph } = await loadWithFakeLlms(spec, { + manager_llm: [ + new AIMessage({ + content: "", + tool_calls: [ + { + name: "__delegate_to__research_helper", + args: { task: "Look up Saturn" }, + id: "call_1", + type: "tool_call", + }, + ], + }), + new AIMessage("The worker reports: Saturn has rings."), + ], + worker_llm: [new AIMessage("Saturn has rings.")], + }); + + const result = await graph.invoke( + { messages: [new HumanMessage("Tell me about Saturn.")] }, + threadConfig("mw-1"), + ); + + const messages = messagesOf(result); + const finalMessage = messages[messages.length - 1]!; + expect(finalMessage.getType()).toBe("ai"); + expect(String(finalMessage.content)).toContain("Saturn has rings"); + + // The worker ran in an isolated message context and its answer came back + // as a ToolMessage matched to the pending tool_call_id. + const toolMessages = toolMessagesOf(result); + expect(toolMessages.length).toBeGreaterThan(0); + expect(toolMessages[0]!.tool_call_id).toBe("call_1"); + expect(String(toolMessages[0]!.content)).toContain("Saturn has rings"); + }); + + it("answers every delegation of a single manager turn with its own ToolMessage", async () => { + const spec = createManagerWorkers({ + name: "Team", + groupManager: mwAgent({ + name: "Coordinator", + llmName: "manager_llm", + systemPrompt: "You coordinate.", + }), + workers: [ + mwAgent({ + name: "Sub Agent", + llmName: "worker_llm", + description: "Writes poems", + }), + ], + }); + + // Turn 1: three delegations to the same worker in one AIMessage. + // Turn 2: terminate. An unanswered delegation would be an invalid + // tool-call/result sequence the manager would hallucinate around. + const { graph } = await loadWithFakeLlms(spec, { + manager_llm: [ + new AIMessage({ + content: "", + tool_calls: [ + { + name: "__delegate_to__sub_agent", + args: { task: "Spanish poem" }, + id: "call_1", + type: "tool_call", + }, + { + name: "__delegate_to__sub_agent", + args: { task: "French poem" }, + id: "call_2", + type: "tool_call", + }, + { + name: "__delegate_to__sub_agent", + args: { task: "German poem" }, + id: "call_3", + type: "tool_call", + }, + ], + }), + new AIMessage("Here are your three poems."), + ], + worker_llm: [1, 2, 3, 4, 5].map( + (index) => new AIMessage(`poem #${index}`), + ), + }); + + const result = await graph.invoke( + { messages: [new HumanMessage("Write 3 poems via sub-agents.")] }, + threadConfig("mw-multi"), + ); + + const toolMessages = toolMessagesOf(result); + const answeredCallIds = toolMessages + .map((message) => message.tool_call_id) + .sort(); + expect(answeredCallIds).toEqual(["call_1", "call_2", "call_3"]); + expect( + toolMessages.every((message) => + String(message.content).startsWith("poem #"), + ), + ).toBe(true); + }); +}); + +describe("nested ManagerWorkers", () => { + it("compiles a ManagerWorkers worker recursively as a subgraph node", async () => { + const innerSpec = createManagerWorkers({ + name: "Inner", + groupManager: mwAgent({ + name: "InnerManager", + llmName: "inner_llm", + systemPrompt: "Manage leaves.", + }), + workers: [ + mwAgent({ name: "Leaf", llmName: "leaf_llm", description: "Leaf task" }), + ], + }); + const outerSpec = createManagerWorkers({ + name: "Outer", + groupManager: mwAgent({ + name: "OuterManager", + llmName: "outer_llm", + systemPrompt: "Manage subteams.", + }), + workers: [innerSpec], + }); + + const { graph } = await loadWithFakeLlms(outerSpec, [ + new AIMessage("Done."), + ]); + expect(Object.keys(graph.builder.nodes)).toContain("inner"); + }); + + it("rejects a non-Agent group manager", async () => { + // A nested ManagerWorkers as groupManager is valid per the Agent Spec + // validators, but the adapter needs a chat-LLM emitting tool_calls to + // route on. + const innerSpec = createManagerWorkers({ + name: "Inner", + groupManager: mwAgent({ name: "Inner", llmName: "i_llm" }), + workers: [mwAgent({ name: "Leaf", llmName: "l_llm" })], + }); + const outerSpec = createManagerWorkers({ + name: "Outer", + groupManager: innerSpec, + workers: [mwAgent({ name: "Other", llmName: "o_llm" })], + }); + + const loader = new AgentSpecLoader(); + await expect(loader.loadComponent(outerSpec)).rejects.toThrow( + /group_manager must be an Agent/, + ); + }); +}); + +describe("ManagerWorkers as a flow step", () => { + /** + * Python's `_flow_with_manager_workers_step`: start -> AgentNode(MW) -> end, + * with data edges resolving the manager's `joke` input and every output. + */ + function flowWithManagerWorkersStep(outputs: Property[]): Flow { + const joke = stringProperty({ title: "joke" }); + const manager = mwAgent({ + name: "manager", + llmName: "manager_llm", + systemPrompt: "Translate the following to Arabic:\n\n{{joke}}", + outputs, + }); + const worker = mwAgent({ + name: "worker", + llmName: "worker_llm", + systemPrompt: "You translate.", + }); + const managerWorkers = createManagerWorkers({ + name: "translator", + groupManager: manager, + workers: [worker], + }); + // The ManagerWorkers exposes the group manager's prompt placeholders as + // inputs, so the AgentNode declares an input port the DataFlowEdge below + // can resolve. + expect((managerWorkers.inputs ?? []).map((input) => input.title)).toEqual([ + "joke", + ]); + + const managerNode = createAgentNode({ + name: "manager_node", + agent: managerWorkers, + }); + const startNode = createStartNode({ + name: "start", + inputs: [joke], + outputs: [joke], + }); + const endNode = createEndNode({ name: "end", inputs: outputs, outputs }); + return createFlow({ + name: "flow", + startNode, + nodes: [startNode, managerNode, endNode], + controlFlowConnections: [ + createControlFlowEdge({ + name: "start_to_node", + fromNode: startNode, + toNode: managerNode, + }), + createControlFlowEdge({ + name: "node_to_end", + fromNode: managerNode, + toNode: endNode, + }), + ], + dataFlowConnections: [ + createDataFlowEdge({ + name: "joke_edge", + sourceNode: startNode, + sourceOutput: joke.title, + destinationNode: managerNode, + destinationInput: joke.title, + }), + ...outputs.map((output) => + createDataFlowEdge({ + name: `${output.title}_edge`, + sourceNode: managerNode, + sourceOutput: output.title, + destinationNode: endNode, + destinationInput: output.title, + }), + ), + ], + outputs, + }); + } + + it("runs as a flow step with data-edge inputs and a single string output", async () => { + const flow = flowWithManagerWorkersStep([ + stringProperty({ title: "translated" }), + ]); + // The final message has no tool_calls -> the manager routes to END + // without delegating; its answer is the node's single string output. + const { graph } = await loadWithFakeLlms(flow, [new AIMessage("لماذا...")]); + + const result = await graph.invoke( + { + inputs: { joke: "Why did the car..." }, + messages: [{ role: "user", content: "" }], + }, + threadConfig("managerworkers-node"), + ); + + expect((result["outputs"] as Record)["translated"]).toBe( + "لماذا...", + ); + }); + + it("rejects unsupported output shapes at conversion time, not mid-run", async () => { + const flow = flowWithManagerWorkersStep([ + stringProperty({ title: "translated" }), + stringProperty({ title: "notes" }), + ]); + const loader = new FakeLlmAgentSpecLoader([], { + checkpointer: new MemorySaver(), + }); + await expect(loader.loadComponent(flow)).rejects.toThrow( + /single string output/, + ); + }); +}); diff --git a/tsagentspec/tests/adapters/langgraph/mcp.test.ts b/tsagentspec/tests/adapters/langgraph/mcp.test.ts new file mode 100644 index 00000000..25284a1b --- /dev/null +++ b/tsagentspec/tests/adapters/langgraph/mcp.test.ts @@ -0,0 +1,497 @@ +/** + * MCP conversion tests for the LangGraph adapter. + * + * Mirrors the offline-able behaviors of + * `pyagentspec/tests/adapters/langgraph/mcp/test_mcp.py` and the MCP sections + * of `_langgraphconverter.py` — transport mapping, the + * `${transport.id}::${toolName}` registry cache, and MCPTool / MCPToolBox + * conversion with `toolFilter` validation. `@langchain/mcp-adapters` is + * mocked (vi.mock), so no MCP server or subprocess is ever started. + * + * Documented divergences exercised here (see mcp.ts header): + * - mTLS transports are rejected (JS connections have no client-cert options); + * - `sessionParameters.readTimeoutSeconds` maps to the stdio connection's + * `defaultToolTimeout` (Python wires it as the MCP session's per-request + * read timeout, stdio only); + * - a missing MCPTool name raises a descriptive Error (Python raises a bare + * KeyError). + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + createMCPTool, + createMCPToolBox, + createMCPToolSpec, + createRemoteTransport, + createSSETransport, + createSSEmTLSTransport, + createStdioTransport, + createStreamableHTTPTransport, + createStreamableHTTPmTLSTransport, + integerProperty, + stringProperty, + type ClientTransport, +} from "../../../src/index.js"; +import { + convertClientTransport, + convertMcpTool, + convertMcpToolbox, + getOrCreateMcpTools, +} from "../../../src/adapters/langgraph/mcp.js"; +import type { ToolRegistry } from "../../../src/adapters/langgraph/types.js"; + +const mocks = vi.hoisted(() => ({ + getTools: vi.fn<(...servers: string[]) => Promise>(), + constructorConfigs: [] as unknown[], +})); + +vi.mock("@langchain/mcp-adapters", () => ({ + MultiServerMCPClient: class { + constructor(config: unknown) { + mocks.constructorConfigs.push(config); + } + + getTools(...servers: string[]): Promise { + return mocks.getTools(...servers); + } + }, +})); + +/** Minimal structural stand-in for a loaded LangChain MCP tool. */ +function fakeMcpTool( + name: string, + properties: Record> = {}, +): { name: string; description: string; schema: Record } { + return { + name, + description: `${name} description`, + schema: { title: name, type: "object", properties }, + }; +} + +function makeSseTransport(): ClientTransport { + return createSSETransport({ + name: "my server", + url: "https://example.com/sse", + }); +} + +beforeEach(() => { + mocks.getTools.mockReset(); + mocks.constructorConfigs.length = 0; +}); + +describe("convertClientTransport", () => { + it("maps StdioTransport with command, args, env and cwd", () => { + const transport = createStdioTransport({ + name: "stdio server", + command: "uv", + args: ["run", "server.py"], + env: { API_KEY: "secret" }, + cwd: "/srv/mcp", + sessionParameters: { readTimeoutSeconds: 42 }, + }); + // readTimeoutSeconds maps to the per-tool-call timeout, mirroring the + // per-request read timeout Python passes via stdio session_kwargs. + expect(convertClientTransport(transport)).toEqual({ + transport: "stdio", + command: "uv", + args: ["run", "server.py"], + env: { API_KEY: "secret" }, + cwd: "/srv/mcp", + defaultToolTimeout: 42000, + }); + }); + + it("omits env and cwd from stdio connections when unset", () => { + const transport = createStdioTransport({ + name: "stdio server", + command: "echo", + }); + // The SDK defaults readTimeoutSeconds to 60 like Python's spec default. + expect(convertClientTransport(transport)).toEqual({ + transport: "stdio", + command: "echo", + args: [], + defaultToolTimeout: 60000, + }); + }); + + it("maps SSETransport with url and headers", () => { + const transport = createSSETransport({ + name: "sse server", + url: "https://example.com/sse", + headers: { Authorization: "Bearer token" }, + }); + expect(convertClientTransport(transport)).toEqual({ + transport: "sse", + url: "https://example.com/sse", + headers: { Authorization: "Bearer token" }, + }); + }); + + it("omits headers from remote connections when unset", () => { + expect(convertClientTransport(makeSseTransport())).toEqual({ + transport: "sse", + url: "https://example.com/sse", + }); + }); + + it("maps StreamableHTTPTransport to an http connection", () => { + const transport = createStreamableHTTPTransport({ + name: "http server", + url: "https://example.com/mcp", + headers: { "X-Tenant": "t1" }, + }); + expect(convertClientTransport(transport)).toEqual({ + transport: "http", + url: "https://example.com/mcp", + headers: { "X-Tenant": "t1" }, + }); + }); + + it("rejects mTLS transports", () => { + const sseMtls = createSSEmTLSTransport({ + name: "mtls sse", + url: "https://example.com/sse", + keyFile: "client.key", + certFile: "client.crt", + caFile: "ca.crt", + }); + expect(() => convertClientTransport(sseMtls)).toThrow( + "The Agent Spec type 'SSEmTLSTransport' is not supported by the LangGraph TypeScript adapter yet.", + ); + + const httpMtls = createStreamableHTTPmTLSTransport({ + name: "mtls http", + url: "https://example.com/mcp", + keyFile: "client.key", + certFile: "client.crt", + caFile: "ca.crt", + }); + expect(() => convertClientTransport(httpMtls)).toThrow( + "The Agent Spec type 'StreamableHTTPmTLSTransport' is not supported by the LangGraph TypeScript adapter yet.", + ); + }); + + it("rejects unsupported transport types with the Python error text", () => { + const remoteTransport = createRemoteTransport({ + name: "remote", + url: "https://example.com/agent", + }); + expect(() => convertClientTransport(remoteTransport)).toThrow( + "Agent Spec ClientTransport 'RemoteTransport' is not supported yet.", + ); + }); +}); + +describe("getOrCreateMcpTools registry cache", () => { + it("loads tools once and caches them under `${transport.id}::${toolName}`", async () => { + const transport = makeSseTransport(); + const connection = convertClientTransport(transport); + const registry: ToolRegistry = {}; + const fooza = fakeMcpTool("fooza_tool"); + const zwak = fakeMcpTool("zwak"); + mocks.getTools.mockResolvedValue([fooza, zwak]); + + const tools = await getOrCreateMcpTools(transport, connection, registry); + + expect(mocks.constructorConfigs).toEqual([ + { mcpServers: { [transport.id]: connection } }, + ]); + expect(mocks.getTools).toHaveBeenCalledExactlyOnceWith(transport.id); + expect(tools).toEqual({ fooza_tool: fooza, zwak: zwak }); + expect(registry).toEqual({ + [`${transport.id}::fooza_tool`]: fooza, + [`${transport.id}::zwak`]: zwak, + }); + }); + + it("returns cached tools without reconnecting on later calls", async () => { + const transport = makeSseTransport(); + const connection = convertClientTransport(transport); + const registry: ToolRegistry = {}; + mocks.getTools.mockResolvedValue([fakeMcpTool("fooza_tool")]); + + const first = await getOrCreateMcpTools(transport, connection, registry); + const second = await getOrCreateMcpTools(transport, connection, registry); + + expect(second).toEqual(first); + expect(mocks.getTools).toHaveBeenCalledTimes(1); + expect(mocks.constructorConfigs).toHaveLength(1); + }); + + it("returns pre-seeded registry entries without connecting at all", async () => { + const transport = makeSseTransport(); + const cached = fakeMcpTool("fooza_tool"); + const registry: ToolRegistry = { + [`${transport.id}::fooza_tool`]: cached, + "unrelated::other_tool": fakeMcpTool("other_tool"), + }; + + const tools = await getOrCreateMcpTools( + transport, + convertClientTransport(transport), + registry, + ); + + expect(tools).toEqual({ fooza_tool: cached }); + expect(mocks.getTools).not.toHaveBeenCalled(); + expect(mocks.constructorConfigs).toHaveLength(0); + }); + + it("raises the duplicate-tool error when a key appears while loading", async () => { + const transport = makeSseTransport(); + const registry: ToolRegistry = {}; + const fooza = fakeMcpTool("fooza_tool"); + // Simulate a concurrent registration racing the load: the key exists by + // the time the loaded tools are committed to the registry. + mocks.getTools.mockImplementation(async () => { + registry[`${transport.id}::fooza_tool`] = fakeMcpTool("fooza_tool"); + return [fooza]; + }); + + await expect( + getOrCreateMcpTools(transport, convertClientTransport(transport), registry), + ).rejects.toThrow( + "Trying to add the same tool twice; this might happen " + + "when the tool is declared as both a standalone MCPTool and part of a MCPToolBox", + ); + }); + + it("raises when a loaded tool has no name", async () => { + const transport = makeSseTransport(); + mocks.getTools.mockResolvedValue([{ description: "nameless" }]); + + await expect( + getOrCreateMcpTools(transport, convertClientTransport(transport), {}), + ).rejects.toThrow("Loaded a tool without a name attribute or __name__."); + }); +}); + +describe("convertMcpTool", () => { + it("returns the exposed tool with the MCPTool's name", async () => { + const transport = makeSseTransport(); + const fooza = fakeMcpTool("fooza_tool"); + mocks.getTools.mockResolvedValue([fooza, fakeMcpTool("zwak")]); + const mcpTool = createMCPTool({ + name: "fooza_tool", + clientTransport: transport, + }); + + const converted = await convertMcpTool(mcpTool, {}); + + expect(converted).toBe(fooza); + }); + + it("raises when the named tool is not exposed by the server", async () => { + const transport = makeSseTransport(); + mocks.getTools.mockResolvedValue([fakeMcpTool("zwak")]); + const mcpTool = createMCPTool({ + name: "missing_tool", + clientTransport: transport, + }); + + await expect(convertMcpTool(mcpTool, {})).rejects.toThrow( + "MCP tool 'missing_tool' was not found in the tools exposed " + + `by the MCP server for transport '${transport.id}'.`, + ); + }); + + it("shares the registry cache with a toolbox on the same transport", async () => { + const transport = makeSseTransport(); + const fooza = fakeMcpTool("fooza_tool"); + const zwak = fakeMcpTool("zwak"); + mocks.getTools.mockResolvedValue([fooza, zwak]); + const registry: ToolRegistry = {}; + + const standalone = await convertMcpTool( + createMCPTool({ name: "fooza_tool", clientTransport: transport }), + registry, + ); + const toolboxTools = await convertMcpToolbox( + createMCPToolBox({ name: "box", clientTransport: transport }), + registry, + ); + + // A single connection serves both conversions. + expect(mocks.getTools).toHaveBeenCalledTimes(1); + expect(standalone).toBe(fooza); + expect(toolboxTools).toEqual([fooza, zwak]); + }); +}); + +describe("convertMcpToolbox", () => { + it("returns all exposed tools when no filter is set", async () => { + const transport = makeSseTransport(); + const fooza = fakeMcpTool("fooza_tool"); + const bwip = fakeMcpTool("bwip_tool"); + const zwak = fakeMcpTool("zwak"); + mocks.getTools.mockResolvedValue([fooza, bwip, zwak]); + + const tools = await convertMcpToolbox( + createMCPToolBox({ name: "box", clientTransport: transport }), + {}, + ); + + expect(tools).toEqual([fooza, bwip, zwak]); + }); + + it("filters by name and spec entries, preserving the filter order", async () => { + const transport = makeSseTransport(); + const fooza = fakeMcpTool("fooza_tool"); + const bwip = fakeMcpTool("bwip_tool"); + const zbuk = fakeMcpTool("zbuk_tool", { + a: { title: "a", type: "integer" }, + b: { title: "b", type: "integer" }, + }); + mocks.getTools.mockResolvedValue([fooza, bwip, zbuk]); + const toolbox = createMCPToolBox({ + name: "drop_box", + clientTransport: transport, + toolFilter: [ + createMCPToolSpec({ + name: "zbuk_tool", + description: "something", + inputs: [integerProperty({ title: "a" }), integerProperty({ title: "b" })], + }), + "bwip_tool", + ], + }); + + const tools = await convertMcpToolbox(toolbox, {}); + + expect(tools).toEqual([zbuk, bwip]); + }); + + it("raises a sorted Missing tools error for unknown filter names", async () => { + const transport = makeSseTransport(); + mocks.getTools.mockResolvedValue([fakeMcpTool("fooza_tool")]); + const toolbox = createMCPToolBox({ + name: "box", + clientTransport: transport, + toolFilter: [ + "z_missing", + "fooza_tool", + createMCPToolSpec({ name: "a_missing" }), + ], + }); + + await expect(convertMcpToolbox(toolbox, {})).rejects.toThrow( + "Missing tools: a_missing, z_missing", + ); + }); + + it("raises Missing tools for filter names that collide with Object.prototype keys", async () => { + // Membership must be own-keys only: "constructor" must not resolve to + // the inherited Object function and dodge the missing-tools error. + const transport = makeSseTransport(); + mocks.getTools.mockResolvedValue([fakeMcpTool("fooza_tool")]); + const toolbox = createMCPToolBox({ + name: "box", + clientTransport: transport, + toolFilter: ["constructor"], + }); + + await expect(convertMcpToolbox(toolbox, {})).rejects.toThrow( + "Missing tools: constructor", + ); + }); + + it("accepts a spec whose input schemas match the remote tool", async () => { + const transport = makeSseTransport(); + const zbuk = fakeMcpTool("zbuk_tool", { + a: { title: "A", type: "integer" }, + b: { title: "b", type: "integer" }, + }); + mocks.getTools.mockResolvedValue([zbuk]); + const toolbox = createMCPToolBox({ + name: "box", + clientTransport: transport, + toolFilter: [ + createMCPToolSpec({ + name: "zbuk_tool", + inputs: [integerProperty({ title: "a" }), integerProperty({ title: "b" })], + }), + ], + }); + + await expect(convertMcpToolbox(toolbox, {})).resolves.toEqual([zbuk]); + }); + + it("does not reject a per-property type mismatch (bug-compatible with Python)", async () => { + // Both SDKs pass {name: schema} maps to jsonSchemasHaveSameType, which + // inspects only JSON-schema keys (type/items/properties/...), so the + // property-level types are never actually compared. Mirrored verbatim + // from Python for wire compatibility. + const transport = makeSseTransport(); + const zbuk = fakeMcpTool("zbuk_tool", { + a: { title: "a", type: "string" }, + }); + mocks.getTools.mockResolvedValue([zbuk]); + const toolbox = createMCPToolBox({ + name: "box", + clientTransport: transport, + toolFilter: [ + createMCPToolSpec({ + name: "zbuk_tool", + inputs: [integerProperty({ title: "a" })], + }), + ], + }); + + await expect(convertMcpToolbox(toolbox, {})).resolves.toEqual([zbuk]); + }); + + it("raises the Input descriptors mismatch error when the schema maps differ in type", async () => { + // A property named like a JSON-schema keyword ("items" here) makes the + // schema maps genuinely comparable, so the mismatch branch fires — in + // Python exactly the same way. + const transport = makeSseTransport(); + const zbuk = fakeMcpTool("zbuk_tool", { + items: { title: "items", type: "string" }, + }); + mocks.getTools.mockResolvedValue([zbuk]); + const spec = createMCPToolSpec({ + name: "zbuk_tool", + inputs: [ + { + title: "items", + description: undefined, + default: undefined, + type: "integer", + jsonSchema: { title: "items", type: "integer" }, + }, + ], + }); + const toolbox = createMCPToolBox({ + name: "box", + clientTransport: transport, + toolFilter: [spec], + }); + + await expect(convertMcpToolbox(toolbox, {})).rejects.toThrow( + "Input descriptors mismatch for tool 'zbuk_tool'.", + ); + }); + + it("raises when the remote tool schema is not a plain object", async () => { + const transport = makeSseTransport(); + mocks.getTools.mockResolvedValue([ + { name: "zbuk_tool", description: "d", schema: undefined }, + ]); + const toolbox = createMCPToolBox({ + name: "box", + clientTransport: transport, + toolFilter: [ + createMCPToolSpec({ + name: "zbuk_tool", + inputs: [stringProperty({ title: "a" })], + }), + ], + }); + + await expect(convertMcpToolbox(toolbox, {})).rejects.toThrow( + "Expected Langchain StructuredTool.args_schema to be a dict but got undefined", + ); + }); +}); diff --git a/tsagentspec/tests/adapters/langgraph/remote-tools.test.ts b/tsagentspec/tests/adapters/langgraph/remote-tools.test.ts new file mode 100644 index 00000000..439676f1 --- /dev/null +++ b/tsagentspec/tests/adapters/langgraph/remote-tools.test.ts @@ -0,0 +1,703 @@ +/** + * RemoteTool / ClientTool conversion tests for the LangGraph adapter. + * + * Mirrors the RemoteTool sections of + * `pyagentspec/tests/adapters/langgraph/test_tools.py` with a mocked global + * fetch (JS equivalent of patching `httpx.request`): template rendering in + * url/data/headers/queryParams, body routing (urlencoded form vs raw string + * vs JSON), the confirmation-interrupt machinery and the ClientTool + * interrupt protocol. + * + * Documented divergences exercised here (see tools.ts / tools-common.ts): + * - a single fetch attempt, no retry engine (TS SDK has no RetryPolicy); + * - fetch forbids GET/HEAD bodies, so none is sent for those methods; + * - confirmation `Args:` strings use JSON.stringify (Python uses str(dict)). + */ +import { afterEach, describe, expect, it } from "vitest"; +import type { StructuredToolInterface } from "@langchain/core/tools"; +import { + Annotation, + Command, + MemorySaver, + START, + StateGraph, +} from "@langchain/langgraph"; +import { + createClientTool, + createRemoteTool, + createServerTool, + integerProperty, + stringProperty, + type JsonSchemaValue, +} from "../../../src/index.js"; +import { DEFAULT_HTTP_REQUEST_TIMEOUT_MS } from "../../../src/adapters/common/tools-common.js"; +import { + confirmThen, + convertClientTool, + convertRemoteTool, + ensureCheckpointerAndValidToolConfig, +} from "../../../src/adapters/langgraph/tools.js"; +import { + approveCommand, + getInterrupts, + installMockFetch, + rejectCommand, + threadConfig, + type MockFetchController, +} from "./test-helpers.js"; + +let mockFetch: MockFetchController | undefined; + +afterEach(() => { + mockFetch?.restore(); + mockFetch = undefined; +}); + +function headersOf(init: RequestInit | undefined): Record { + return (init?.headers ?? {}) as Record; +} + +/** Compile a one-node graph that invokes the tool with the given arguments. */ +function makeToolCallGraph( + langchainTool: StructuredToolInterface, + args: Record, +) { + const state = Annotation.Root({ result: Annotation() }); + return new StateGraph(state) + .addNode("call", async () => ({ result: await langchainTool.invoke(args) })) + .addEdge(START, "call") + .compile({ checkpointer: new MemorySaver() }); +} + +describe("convertRemoteTool template rendering", () => { + it("renders nested data, url path, header keys and values", async () => { + mockFetch = installMockFetch((url) => { + const city = decodeURIComponent(url.split("/").pop() ?? ""); + return { weather: `sunny in ${city}` }; + }); + const remoteTool = createRemoteTool({ + name: "forecast_weather", + description: "Returns a forecast of the weather for the chosen city", + url: "https://weatherforecast.example/api/forecast/{{city}}", + httpMethod: "POST", + data: { + location: { + city: "{{city}}", + coordinates: { lat: "{{lat}}", lon: "{{lon}}" }, + }, + meta: ["requested_by:{{user}}", { note: "hello{{suffix}}" }], + raw: "binary-{{bin_suffix}}", + }, + headers: { "X-{{header_key}}": "{{user}}" }, + }); + + const langchainTool = convertRemoteTool(remoteTool); + const result = await langchainTool.invoke({ + city: "Agadir", + lat: "30.4", + lon: "-9.6", + user: "alice", + suffix: "world", + bin_suffix: "blob", + header_key: "Caller", + }); + + expect(mockFetch.calls).toHaveLength(1); + const call = mockFetch.calls[0]!; + expect(call.url).toBe( + "https://weatherforecast.example/api/forecast/Agadir", + ); + expect(call.init?.method).toBe("POST"); + // Templated header keys and values are both rendered; JSON content type + // is added automatically for object bodies. + expect(headersOf(call.init)).toEqual({ + "X-Caller": "alice", + "Content-Type": "application/json", + }); + expect(JSON.parse(call.init?.body as string)).toEqual({ + location: { + city: "Agadir", + coordinates: { lat: "30.4", lon: "-9.6" }, + }, + meta: ["requested_by:alice", { note: "helloworld" }], + raw: "binary-blob", + }); + expect(result).toEqual({ weather: "sunny in Agadir" }); + }); + + it("leaves unknown placeholders verbatim", async () => { + mockFetch = installMockFetch(() => ({ ok: true })); + const remoteTool = createRemoteTool({ + name: "partial", + description: "d", + url: "https://example.com/api/{{known}}/{{unknown}}", + httpMethod: "POST", + data: { note: "hello{{unknown}}" }, + inputs: [stringProperty({ title: "known" })], + }); + + await convertRemoteTool(remoteTool).invoke({ known: "k" }); + + const call = mockFetch.calls[0]!; + expect(call.url).toBe("https://example.com/api/k/{{unknown}}"); + expect(JSON.parse(call.init?.body as string)).toEqual({ + note: "hello{{unknown}}", + }); + }); +}); + +describe("convertRemoteTool body routing", () => { + it("sends an urlencoded form body for object data with the urlencoded content type", async () => { + mockFetch = installMockFetch(() => ({ ok: true })); + const remoteTool = createRemoteTool({ + name: "form_tool", + description: "d", + url: "https://example.com/api/form", + httpMethod: "POST", + data: { value: "{{v1}}", listofvalues: ["a", "{{v2}}", "c"] }, + headers: { + header1: "{{h1}}", + "Content-Type": "application/x-www-form-urlencoded", + }, + }); + + await convertRemoteTool(remoteTool).invoke({ + v1: "test1", + v2: "test2", + h1: "test4", + }); + + const call = mockFetch.calls[0]!; + expect(headersOf(call.init)).toEqual({ + header1: "test4", + "Content-Type": "application/x-www-form-urlencoded", + }); + const body = call.init?.body as URLSearchParams; + expect(body).toBeInstanceOf(URLSearchParams); + expect(body.get("value")).toBe("test1"); + // Non-string form values are JSON stringified. + expect(body.get("listofvalues")).toBe('["a","test2","c"]'); + }); + + it("sends a raw string body verbatim after rendering", async () => { + mockFetch = installMockFetch(() => ({ ok: true })); + const remoteTool = createRemoteTool({ + name: "send_raw", + description: "Sends a raw string body", + url: "https://example.com/api/raw", + httpMethod: "POST", + data: "request body for city: {{city}} with note: {{note}}", + }); + + await convertRemoteTool(remoteTool).invoke({ + city: "Agadir", + note: "urgent", + }); + + const call = mockFetch.calls[0]!; + expect(call.init?.body).toBe( + "request body for city: Agadir with note: urgent", + ); + // No JSON content type for raw bodies. + expect(headersOf(call.init)).toEqual({}); + }); + + it("sends a JSON body for array data", async () => { + mockFetch = installMockFetch(() => ({ ok: true })); + const remoteTool = createRemoteTool({ + name: "process_array", + description: "Processes a JSON array body", + url: "https://example.com/api/process", + httpMethod: "POST", + data: ["forecast", { location: "{{city}}", temp: "{{temp}}" }], + }); + + await convertRemoteTool(remoteTool).invoke({ city: "Agadir", temp: "25" }); + + const call = mockFetch.calls[0]!; + expect(headersOf(call.init)["Content-Type"]).toBe("application/json"); + expect(JSON.parse(call.init?.body as string)).toEqual([ + "forecast", + { location: "Agadir", temp: "25" }, + ]); + }); + + it("does not send a body on GET and appends query parameters", async () => { + mockFetch = installMockFetch(() => ({ ok: true })); + const remoteTool = createRemoteTool({ + name: "get_tool", + description: "d", + url: "https://example.com/api/echo/{{u1}}", + httpMethod: "GET", + data: { ignored: "{{u1}}" }, + queryParams: { param: "{{p1}}" }, + }); + + await convertRemoteTool(remoteTool).invoke({ u1: "u_seg", p1: "test3" }); + + const call = mockFetch.calls[0]!; + expect(call.url).toBe("https://example.com/api/echo/u_seg?param=test3"); + expect(call.init?.method).toBe("GET"); + expect(call.init?.body).toBeUndefined(); + }); + + it("appends query params with & when the url already has a query, repeating array values", async () => { + mockFetch = installMockFetch(() => ({ ok: true })); + const remoteTool = createRemoteTool({ + name: "query_tool", + description: "d", + url: "https://example.com/api?x=1", + httpMethod: "GET", + queryParams: { tags: ["a", "{{t}}"], n: 3 }, + }); + + await convertRemoteTool(remoteTool).invoke({ t: "b" }); + + expect(mockFetch.calls[0]!.url).toBe( + "https://example.com/api?x=1&tags=a&tags=b&n=3", + ); + }); + + it("stringifies non-string header values", async () => { + mockFetch = installMockFetch(() => ({ ok: true })); + const remoteTool = createRemoteTool({ + name: "num_header", + description: "d", + url: "https://example.com/api", + httpMethod: "GET", + headers: { "X-Num": 42 }, + }); + + await convertRemoteTool(remoteTool).invoke({}); + + expect(headersOf(mockFetch.calls[0]!.init)["X-Num"]).toBe("42"); + }); +}); + +describe("convertRemoteTool responses", () => { + it("returns the parsed JSON response body", async () => { + mockFetch = installMockFetch(() => ({ processed_city: "Agadir" })); + const remoteTool = createRemoteTool({ + name: "echo", + description: "d", + url: "https://example.com/api", + httpMethod: "POST", + data: { x: "{{x}}" }, + }); + + await expect( + convertRemoteTool(remoteTool).invoke({ x: "1" }), + ).resolves.toEqual({ processed_city: "Agadir" }); + }); + + it("parses and returns the JSON body of non-2xx responses like Python", async () => { + // Python without a retry policy (the only state the TS RemoteTool can + // express) returns response.json() for every status, so the agent sees + // error payloads as the tool result instead of an aborted run. + mockFetch = installMockFetch( + () => + new Response('{"error": "bad date range"}', { + status: 422, + headers: { "Content-Type": "application/json" }, + }), + ); + const remoteTool = createRemoteTool({ + name: "failing", + description: "d", + url: "https://example.com/api", + httpMethod: "GET", + }); + + await expect(convertRemoteTool(remoteTool).invoke({})).resolves.toEqual({ + error: "bad date range", + }); + }); + + it("does not follow redirects and parses a 3xx body like any other status", async () => { + // Python's httpx does not follow redirects (follow_redirects defaults to + // False): a 3xx comes back as the response instead of triggering a second + // request to the Location target (redirect-based egress / auth-header + // forwarding on untrusted spec config). undici's redirect: "manual" + // returns the 3xx response with its body intact, which the no-retry path + // then parses like any other status. + mockFetch = installMockFetch( + () => + new Response('{"error": "moved"}', { + status: 302, + headers: { + "Content-Type": "application/json", + Location: "https://attacker.example/exfil", + }, + }), + ); + const remoteTool = createRemoteTool({ + name: "redirecting", + description: "d", + url: "https://example.com/api", + httpMethod: "GET", + }); + + await expect(convertRemoteTool(remoteTool).invoke({})).resolves.toEqual({ + error: "moved", + }); + expect(mockFetch.calls).toHaveLength(1); + expect(mockFetch.calls[0]!.init?.redirect).toBe("manual"); + }); + + it("attaches the default httpx-parity timeout and names the tool on a timeout abort", async () => { + // Python's httpx applies a 5s default timeout; the TS SDK RemoteTool has + // no RetryPolicy.requestTimeout yet, so the exported constant is the only + // knob and a timeout abort maps to an Error naming the tool. + expect(DEFAULT_HTTP_REQUEST_TIMEOUT_MS).toBe(5000); + mockFetch = installMockFetch(() => { + throw new DOMException( + "The operation was aborted due to timeout", + "TimeoutError", + ); + }); + const remoteTool = createRemoteTool({ + name: "slow", + description: "d", + url: "https://example.com/api", + httpMethod: "GET", + }); + + await expect(convertRemoteTool(remoteTool).invoke({})).rejects.toThrow( + `RemoteTool \`slow\` HTTP request timed out after ${DEFAULT_HTTP_REQUEST_TIMEOUT_MS}ms.`, + ); + expect(mockFetch.calls).toHaveLength(1); + expect(mockFetch.calls[0]!.init?.signal).toBeInstanceOf(AbortSignal); + }); + + it("injects declared input defaults into the rendered request like Python", async () => { + // Python's pydantic args model fills Property defaults before the tool + // func renders the URL; langchain JS applies no JSON-schema defaults, so + // the adapter injects them itself. + mockFetch = installMockFetch(() => ({ ok: true })); + const remoteTool = createRemoteTool({ + name: "search", + description: "d", + url: "https://api.example.com/search?q={{query}}&limit={{limit}}", + httpMethod: "GET", + inputs: [ + stringProperty({ title: "query" }), + integerProperty({ title: "limit", default: 10 }), + ], + }); + + await convertRemoteTool(remoteTool).invoke({ query: "abc" }); + + expect(mockFetch.calls[0]!.url).toBe( + "https://api.example.com/search?q=abc&limit=10", + ); + }); + + it("validates the call arguments against the inferred schema", async () => { + mockFetch = installMockFetch(() => ({ ok: true })); + const remoteTool = createRemoteTool({ + name: "strict", + description: "d", + url: "https://example.com/api/{{city}}", + httpMethod: "GET", + }); + + await expect(convertRemoteTool(remoteTool).invoke({})).rejects.toThrow( + "Received tool input did not match expected schema", + ); + expect(mockFetch.calls).toHaveLength(0); + }); +}); + +describe("requiresConfirmation interrupt machinery", () => { + function makeConfirmedRemoteTool() { + return createRemoteTool({ + name: "remote_echo", + description: "Echo", + url: "https://example.com/echo", + httpMethod: "POST", + data: { x: "{{x}}" }, + inputs: [integerProperty({ title: "x" })], + requiresConfirmation: true, + }); + } + + it("interrupts with the exact confirmation payload and executes on approve", async () => { + mockFetch = installMockFetch((_url, init) => ({ + ok: true, + body: JSON.parse(init?.body as string) as unknown, + })); + const graph = makeToolCallGraph( + convertRemoteTool(makeConfirmedRemoteTool()), + { x: 3 }, + ); + const config = threadConfig("rt1"); + + const first = await graph.invoke({}, config); + const interrupts = getInterrupts(first); + expect(interrupts).toHaveLength(1); + expect(interrupts[0]!.value).toEqual({ + action_requests: [ + { + name: "remote_echo", + arguments: { x: 3 }, + description: + 'Tool execution pending approval\n\nTool: remote_echo\nArgs: {"x":3}', + }, + ], + review_configs: [ + { + action_name: "remote_echo", + allowed_decisions: ["approve", "reject"], + description: + 'Please resume with {"decisions": [{"type": "approve"}]} # or "reject" ' + + 'with an optional "reason" for rejected tool calls.', + }, + ], + }); + // The HTTP request must not run before approval. + expect(mockFetch.calls).toHaveLength(0); + + const resumed = await graph.invoke(approveCommand(), config); + expect(mockFetch.calls).toHaveLength(1); + expect(resumed["result"]).toEqual({ ok: true, body: { x: "3" } }); + }); + + it("throws the denial error on reject and never calls fetch", async () => { + mockFetch = installMockFetch(() => ({ ok: true })); + const graph = makeToolCallGraph( + convertRemoteTool(makeConfirmedRemoteTool()), + { x: 3 }, + ); + const config = threadConfig("rt2"); + + await graph.invoke({}, config); + await expect(graph.invoke(rejectCommand("no"), config)).rejects.toThrow( + "Tool 'remote_echo' was denied by the user (reason: no).", + ); + expect(mockFetch.calls).toHaveLength(0); + }); + + it("uses a default reason when the rejection has none", async () => { + mockFetch = installMockFetch(() => ({ ok: true })); + const graph = makeToolCallGraph( + convertRemoteTool(makeConfirmedRemoteTool()), + { x: 3 }, + ); + const config = threadConfig("rt3"); + + await graph.invoke({}, config); + await expect(graph.invoke(rejectCommand(), config)).rejects.toThrow( + "Tool 'remote_echo' was denied by the user (reason: No reason was provided.).", + ); + }); + + it.each([ + [ + "a non-dict resume value", + "nope", + "Tool confirmation result for tool remote_echo is not valid, should be " + + `a dict with a 'decisions' key, was "nope" of type string.`, + ], + [ + "an empty decisions list", + { decisions: [] }, + "Tool confirmation result for tool remote_echo is not valid, decisions " + + "should be of length 1, was of length 0", + ], + [ + "two decisions", + { decisions: [{ type: "approve" }, { type: "approve" }] }, + "Tool confirmation result for tool remote_echo is not valid, decisions " + + "should be of length 1, was of length 2", + ], + [ + "an unknown decision type", + { decisions: [{ type: "maybe" }] }, + "Tool confirmation result for tool remote_echo is not valid, decision " + + `should be in ['approve', 'reject'], was {"type":"maybe"}.`, + ], + ])("raises the Python validation error for %s", async (_label, resume, message) => { + mockFetch = installMockFetch(() => ({ ok: true })); + const graph = makeToolCallGraph( + convertRemoteTool(makeConfirmedRemoteTool()), + { x: 3 }, + ); + const config = threadConfig(`rt-${_label}`); + + await graph.invoke({}, config); + await expect( + graph.invoke(new Command({ resume }), config), + ).rejects.toThrow(message); + expect(mockFetch.calls).toHaveLength(0); + }); + + it("confirmThen returns the function unchanged without requiresConfirmation", () => { + const func = (input: unknown): unknown => input; + expect(confirmThen(func, "t", false)).toBe(func); + expect(confirmThen(func, "t", true)).not.toBe(func); + }); +}); + +describe("ensureCheckpointerAndValidToolConfig", () => { + it("requires a checkpointer for tools with requiresConfirmation", () => { + const serverTool = createServerTool({ + name: "double_tool", + description: "Doubles input", + inputs: [integerProperty({ title: "x" })], + requiresConfirmation: true, + }); + expect(() => + ensureCheckpointerAndValidToolConfig(serverTool, undefined), + ).toThrow( + "A Checkpointer is required for tool 'double_tool' because requires_confirmation=True", + ); + expect(() => + ensureCheckpointerAndValidToolConfig(serverTool, new MemorySaver()), + ).not.toThrow(); + }); + + it("requires a checkpointer for every ClientTool", () => { + const clientTool = createClientTool({ + name: "client_double", + description: "Client doubles the number", + inputs: [integerProperty({ title: "x" })], + }); + expect(() => + ensureCheckpointerAndValidToolConfig(clientTool, undefined), + ).toThrow("A Checkpointer is required when using ClientTool 'client_double'."); + expect(() => + ensureCheckpointerAndValidToolConfig(clientTool, new MemorySaver()), + ).not.toThrow(); + }); + + it("accepts confirmation-free server tools without a checkpointer", () => { + const serverTool = createServerTool({ + name: "double_tool", + description: "Doubles input", + inputs: [integerProperty({ title: "x" })], + }); + expect(() => + ensureCheckpointerAndValidToolConfig(serverTool, undefined), + ).not.toThrow(); + }); +}); + +describe("convertClientTool interrupt protocol", () => { + it("builds the args schema from the AgentSpec inputs", () => { + const clientTool = createClientTool({ + name: "client_double", + description: "Client doubles the number", + inputs: [integerProperty({ title: "x" })], + }); + const langchainTool = convertClientTool(clientTool); + expect(langchainTool.name).toBe("client_double"); + expect(langchainTool.description).toBe("Client doubles the number"); + expect(langchainTool.schema as JsonSchemaValue).toEqual({ + title: "client_doubleArgs", + type: "object", + properties: { x: { title: "x", type: "integer" } }, + required: ["x"], + }); + }); + + it("interrupts with the client_tool_request payload and returns the resume value", async () => { + const clientTool = createClientTool({ + name: "client_double", + description: "Client doubles the number", + inputs: [integerProperty({ title: "x" })], + }); + const graph = makeToolCallGraph(convertClientTool(clientTool), { x: 7 }); + const config = threadConfig("ct1"); + + const first = await graph.invoke({}, config); + const interrupts = getInterrupts(first); + expect(interrupts).toHaveLength(1); + expect(interrupts[0]!.value).toEqual({ + type: "client_tool_request", + name: "client_double", + description: "Client doubles the number", + inputs: { args: [], kwargs: { x: 7 } }, + }); + + const resumed = await graph.invoke(new Command({ resume: 14 }), config); + expect(resumed["result"]).toBe(14); + }); + + it("includes declared input defaults in the client_tool_request kwargs", async () => { + // Python's pydantic validation injects defaults before the interrupt + // payload is built, so the client sees the defaulted arguments too. + const clientTool = createClientTool({ + name: "client_double", + description: "Client doubles the number", + inputs: [ + integerProperty({ title: "x" }), + integerProperty({ title: "factor", default: 2 }), + ], + }); + const graph = makeToolCallGraph(convertClientTool(clientTool), { x: 7 }); + const config = threadConfig("ct-defaults"); + + const first = await graph.invoke({}, config); + const interrupts = getInterrupts(first); + expect(interrupts).toHaveLength(1); + expect(interrupts[0]!.value).toEqual({ + type: "client_tool_request", + name: "client_double", + description: "Client doubles the number", + inputs: { args: [], kwargs: { x: 7, factor: 2 } }, + }); + }); + + it("confirms first, then interrupts for client execution (two interrupts)", async () => { + const clientTool = createClientTool({ + name: "client_double", + description: "Client doubles the number", + inputs: [integerProperty({ title: "x" })], + requiresConfirmation: true, + }); + const graph = makeToolCallGraph(convertClientTool(clientTool), { x: 7 }); + const config = threadConfig("ct2"); + + // 1. confirmation interrupt + const first = await graph.invoke({}, config); + const confirmPayload = getInterrupts(first)[0]!.value as Record< + string, + unknown + >; + const actionRequests = confirmPayload["action_requests"] as Array< + Record + >; + expect(actionRequests[0]!["name"]).toBe("client_double"); + expect(actionRequests[0]!["arguments"]).toEqual({ x: 7 }); + + // 2. approve -> client_tool_request interrupt + const second = await graph.invoke(approveCommand(), config); + const clientRequest = getInterrupts(second)[0]!.value as Record< + string, + unknown + >; + expect(clientRequest["type"]).toBe("client_tool_request"); + expect(clientRequest["name"]).toBe("client_double"); + expect(clientRequest["inputs"]).toEqual({ args: [], kwargs: { x: 7 } }); + + // 3. resume with the client-side result + const resumed = await graph.invoke(new Command({ resume: 14 }), config); + expect(resumed["result"]).toBe(14); + }); + + it("rejecting the confirmation raises and never requests client execution", async () => { + const clientTool = createClientTool({ + name: "client_double", + description: "Client doubles the number", + inputs: [integerProperty({ title: "x" })], + requiresConfirmation: true, + }); + const graph = makeToolCallGraph(convertClientTool(clientTool), { x: 7 }); + const config = threadConfig("ct3"); + + await graph.invoke({}, config); + await expect(graph.invoke(rejectCommand("no"), config)).rejects.toThrow( + "Tool 'client_double' was denied by the user (reason: no).", + ); + }); +}); diff --git a/tsagentspec/tests/adapters/langgraph/swarm.test.ts b/tsagentspec/tests/adapters/langgraph/swarm.test.ts new file mode 100644 index 00000000..e17005a0 --- /dev/null +++ b/tsagentspec/tests/adapters/langgraph/swarm.test.ts @@ -0,0 +1,207 @@ +/** + * Swarm tests for the LangGraph adapter. + * + * Mirrors the offline-able Swarm behaviors of the Python suite: loading an + * Agent Spec Swarm compiles a `@langchain/langgraph-swarm` graph with one node + * per participating agent, `transfer_to_` handoff tools are injected + * per relationship direction, a handoff round trip runs with fake LLMs, and + * the two conversion errors (HandoffMode NEVER, non-Agent participant) carry + * the Python message text. All tests run offline. + */ +import { describe, expect, it } from "vitest"; +import { AIMessage, HumanMessage, type BaseMessage } from "@langchain/core/messages"; +import { MemorySaver, START } from "@langchain/langgraph"; +import { + createAgent as createAgentSpecAgent, + createManagerWorkers, + createSwarm, + HandoffMode, +} from "../../../src/index.js"; +import type { Agent, Swarm } from "../../../src/index.js"; +import { AgentSpecLoader } from "../../../src/adapters/langgraph/agentspec-loader.js"; +import { + FakeLlmAgentSpecLoader, + makeLlmConfig, + threadConfig, + toolCallMessage, + type FakeLlmResponses, +} from "./test-helpers.js"; + +/** Structural surface of a compiled swarm graph used by these tests. */ +interface CompiledSwarmLike { + lg_is_pregel?: boolean; + name?: string; + builder: { + nodes: Record; + branches: Record; + channels: Record; + }; + invoke(input: unknown, config?: unknown): Promise>; +} + +/** A minimal swarm participant with a named LLM config. */ +function swarmAgent(opts: { + name: string; + llmName: string; + systemPrompt?: string; +}): Agent { + return createAgentSpecAgent({ + name: opts.name, + llmConfig: makeLlmConfig({ name: opts.llmName }), + systemPrompt: opts.systemPrompt ?? ".", + }); +} + +/** Compile a Swarm offline, answering each LLM config with a queued fake. */ +async function loadWithFakeLlms( + swarm: Swarm, + responses: FakeLlmResponses, +): Promise<{ graph: CompiledSwarmLike; loader: FakeLlmAgentSpecLoader }> { + const loader = new FakeLlmAgentSpecLoader(responses, { + checkpointer: new MemorySaver(), + }); + const graph = (await loader.loadComponent(swarm)) as CompiledSwarmLike; + return { graph, loader }; +} + +/** Tool names registered on the `tools` node of one swarm agent's subgraph. */ +function agentToolNames(graph: CompiledSwarmLike, agentName: string): string[] { + const agentGraph = graph.builder.nodes[agentName]?.runnable as + | CompiledSwarmLike + | undefined; + expect(agentGraph).toBeDefined(); + const toolsNode = agentGraph!.builder.nodes["tools"]?.runnable as + | { tools?: Array<{ name?: unknown }> } + | undefined; + return (toolsNode?.tools ?? []).map((registered) => String(registered.name)); +} + +function twoAgentSwarm(overrides?: { + relationships?: [Record, Record][]; + handoff?: HandoffMode; +}): { swarm: Swarm; alice: Agent; bob: Agent } { + const alice = swarmAgent({ + name: "alice", + llmName: "alice_llm", + systemPrompt: "You are Alice. Hand off to bob when needed.", + }); + const bob = swarmAgent({ + name: "bob", + llmName: "bob_llm", + systemPrompt: "You are Bob.", + }); + const swarm = createSwarm({ + name: "SwarmTeam", + firstAgent: alice, + relationships: overrides?.relationships ?? [ + [alice, bob], + [bob, alice], + ], + ...(overrides?.handoff !== undefined ? { handoff: overrides.handoff } : {}), + }); + return { swarm, alice, bob }; +} + +describe("swarm loading", () => { + it("compiles to a swarm graph with one node per participating agent", async () => { + const { swarm } = twoAgentSwarm(); + const { graph } = await loadWithFakeLlms(swarm, { + alice_llm: [], + bob_llm: [], + }); + + expect(graph.lg_is_pregel).toBe(true); + expect(graph.name).toBe("SwarmTeam"); + const nodeNames = Object.keys(graph.builder.nodes); + expect(nodeNames).toContain("alice"); + expect(nodeNames).toContain("bob"); + // The swarm state tracks the active agent and routes off START. + expect(Object.keys(graph.builder.channels)).toContain("messages"); + expect(Object.keys(graph.builder.channels)).toContain("activeAgent"); + expect(graph.builder.branches[START]).toBeTruthy(); + }); + + it("injects a transfer_to_ handoff tool per relationship", async () => { + const { swarm } = twoAgentSwarm(); + const { graph } = await loadWithFakeLlms(swarm, { + alice_llm: [], + bob_llm: [], + }); + + expect(agentToolNames(graph, "alice")).toContain("transfer_to_bob"); + expect(agentToolNames(graph, "bob")).toContain("transfer_to_alice"); + // Handoff tools follow the relationship direction only. + expect(agentToolNames(graph, "alice")).not.toContain("transfer_to_alice"); + expect(agentToolNames(graph, "bob")).not.toContain("transfer_to_bob"); + }); + + it("injects no handoff tool against the relationship direction", async () => { + const alice = swarmAgent({ name: "alice", llmName: "alice_llm" }); + const bob = swarmAgent({ name: "bob", llmName: "bob_llm" }); + const swarm = createSwarm({ + name: "OneWaySwarm", + firstAgent: alice, + relationships: [[alice, bob]], + }); + const { graph } = await loadWithFakeLlms(swarm, { + alice_llm: [], + bob_llm: [], + }); + + expect(agentToolNames(graph, "alice")).toContain("transfer_to_bob"); + expect(agentToolNames(graph, "bob")).toEqual([]); + }); +}); + +describe("swarm execution", () => { + it("hands off between agents through the injected tools", async () => { + const { swarm } = twoAgentSwarm(); + const { graph, loader } = await loadWithFakeLlms(swarm, { + alice_llm: [toolCallMessage("transfer_to_bob", {}, "handoff_1")], + bob_llm: [new AIMessage("Hi, this is Bob.")], + }); + + const result = await graph.invoke( + { messages: [new HumanMessage("hello")] }, + threadConfig("swarm-1"), + ); + + const messages = result["messages"] as BaseMessage[]; + const finalMessage = messages[messages.length - 1]!; + expect(finalMessage.getType()).toBe("ai"); + expect(finalMessage.content).toBe("Hi, this is Bob."); + expect(result["activeAgent"]).toBe("bob"); + // Bob's model actually ran; Alice's ran exactly once (the handoff turn). + expect(loader.getFakeModel("bob_llm").calls.length).toBe(1); + expect(loader.getFakeModel("alice_llm").calls.length).toBe(1); + }); +}); + +describe("swarm conversion errors", () => { + it("rejects HandoffMode NEVER with the Python message", async () => { + const { swarm } = twoAgentSwarm({ handoff: HandoffMode.NEVER }); + const loader = new AgentSpecLoader(); + await expect(loader.loadComponent(swarm)).rejects.toThrow( + "Handoff mode NEVER is not supported for conversion in LangGraph adapter", + ); + }); + + it("rejects non-Agent participants", async () => { + const alice = swarmAgent({ name: "alice", llmName: "alice_llm" }); + const managerWorkers = createManagerWorkers({ + name: "SubTeam", + groupManager: swarmAgent({ name: "m", llmName: "m_llm" }), + workers: [swarmAgent({ name: "w", llmName: "w_llm" })], + }); + const swarm = createSwarm({ + name: "BadSwarm", + firstAgent: alice, + relationships: [[managerWorkers, alice]], + }); + + const loader = new AgentSpecLoader(); + await expect(loader.loadComponent(swarm)).rejects.toThrow( + /Only Agents are supported as part of a Swarm/, + ); + }); +}); diff --git a/tsagentspec/tests/adapters/langgraph/test-helpers.ts b/tsagentspec/tests/adapters/langgraph/test-helpers.ts new file mode 100644 index 00000000..69c43868 --- /dev/null +++ b/tsagentspec/tests/adapters/langgraph/test-helpers.ts @@ -0,0 +1,358 @@ +/** + * Shared test infrastructure for the LangGraph adapter test suite. + * + * Ports the mechanisms the Python tests rely on (see + * `pyagentspec/tests/adapters/langgraph/`): + * - `FakeToolCallingChatModel`: queued AIMessage responses consumed one per + * model call, with a self-returning `bindTools` so it drives `createAgent` + * tool loops (JS equivalent of `FakeMessagesListChatModel` + patched + * `bind_tools`). + * - `FakeLlmAgentSpecLoader` / `loadWithFakeLlm`: the converter injection + * seam — a loader whose converter overrides the protected + * `convertLlmConfig` hook (JS equivalent of patching + * `_llm_convert_to_langgraph`), supporting one fake for all LLM configs or + * one per LLM config name. + * - `installMockFetch`: global fetch stub for RemoteTool tests (JS equivalent + * of patching `httpx.request`). + * - Spec builder helpers (`makeLlmConfig`, `makeAgent`) and interrupt/resume + * helpers matching the Python test command shapes. + */ +import type { CallbackManagerForLLMRun } from "@langchain/core/callbacks/manager"; +import { + BaseChatModel, + type BaseChatModelParams, + type BindToolsInput, +} from "@langchain/core/language_models/chat_models"; +import { AIMessage, type BaseMessage } from "@langchain/core/messages"; +import type { ChatResult } from "@langchain/core/outputs"; +import { Command } from "@langchain/langgraph"; +import { + createAgent as createAgentSpecAgent, + createVllmConfig, +} from "../../../src/index.js"; +import type { + Agent, + ComponentBase, + LlmConfig, + Property, + Tool, + ToolBox, + VllmConfig, +} from "../../../src/index.js"; +import { + AgentSpecLoader, + type AgentSpecLoaderOptions, +} from "../../../src/adapters/langgraph/agentspec-loader.js"; +import { AgentSpecToLangGraphConverter } from "../../../src/adapters/langgraph/langgraph-converter.js"; + +/** + * Fake chat model returning queued AIMessages verbatim (tool_calls included). + * + * The queue index advances one message per `_generate` call and clamps on the + * last response. `bindTools` records the bound tools and returns `this`, which + * is what makes the fake work through `createAgent`'s binding flow. + */ +export class FakeToolCallingChatModel extends BaseChatModel { + responses: AIMessage[]; + idx = 0; + /** Tools bound by the agent (last `bindTools` call wins). */ + bound: BindToolsInput[] = []; + /** The message lists received by each `_generate` call. */ + calls: BaseMessage[][] = []; + + constructor(fields: { responses: AIMessage[] } & BaseChatModelParams) { + super(fields); + this.responses = fields.responses; + } + + _llmType(): string { + return "fake-tool-calling-chat-model"; + } + + bindTools(tools: BindToolsInput[]): this { + this.bound = tools; + return this; + } + + async _generate( + messages: BaseMessage[], + _options?: this["ParsedCallOptions"], + _runManager?: CallbackManagerForLLMRun, + ): Promise { + if (this.responses.length === 0) { + throw new Error("FakeToolCallingChatModel has no queued responses."); + } + this.calls.push(messages); + const message = this.responses[Math.min(this.idx, this.responses.length - 1)]!; + this.idx += 1; + return { + generations: [ + { + text: typeof message.content === "string" ? message.content : "", + message, + }, + ], + llmOutput: {}, + }; + } +} + +/** Build an AIMessage carrying a single tool call. */ +export function toolCallMessage( + name: string, + args: Record, + id = "call_1", +): AIMessage { + return new AIMessage({ + content: "", + tool_calls: [{ name, args, id, type: "tool_call" }], + }); +} + +/** Default LLM config used by the spec builder helpers. */ +export function makeLlmConfig(overrides?: { + name?: string; + url?: string; + modelId?: string; + id?: string; +}): VllmConfig { + return createVllmConfig({ + name: overrides?.name ?? "test-llm", + url: overrides?.url ?? "http://localhost:8000", + modelId: overrides?.modelId ?? "fake-model", + ...(overrides?.id !== undefined ? { id: overrides.id } : {}), + }); +} + +/** Build an Agent Spec Agent with sensible defaults for loader tests. */ +export function makeAgent(overrides?: { + name?: string; + systemPrompt?: string; + llmConfig?: LlmConfig; + tools?: Tool[]; + toolboxes?: ToolBox[]; + inputs?: Property[]; + outputs?: Property[]; +}): Agent { + return createAgentSpecAgent({ + name: overrides?.name ?? "test_agent", + systemPrompt: overrides?.systemPrompt ?? "You are a helpful agent.", + llmConfig: overrides?.llmConfig ?? makeLlmConfig(), + ...(overrides?.tools !== undefined ? { tools: overrides.tools } : {}), + ...(overrides?.toolboxes !== undefined + ? { toolboxes: overrides.toolboxes } + : {}), + ...(overrides?.inputs !== undefined ? { inputs: overrides.inputs } : {}), + ...(overrides?.outputs !== undefined ? { outputs: overrides.outputs } : {}), + }); +} + +/** + * Fake responses source for `FakeLlmAgentSpecLoader`: + * - a single response queue shared by every LLM config, + * - a per-LLM-config-name map of response queues, + * - or a factory receiving the LLM config and returning any chat model. + */ +export type FakeLlmResponses = + | AIMessage[] + | Record + | ((llmConfig: LlmConfig) => unknown); + +/** Converter whose protected LLM hook delegates to the fake loader. */ +class FakeLlmConverter extends AgentSpecToLangGraphConverter { + constructor(private readonly loader: FakeLlmAgentSpecLoader) { + super(); + } + + protected override async convertLlmConfig( + llmConfig: LlmConfig, + ): Promise { + return this.loader.resolveFakeModel(llmConfig); + } +} + +/** + * AgentSpecLoader whose converter substitutes fake chat models for every LLM + * config — the TS equivalent of patching `_llm_convert_to_langgraph` in the + * Python tests. Fakes are cached per LLM config name for later inspection. + */ +export class FakeLlmAgentSpecLoader extends AgentSpecLoader { + /** Fake models created so far, keyed by LLM config name. */ + readonly fakeModels = new Map(); + /** Every LLM config routed through the conversion seam, in order. */ + readonly convertedLlmConfigs: LlmConfig[] = []; + private readonly responses: FakeLlmResponses; + + constructor(responses: FakeLlmResponses, options?: AgentSpecLoaderOptions) { + super(options); + this.responses = responses; + } + + override get agentspecToRuntimeConverter(): AgentSpecToLangGraphConverter { + return new FakeLlmConverter(this); + } + + /** Resolve (creating and caching if needed) the fake model for a config. */ + resolveFakeModel(llmConfig: LlmConfig): unknown { + this.convertedLlmConfigs.push(llmConfig); + if (typeof this.responses === "function") { + return this.responses(llmConfig); + } + const responseQueue = Array.isArray(this.responses) + ? this.responses + : this.responses[llmConfig.name]; + if (responseQueue === undefined) { + throw new Error( + `No fake responses configured for LLM config '${llmConfig.name}'.`, + ); + } + let model = this.fakeModels.get(llmConfig.name); + if (model === undefined) { + model = new FakeToolCallingChatModel({ responses: responseQueue }); + this.fakeModels.set(llmConfig.name, model); + } + return model; + } + + /** The single created fake model, or the one for the given config name. */ + getFakeModel(llmName?: string): FakeToolCallingChatModel { + if (llmName !== undefined) { + const model = this.fakeModels.get(llmName); + if (model === undefined) { + throw new Error(`No fake model was created for LLM config '${llmName}'.`); + } + return model; + } + const models = [...this.fakeModels.values()]; + if (models.length !== 1) { + throw new Error( + `Expected exactly one fake model, found ${models.length}. Pass the LLM config name.`, + ); + } + return models[0]!; + } +} + +/** Structural surface of a loaded langchain ReactAgent used by the tests. */ +export interface LoadedReactAgent { + options: { + name?: string; + systemPrompt?: string; + middleware?: unknown[]; + responseFormat?: unknown; + tools?: unknown[]; + [key: string]: unknown; + }; + graph: { + lg_is_pregel?: boolean; + name?: string; + getName(): string; + builder: { + nodes: Record; + channels: Record; + }; + }; + invoke(input: unknown, config?: unknown): Promise>; +} + +/** + * Load an in-memory Agent Spec component with fake LLMs injected at the + * converter seam. Returns the loaded runtime object (typed as a react agent + * for convenience) together with the loader for fake-model inspection. + */ +export async function loadWithFakeLlm( + spec: ComponentBase, + responses: FakeLlmResponses, + options?: AgentSpecLoaderOptions, +): Promise<{ agent: LoadedReactAgent; loader: FakeLlmAgentSpecLoader }> { + const loader = new FakeLlmAgentSpecLoader(responses, options); + const agent = (await loader.loadComponent(spec)) as LoadedReactAgent; + return { agent, loader }; +} + +/** One recorded call observed by the mock fetch installed by `installMockFetch`. */ +export interface RecordedFetchCall { + url: string; + init: RequestInit | undefined; +} + +/** Controller returned by `installMockFetch`. */ +export interface MockFetchController { + /** The calls received so far, in order. */ + calls: RecordedFetchCall[]; + /** Restore the original global fetch. */ + restore(): void; +} + +/** + * Replace `globalThis.fetch` with a recording mock for RemoteTool tests. + * + * The handler receives the URL and request init; it may return a `Response` + * directly, or any JSON-able value (sync or async) which is wrapped in a 200 + * JSON response. Always call `restore()` (e.g. in `afterEach`/`finally`). + */ +export function installMockFetch( + handler: (url: string, init?: RequestInit) => unknown, +): MockFetchController { + const originalFetch = globalThis.fetch; + const calls: RecordedFetchCall[] = []; + const mockedFetch = async ( + input: string | URL | Request, + init?: RequestInit, + ): Promise => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + calls.push({ url, init }); + const result = await handler(url, init); + if (result instanceof Response) { + return result; + } + return new Response(JSON.stringify(result), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + globalThis.fetch = mockedFetch as typeof fetch; + return { + calls, + restore(): void { + globalThis.fetch = originalFetch; + }, + }; +} + +/** RunnableConfig pinning a thread id (checkpointed runs). */ +export function threadConfig( + threadId: string, +): { configurable: { thread_id: string } } { + return { configurable: { thread_id: threadId } }; +} + +/** The `__interrupt__` entries of an invoke result (empty when none). */ +export function getInterrupts( + result: Record, +): Array<{ id?: string; value?: unknown }> { + return ( + (result["__interrupt__"] as Array<{ id?: string; value?: unknown }>) ?? [] + ); +} + +/** Resume command approving a pending tool confirmation. */ +export function approveCommand(): Command { + return new Command({ resume: { decisions: [{ type: "approve" }] } }); +} + +/** Resume command rejecting a pending tool confirmation. */ +export function rejectCommand(reason?: string): Command { + return new Command({ + resume: { + decisions: [ + { type: "reject", ...(reason !== undefined ? { reason } : {}) }, + ], + }, + }); +} diff --git a/tsagentspec/tsup.config.ts b/tsagentspec/tsup.config.ts index 96cb009c..f1398349 100644 --- a/tsagentspec/tsup.config.ts +++ b/tsagentspec/tsup.config.ts @@ -1,7 +1,10 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: ["src/index.ts"], + entry: { + index: "src/index.ts", + "adapters/langgraph/index": "src/adapters/langgraph/index.ts", + }, format: ["cjs", "esm"], dts: true, splitting: false, From 97c2f3ae9c4f7b73bb5283842c6f2818a109fcdf Mon Sep 17 00:00:00 2001 From: Salah Date: Thu, 3 Sep 2026 18:59:01 +0400 Subject: [PATCH 02/14] refactor(tsagentspec/adapters): unify HTTP request assembly and split node execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tsagentspec/src/adapters/common/index.ts | 3 + .../src/adapters/common/tools-common.ts | 228 +-- .../adapters/langgraph/langgraph-converter.ts | 2 +- .../src/adapters/langgraph/node-execution.ts | 1266 +---------------- .../langgraph/node-execution/agent-node.ts | 136 ++ .../langgraph/node-execution/api-node.ts | 77 + .../langgraph/node-execution/basic-nodes.ts | 195 +++ .../langgraph/node-execution/executor.ts | 162 +++ .../langgraph/node-execution/llm-node.ts | 128 ++ .../langgraph/node-execution/python-parity.ts | 160 +++ .../langgraph/node-execution/subflow-nodes.ts | 244 ++++ .../langgraph/node-execution/tool-node.ts | 167 +++ .../adapters/langgraph/flow-nodes.test.ts | 63 + 13 files changed, 1506 insertions(+), 1325 deletions(-) create mode 100644 tsagentspec/src/adapters/langgraph/node-execution/agent-node.ts create mode 100644 tsagentspec/src/adapters/langgraph/node-execution/api-node.ts create mode 100644 tsagentspec/src/adapters/langgraph/node-execution/basic-nodes.ts create mode 100644 tsagentspec/src/adapters/langgraph/node-execution/executor.ts create mode 100644 tsagentspec/src/adapters/langgraph/node-execution/llm-node.ts create mode 100644 tsagentspec/src/adapters/langgraph/node-execution/python-parity.ts create mode 100644 tsagentspec/src/adapters/langgraph/node-execution/subflow-nodes.ts create mode 100644 tsagentspec/src/adapters/langgraph/node-execution/tool-node.ts diff --git a/tsagentspec/src/adapters/common/index.ts b/tsagentspec/src/adapters/common/index.ts index 365ff3c9..01202bb6 100644 --- a/tsagentspec/src/adapters/common/index.ts +++ b/tsagentspec/src/adapters/common/index.ts @@ -29,8 +29,11 @@ export { } from "./json-schema.js"; export { DEFAULT_HTTP_REQUEST_TIMEOUT_MS, + buildTemplatedHttpRequest, createRemoteToolFunc, fetchWithAdapterDefaults, + renderRecord, + type TemplatedHttpRequestSpec, } from "./tools-common.js"; export type { AgentSpecToRuntimeConverter, diff --git a/tsagentspec/src/adapters/common/tools-common.ts b/tsagentspec/src/adapters/common/tools-common.ts index 13e0fdbd..bc5e3289 100644 --- a/tsagentspec/src/adapters/common/tools-common.ts +++ b/tsagentspec/src/adapters/common/tools-common.ts @@ -1,6 +1,9 @@ /** - * Shared RemoteTool execution helper. Port of - * `pyagentspec.adapters._tools_common._create_remote_tool_func`. + * Shared templated-HTTP-request assembly and RemoteTool execution helpers. + * Port of `pyagentspec.adapters._tools_common._create_remote_tool_func`; the + * request assembly (`buildTemplatedHttpRequest`) is also the one Python + * spells out a second time in `ApiNodeExecutor` (`_node_execution.py`) and is + * shared here with the LangGraph ApiNode executor. * * Divergences from Python (see the adapter README): * - The TS SDK RemoteTool has no `retryPolicy`, so a single fetch attempt is @@ -11,7 +14,7 @@ * helpers are invoked with `undefined` (i.e. allow) and the templated-URL * warning fires per the Python rules. * - `fetch` forbids request bodies on GET/HEAD, so no body is sent for those - * methods. + * methods (reported via `bodyDropped`). * * Python-parity network behavior (NOT divergences): redirects are not * followed and requests time out after `DEFAULT_HTTP_REQUEST_TIMEOUT_MS`, @@ -86,7 +89,12 @@ export async function fetchWithAdapterDefaults( } } -function renderRecord( +/** + * Render `{{placeholder}}` templates in both the keys and the values of a + * record (header/query-param maps), like Python's dict comprehensions over + * `render_template(k)` / `render_nested_object_template(v)`. + */ +export function renderRecord( record: Record, kwargs: Record, ): Record { @@ -100,6 +108,136 @@ function renderRecord( return rendered; } +/** + * The structural surface shared by the AgentSpec `RemoteTool` and `ApiNode` + * components: a templated HTTP request specification. + */ +export interface TemplatedHttpRequestSpec { + url: string; + httpMethod: string; + data?: unknown; + headers: Record; + queryParams: Record; +} + +/** + * Assemble one HTTP request from a templated spec and the call inputs: + * renders `{{placeholder}}` templates in the URL, data, headers and query + * parameters, stringifies header values, validates the rendered URL against + * the allow list (a seam — the TS SDK has no `urlAllowList` field yet, so + * this always allows), encodes the body (an urlencoded form for dict data + * under an urlencoded content type, raw strings/bytes verbatim, JSON + * otherwise — adding the JSON content type unless the caller set one), and + * appends the rendered query parameters to the URL. + * + * Mirrors the request assembly Python spells out identically in + * `_create_remote_tool_func` (`_tools_common.py`) and `ApiNodeExecutor` + * (`_node_execution.py`). + * + * `bodyDropped` reports the one fetch-forced divergence: `fetch` forbids + * request bodies on GET/HEAD (Python's httpx sends them), so declared + * non-empty data is not sent for those methods and the flag is returned for + * the caller to surface (the ApiNode executor warns; the RemoteTool path + * keeps Python's silence). + */ +export function buildTemplatedHttpRequest( + spec: TemplatedHttpRequestSpec, + inputs: Record, +): { url: string; init: RequestInit; bodyDropped: boolean } { + const renderedData = renderNestedObjectTemplate(spec.data, inputs); + const renderedHeaders = renderRecord(spec.headers, inputs); + const renderedQueryParams = renderRecord(spec.queryParams, inputs); + const renderedUrl = renderTemplate(spec.url, inputs); + + // Falsy (`||`) coalescing on purpose, matching Python's + // `headers.get("Content-Type") or headers.get("content-type")`: an + // empty-string `Content-Type` falls through to the lowercase header. + const contentTypeHeader = + renderedHeaders["Content-Type"] || renderedHeaders["content-type"]; + const expectUrlencodedFormData = + typeof contentTypeHeader === "string" && + contentTypeHeader.includes("application/x-www-form-urlencoded"); + + const requestHeaders: Record = {}; + for (const [key, value] of Object.entries(renderedHeaders)) { + requestHeaders[key] = + typeof value === "string" ? value : stringifyTemplateValue(value); + } + const callerSetContentType = Object.keys(requestHeaders).some( + (key) => key.toLowerCase() === "content-type", + ); + + const method = spec.httpMethod; + const methodUpper = method.toUpperCase(); + // fetch forbids request bodies on GET/HEAD (Python's httpx sends them). + const methodAllowsBody = methodUpper !== "GET" && methodUpper !== "HEAD"; + const hasDeclaredBody = + renderedData !== undefined && + renderedData !== null && + renderedData !== "" && + !(isPlainRecord(renderedData) && Object.keys(renderedData).length === 0); + + let body: string | URLSearchParams | Uint8Array | undefined; + if (methodAllowsBody) { + if (expectUrlencodedFormData && isPlainRecord(renderedData)) { + const form = new URLSearchParams(); + for (const [key, value] of Object.entries(renderedData)) { + form.append( + key, + typeof value === "string" ? value : stringifyTemplateValue(value), + ); + } + body = form; + } else if (typeof renderedData === "string") { + body = renderedData; + } else if (renderedData instanceof Uint8Array) { + body = renderedData; + } else if (renderedData !== undefined && renderedData !== null) { + body = JSON.stringify(renderedData); + if (!callerSetContentType) { + requestHeaders["Content-Type"] = "application/json"; + } + } + } + + // Kept as the seam for allow-list enforcement: neither the TS SDK + // RemoteTool nor the ApiNode has a urlAllowList field yet, so this always + // allows. + validateUrlAgainstAllowList(renderedUrl, undefined); + + const searchParams = new URLSearchParams(); + for (const [key, value] of Object.entries(renderedQueryParams)) { + if (Array.isArray(value)) { + for (const item of value) { + searchParams.append( + key, + item == null ? "" : stringifyTemplateValue(item), + ); + } + } else { + searchParams.append( + key, + value == null ? "" : stringifyTemplateValue(value), + ); + } + } + const query = searchParams.toString(); + const requestUrl = + query.length > 0 + ? `${renderedUrl}${renderedUrl.includes("?") ? "&" : "?"}${query}` + : renderedUrl; + + return { + url: requestUrl, + init: { + method, + headers: requestHeaders, + ...(body !== undefined ? { body } : {}), + }, + bodyDropped: !methodAllowsBody && hasDeclaredBody, + }; +} + /** * Create the execution function for an AgentSpec RemoteTool. * @@ -122,86 +260,10 @@ export function createRemoteToolFunc( return async function remoteToolFunc( kwargs: Record, ): Promise { - const remoteToolData = renderNestedObjectTemplate(remoteTool.data, kwargs); - const remoteToolHeaders = renderRecord(remoteTool.headers, kwargs); - const remoteToolQueryParams = renderRecord(remoteTool.queryParams, kwargs); - const remoteToolUrl = renderTemplate(remoteTool.url, kwargs); - - const contentTypeHeader = - remoteToolHeaders["Content-Type"] || remoteToolHeaders["content-type"]; - const expectUrlencodedFormData = - typeof contentTypeHeader === "string" && - contentTypeHeader.includes("application/x-www-form-urlencoded"); - - const requestHeaders: Record = {}; - for (const [key, value] of Object.entries(remoteToolHeaders)) { - requestHeaders[key] = - typeof value === "string" ? value : stringifyTemplateValue(value); - } - const callerSetContentType = Object.keys(requestHeaders).some( - (key) => key.toLowerCase() === "content-type", - ); - - const method = remoteTool.httpMethod; - const methodUpper = method.toUpperCase(); - const methodAllowsBody = methodUpper !== "GET" && methodUpper !== "HEAD"; - - let body: string | URLSearchParams | Uint8Array | undefined; - if (methodAllowsBody) { - if (expectUrlencodedFormData && isPlainRecord(remoteToolData)) { - const form = new URLSearchParams(); - for (const [key, value] of Object.entries(remoteToolData)) { - form.append( - key, - typeof value === "string" ? value : stringifyTemplateValue(value), - ); - } - body = form; - } else if (typeof remoteToolData === "string") { - body = remoteToolData; - } else if (remoteToolData instanceof Uint8Array) { - body = remoteToolData; - } else if (remoteToolData !== undefined && remoteToolData !== null) { - body = JSON.stringify(remoteToolData); - if (!callerSetContentType) { - requestHeaders["Content-Type"] = "application/json"; - } - } - } - - // Kept as the seam for allow-list enforcement: the TS SDK RemoteTool has - // no urlAllowList field yet, so this always allows. - validateUrlAgainstAllowList(remoteToolUrl, undefined); - - const searchParams = new URLSearchParams(); - for (const [key, value] of Object.entries(remoteToolQueryParams)) { - if (Array.isArray(value)) { - for (const item of value) { - searchParams.append( - key, - item == null ? "" : stringifyTemplateValue(item), - ); - } - } else { - searchParams.append( - key, - value == null ? "" : stringifyTemplateValue(value), - ); - } - } - const query = searchParams.toString(); - const requestUrl = - query.length > 0 - ? `${remoteToolUrl}${remoteToolUrl.includes("?") ? "&" : "?"}${query}` - : remoteToolUrl; - + const { url, init } = buildTemplatedHttpRequest(remoteTool, kwargs); const response = await fetchWithAdapterDefaults( - requestUrl, - { - method, - headers: requestHeaders, - ...(body !== undefined ? { body } : {}), - }, + url, + init, `RemoteTool \`${remoteTool.name}\``, ); // Python (with no retry policy — the only state the TS RemoteTool can diff --git a/tsagentspec/src/adapters/langgraph/langgraph-converter.ts b/tsagentspec/src/adapters/langgraph/langgraph-converter.ts index 5c740e65..9886f465 100644 --- a/tsagentspec/src/adapters/langgraph/langgraph-converter.ts +++ b/tsagentspec/src/adapters/langgraph/langgraph-converter.ts @@ -873,7 +873,7 @@ export class AgentSpecToLangGraphConverter { if (!isCompiledGraph(subflow)) { throw new Error("MapNodeExecutor can only be initialized with MapNode"); } - return new MapNodeExecutor(node, subflow, context.config); + return new MapNodeExecutor(node, subflow); } default: throw new Error( diff --git a/tsagentspec/src/adapters/langgraph/node-execution.ts b/tsagentspec/src/adapters/langgraph/node-execution.ts index d1636a3c..319e0e55 100644 --- a/tsagentspec/src/adapters/langgraph/node-execution.ts +++ b/tsagentspec/src/adapters/langgraph/node-execution.ts @@ -6,1244 +6,28 @@ * inputs, executing the node, and folding outputs / routing details back into * the state. * - * Runtime contracts (state keys, branch names, interrupt payloads, - * error-message text) mirror the Python adapter exactly so specs behave the - * same across both SDKs. - * - * Divergences from Python (see the adapter README): - * - Execution is async-only (no sync `__call__` / thread offloading). - * - Executors never mutate the incoming state: they return updated copies - * with the same accumulate semantics as Python's in-place mutation. - * - Executors receive their collaborators from the converter (converted - * tools, chat models, compiled subgraphs, agent compile factories) instead - * of importing the converter, so there are no module cycles. - * - Node execution spans/events are not emitted (tracing is a no-op seam). - * - JS has no tuple type: arrays map positionally onto multiple declared - * tool-node outputs where Python only accepts tuples. - * - The react-agent invoke payload adds no `remaining_steps` / - * `structured_response` keys: the langchain JS agent state has neither - * channel (structured output lands in `structuredResponse`). - */ -import type { BaseMessage } from "@langchain/core/messages"; -import type { RunnableConfig } from "@langchain/core/runnables"; -import { addMessages, interrupt } from "@langchain/langgraph"; -import type { - AgentNode, - ApiNode, - BranchingNode, - CatchExceptionNode, - EndNode, - FlowNode, - InputMessageNode, - LlmNode, - MapNode, - OutputMessageNode, - StartNode, - ToolNode, -} from "../../flows/index.js"; -import { - CAUGHT_EXCEPTION_BRANCH, - DEFAULT_BRANCH, - DEFAULT_INPUT_MESSAGE_OUTPUT, - DEFAULT_NEXT_BRANCH, -} from "../../flows/index.js"; -import type { DataFlowEdge } from "../../flows/index.js"; -import type { Property } from "../../property.js"; -import { - fetchWithAdapterDefaults, - maybeWarnAboutUnrestrictedTemplatedUrl, - renderNestedObjectTemplate, - renderTemplate, - stringifyTemplateValue, - validateUrlAgainstAllowList, -} from "../common/index.js"; -import type { - ExecuteOutput, - FlowState, - NextNodeInputs, - NodeExecutionDetails, - NodeOutputs, -} from "./types.js"; - -/** The structural surface of an Agent Spec flow node used by the executors. */ -interface FlowNodeLike { - id: string; - name: string; - inputs?: Property[]; - outputs?: Property[]; -} - -/** A compiled graph / react agent surface: everything invocable. */ -interface InvocableGraph { - invoke( - input: unknown, - config?: RunnableConfig, - ): Promise>; -} - -function isPlainRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -/** Serialize one string the way Python's `json.dumps` does (ensure_ascii). */ -function pythonJsonDumpsString(value: string): string { - let out = '"'; - for (const ch of value) { - const code = ch.codePointAt(0)!; - if (ch === '"') out += '\\"'; - else if (ch === "\\") out += "\\\\"; - else if (ch === "\b") out += "\\b"; - else if (ch === "\f") out += "\\f"; - else if (ch === "\n") out += "\\n"; - else if (ch === "\r") out += "\\r"; - else if (ch === "\t") out += "\\t"; - else if (code < 0x20 || code > 0x7e) { - if (code > 0xffff) { - // ensure_ascii escapes astral characters as a surrogate pair. - const high = 0xd800 + ((code - 0x10000) >> 10); - const low = 0xdc00 + ((code - 0x10000) & 0x3ff); - out += `\\u${high.toString(16).padStart(4, "0")}`; - out += `\\u${low.toString(16).padStart(4, "0")}`; - } else { - out += `\\u${code.toString(16).padStart(4, "0")}`; - } - } else out += ch; - } - return out + '"'; -} - -/** - * Serialize a value the way Python's `json.dumps` does with its default - * arguments: `", "` / `": "` separators, ensure_ascii `\uXXXX` escapes, and - * `Infinity`/`-Infinity`/`NaN` literals (allow_nan). Used when casting - * non-string values into `string`-typed properties so the resulting flow - * state text matches the Python adapter byte-for-byte. - */ -export function pythonJsonDumps(value: unknown): string { - if (value === null || value === undefined) return "null"; - if (typeof value === "boolean") return value ? "true" : "false"; - if (typeof value === "number") { - if (Number.isFinite(value)) return JSON.stringify(value); - if (value === Infinity) return "Infinity"; - if (value === -Infinity) return "-Infinity"; - return "NaN"; - } - if (typeof value === "string") return pythonJsonDumpsString(value); - if (Array.isArray(value)) { - return `[${value.map((item) => pythonJsonDumps(item)).join(", ")}]`; - } - if (typeof value === "object") { - const entries = Object.entries(value as Record) - .filter(([, v]) => v !== undefined && typeof v !== "function") - .map(([k, v]) => `${pythonJsonDumpsString(k)}: ${pythonJsonDumps(v)}`); - return `{${entries.join(", ")}}`; - } - return JSON.stringify(value) ?? "null"; -} - -/** Digit run with Python's underscore separators (`1_000`, not `1__0`). */ -const PY_DIGITS = String.raw`\d(?:_?\d)*`; - -/** Python `int()` string grammar: optional sign + underscore-separated digits. */ -const PYTHON_INT_REGEXP = new RegExp(`^[+-]?${PY_DIGITS}$`); - -/** Python `float()` numeric grammar (decimal/scientific, no hex/binary/octal). */ -const PYTHON_FLOAT_REGEXP = new RegExp( - `^[+-]?(?:(?:${PY_DIGITS})?\\.${PY_DIGITS}|${PY_DIGITS}\\.?)(?:[eE][+-]?${PY_DIGITS})?$`, -); - -/** - * Parse a (trimmed) string with Python `float()` semantics: decimal and - * scientific forms plus `inf`/`infinity`/`nan` (any case, optional sign) and - * underscore digit separators. Returns `undefined` for anything Python's - * `float()` rejects (hex/binary/octal literals, `1__0`, empty strings, ...). - */ -function parsePythonFloat(text: string): number | undefined { - const unsigned = text.toLowerCase().replace(/^[+-]/, ""); - if (unsigned === "inf" || unsigned === "infinity") { - return text.startsWith("-") ? -Infinity : Infinity; - } - if (unsigned === "nan") return NaN; - if (!PYTHON_FLOAT_REGEXP.test(text)) return undefined; - const parsed = Number(text.replace(/_/g, "")); - return Number.isNaN(parsed) ? undefined : parsed; -} - -/** - * Cast the given values to the types declared by the properties and add - * missing defaults, mirroring Python's `_cast_values_and_add_defaults`: - * non-strings are `json.dumps`-serialized into `string` properties, numbers - * become booleans, numeric strings parse into `integer`/`number` properties - * (an unparsable integer string raises like Python's `int()`; an unparsable - * number string is left as-is like Python's swallowed `float()` error), and - * a property with neither value nor default raises. Values for undeclared - * properties are dropped. - */ -export function castValuesAndAddDefaults( - valuesDict: Record, - properties: Property[], - nodeName: string, -): NodeOutputs { - const resultsDict: NodeOutputs = {}; - for (const property of properties) { - const key = property.title; - if (Object.hasOwn(valuesDict, key)) { - let value = valuesDict[key]; - const propertyType = property.type; - if (propertyType === "string" && typeof value !== "string") { - value = pythonJsonDumps(value); - } else if (propertyType === "boolean" && typeof value === "number") { - value = Boolean(value); - } else if (propertyType === "integer" && typeof value === "boolean") { - value = value ? 1 : 0; - } else if (propertyType === "integer" && typeof value === "number") { - value = Math.trunc(value); - } else if (propertyType === "integer" && typeof value === "string") { - // Python does `int(value.strip())` and re-raises for any unparsable - // string (its error-message guard never matches `int()`'s text), so - // an unparsable integer string aborts the flow here too. - const trimmed = value.trim(); - if (PYTHON_INT_REGEXP.test(trimmed)) { - value = parseInt(trimmed.replace(/_/g, ""), 10); - } else { - // Python raises ValueError with this exact message (repr'd value). - throw new Error( - `invalid literal for int() with base 10: ${JSON.stringify(trimmed)}`, - ); - } - } else if (propertyType === "number" && typeof value === "boolean") { - value = value ? 1 : 0; - } else if (propertyType === "number" && typeof value === "string") { - // Try converting numeric strings to floats with Python `float()` - // semantics; if the parse fails, leave the string as-is (Python - // swallows the `could not convert string to float:` error). - const parsed = parsePythonFloat(value.trim()); - if (parsed !== undefined) { - value = parsed; - } - } - resultsDict[key] = value; - } else if (property.default !== undefined) { - resultsDict[key] = property.default; - } else { - throw new Error( - `Expected node \`${nodeName}\` to have a value ` + - `for property \`${property.title}\`, but none was found.`, - ); - } - } - return resultsDict; -} - -/** - * Extract the outputs of an agent invoke result for the expected output - * properties, merging (in increasing priority) property defaults, the - * structured response, and top-level result entries. Reads the langchain JS - * `structuredResponse` key, falling back to Python's `structured_response`. - */ -export function extractOutputsFromInvokeResult( - result: Record, - expectedOutputs: Property[], -): NodeOutputs { - const outputs: NodeOutputs = {}; - for (const output of expectedOutputs) { - if (output.default !== undefined) { - outputs[output.title] = output.default; - } - } - const structuredResponse = - result["structuredResponse"] ?? result["structured_response"]; - if (isPlainRecord(structuredResponse)) { - Object.assign(outputs, structuredResponse); - } - for (const output of expectedOutputs) { - if (Object.hasOwn(result, output.title)) { - outputs[output.title] = result[output.title]; - } - } - return outputs; -} - -/** - * Base class of the flow node executors. - * - * `call` is the LangGraph node function: it selects this node's pending - * inputs from the state, casts them against the declared input properties, - * executes the node, and returns the updated flow state (accumulated inputs - * routing table, cast outputs, merged messages and execution details). - */ -export abstract class NodeExecutor< - TNode extends FlowNodeLike = FlowNodeLike, -> { - protected readonly node: TNode; - protected readonly edges: DataFlowEdge[] = []; - - constructor(node: TNode) { - this.node = node; - } - - /** Attach a data-flow edge whose source is this node. */ - attachEdge(edge: DataFlowEdge): void { - this.edges.push(edge); - } - - /** Execute this node against the current flow state (LangGraph node fn). */ - async call(state: FlowState, _config?: RunnableConfig): Promise { - const inputs = this.getInputs(state); - const [outputs, executionDetails] = await this._execute( - inputs, - state.messages ?? [], - ); - return this.updateStatus(outputs, executionDetails, state); - } - - /** Execute the node with the given cast inputs; returns outputs + details. */ - protected abstract _execute( - inputs: NodeOutputs, - messages: BaseMessage[], - ): Promise; - - /** - * Retrieve the inputs for this node (the `state.inputs` entries keyed by - * this node's id), adding default values when missing and casting to the - * declared types. - */ - protected getInputs(state: FlowState): NodeOutputs { - const nodeInputs = state.inputs?.[this.node.id]; - const ioInputs: Record = isPlainRecord(nodeInputs) - ? { ...nodeInputs } - : {}; - return castValuesAndAddDefaults( - ioInputs, - this.node.inputs ?? [], - this.node.name, - ); - } - - /** - * Fold the node outputs and execution details into the flow state: cast the - * outputs, route them along the attached data-flow edges into the pending - * inputs of downstream nodes (accumulating into a copy of the previous - * routing table), default the execution details, and merge generated - * messages via LangGraph's `addMessages`. - */ - protected updateStatus( - outputs: NodeOutputs, - executionDetails: NodeExecutionDetails, - previousState: FlowState, - ): FlowState { - const castOutputs = castValuesAndAddDefaults( - outputs, - this.node.outputs ?? [], - this.node.name, - ); - const nextNodeInputs: NextNodeInputs = { ...(previousState.inputs ?? {}) }; - for (const edge of this.edges) { - const destinationNodeId = String(edge.destinationNode["id"]); - const existing = nextNodeInputs[destinationNodeId]; - const destinationInputs: Record = isPlainRecord(existing) - ? { ...existing } - : {}; - if (!Object.hasOwn(castOutputs, edge.sourceOutput)) { - // Python raises a bare KeyError here. - throw new Error( - `Node \`${this.node.name}\` produced no output ` + - `\`${edge.sourceOutput}\` required by data-flow edge \`${edge.name}\`.`, - ); - } - destinationInputs[edge.destinationInput] = castOutputs[edge.sourceOutput]; - nextNodeInputs[destinationNodeId] = destinationInputs; - } - - const details: NodeExecutionDetails = { - branch: executionDetails.branch ?? DEFAULT_NEXT_BRANCH, - generated_messages: executionDetails.generated_messages ?? [], - should_finish: executionDetails.should_finish ?? false, - }; - return { - inputs: nextNodeInputs, - outputs: castOutputs, - messages: addMessages( - previousState.messages ?? [], - details.generated_messages ?? [], - ), - node_execution_details: details, - }; - } -} - -/** - * Executes a StartNode: consumes the flow-level invocation inputs (plain - * string keys at the top level of `state.inputs`) and passes them through as - * outputs, flowing to downstream nodes along the data edges. - */ -export class StartNodeExecutor extends NodeExecutor { - protected override getInputs(state: FlowState): NodeOutputs { - // At StartNode time the state inputs hold the flow's initial call inputs - // as plain `{inputName: value}` keys (no node-id nesting): consume all of - // them (they are removed from the state in updateStatus below). - const ioInputs: Record = { ...(state.inputs ?? {}) }; - return castValuesAndAddDefaults( - ioInputs, - this.node.inputs ?? [], - this.node.name, - ); - } - - protected override updateStatus( - outputs: NodeOutputs, - executionDetails: NodeExecutionDetails, - previousState: FlowState, - ): FlowState { - // Python pops the consumed flow-level inputs out of the state; the - // non-mutating equivalent is starting the routing table from scratch. - return super.updateStatus(outputs, executionDetails, { - ...previousState, - inputs: {}, - }); - } - - protected async _execute( - inputs: NodeOutputs, - _messages: BaseMessage[], - ): Promise { - return [inputs, {}]; - } -} - -/** - * Executes an EndNode: passes its inputs through as outputs, reshapes them to - * the flow's declared outputs, and marks the run finished on the node's - * branch. - */ -export class EndNodeExecutor extends NodeExecutor { - private flowOutputs: Property[] = []; - - /** Give the executor the flow outputs used to reshape the final state. */ - setFlowOutputs(flowOutputs: Property[]): void { - this.flowOutputs = flowOutputs; - } - - protected async _execute( - inputs: NodeOutputs, - _messages: BaseMessage[], - ): Promise { - return [inputs, { branch: this.node.branchName, should_finish: true }]; - } - - protected override updateStatus( - outputs: NodeOutputs, - executionDetails: NodeExecutionDetails, - previousState: FlowState, - ): FlowState { - const newState = super.updateStatus( - outputs, - executionDetails, - previousState, - ); - const nodeOutputs = newState.outputs; - const filteredOutputs: NodeOutputs = {}; - for (const property of this.flowOutputs) { - filteredOutputs[property.title] = Object.hasOwn( - nodeOutputs, - property.title, - ) - ? nodeOutputs[property.title] - : property.default; - } - for (const [propertyName, propertyValue] of Object.entries(nodeOutputs)) { - if (propertyValue === undefined) { - throw new Error( - `EndNode \`${this.node.name}\` exited without any value generated for property \`${propertyName}\``, - ); - } - } - return { ...newState, outputs: filteredOutputs }; - } -} - -/** - * Executes a BranchingNode: reads its first input and selects the branch its - * mapping points to (the `default` branch when the value is unmapped). - */ -export class BranchingNodeExecutor extends NodeExecutor { - constructor(node: BranchingNode) { - super(node); - if (!node.inputs || node.inputs.length === 0) { - throw new Error("BranchingNode requires at least one input"); - } - } - - protected async _execute( - inputs: NodeOutputs, - _messages: BaseMessage[], - ): Promise { - const nodeInputs = this.node.inputs ?? []; - const inputBranchPropTitle = nodeInputs[0]!.title; - const inputBranchName = Object.hasOwn(inputs, inputBranchPropTitle) - ? inputs[inputBranchPropTitle] - : DEFAULT_BRANCH; - const selectedBranch = - typeof inputBranchName === "string" && - Object.hasOwn(this.node.mapping, inputBranchName) - ? this.node.mapping[inputBranchName]! - : DEFAULT_BRANCH; - return [{}, { branch: selectedBranch }]; - } -} - -/** True for a list of MCP-style content blocks (text / image / file). */ -function isMcpContentBlocksList(items: unknown[]): boolean { - // Empty lists are ambiguous; treat them as non-MCP to avoid false positives - if (items.length === 0) { - return false; - } - for (const element of items) { - if (!isPlainRecord(element)) { - return false; - } - const blockType = element["type"]; - if (blockType !== "text" && blockType !== "image" && blockType !== "file") { - return false; - } - if (blockType === "text") { - if (typeof element["text"] !== "string") { - return false; - } - } else if ( - !("base64" in element) && - !("url" in element) && - !("file_id" in element) - ) { - return false; - } - } - return true; -} - -/** Extract the payload of one MCP content block. */ -function extractValueFromContentBlock(block: Record): unknown { - const blockType = block["type"]; - if (blockType === "text") { - return block["text"]; - } - if (blockType === "image" || blockType === "file") { - if ("base64" in block) { - return block["base64"]; - } - if ("url" in block) { - return block["url"]; - } - if ("file_id" in block) { - return block["file_id"]; - } - throw new Error( - `No payload found in ${blockType} block: ${JSON.stringify(block)}`, - ); - } - throw new Error( - `Unsupported message content block type: ${String(blockType)}`, - ); -} - -/** - * Executes a ToolNode: invokes the converted LangChain tool with the node - * inputs and maps the raw tool output onto the node's declared output - * properties (MCP content-block lists map positionally; dicts are filtered; - * arrays map positionally onto multiple outputs). - */ -export class ToolNodeExecutor extends NodeExecutor { - private readonly toolCallable: InvocableGraph; - - constructor(node: ToolNode, tool: unknown) { - super(node); - if ( - typeof tool !== "object" || - tool === null || - typeof (tool as { invoke?: unknown }).invoke !== "function" - ) { - throw new Error( - `ToolNodeExecutor expected a LangChain StructuredTool, but got ${typeof tool}.`, - ); - } - this.toolCallable = tool as InvocableGraph; - } - - /** Best-effort mapping of raw tool outputs to the declared node outputs. */ - private formatToolResult(toolOutput: unknown): ExecuteOutput { - const nodeOutputProperties = this.node.outputs ?? []; - let mapped: NodeOutputs; - if (Array.isArray(toolOutput) && isMcpContentBlocksList(toolOutput)) { - const extractedValues = (toolOutput as Record[]).map( - (block) => extractValueFromContentBlock(block), - ); - mapped = {}; - nodeOutputProperties.forEach((property, i) => { - if (i >= extractedValues.length) { - // Python raises a bare IndexError ("list index out of range") here. - throw new Error( - `Tool node \`${this.node.name}\` returned ${extractedValues.length} ` + - `content block(s) but declares ${nodeOutputProperties.length} ` + - `outputs; no value for output \`${property.title}\`.`, - ); - } - mapped[property.title] = extractedValues[i]; - }); - } else if (nodeOutputProperties.length === 1) { - // The tool returns a dict with a single key being the node's output - // property's title: use it as-is to avoid double-wrapping. - const onlyTitle = nodeOutputProperties[0]!.title; - if ( - isPlainRecord(toolOutput) && - Object.keys(toolOutput).length === 1 && - Object.hasOwn(toolOutput, onlyTitle) - ) { - mapped = toolOutput; - } else { - mapped = { [onlyTitle]: toolOutput }; - } - } else if (isPlainRecord(toolOutput)) { - // The node emits multiple outputs: filter the tool output. - mapped = {}; - for (const property of nodeOutputProperties) { - if (Object.hasOwn(toolOutput, property.title)) { - mapped[property.title] = toolOutput[property.title]; - } - } - } else if (Array.isArray(toolOutput)) { - // Multiple outputs from an array (Python: tuple): map positionally. - mapped = {}; - nodeOutputProperties.forEach((property, i) => { - if (i >= toolOutput.length) { - // Python raises a bare IndexError ("tuple index out of range") here. - throw new Error( - `Tool node \`${this.node.name}\` returned ${toolOutput.length} ` + - `value(s) but declares ${nodeOutputProperties.length} ` + - `outputs; no value for output \`${property.title}\`.`, - ); - } - mapped[property.title] = toolOutput[i]; - }); - } else { - throw new Error( - `Unsupported multi-output mapping for tool_output: ${stringifyTemplateValue(toolOutput)}` + - `(declared_outputs=${nodeOutputProperties.length}).`, - ); - } - return [mapped, {}]; - } - - protected async _execute( - inputs: NodeOutputs, - _messages: BaseMessage[], - ): Promise { - const toolOutput = await this.toolCallable.invoke(inputs); - return this.formatToolResult(toolOutput); - } -} - -/** - * Executes an AgentNode holding a plain Agent: renders the agent's system - * prompt against the node inputs, compiles (and caches) a react agent per - * rendered prompt through the converter-provided factory, and invokes it on - * the flow messages. - */ -export class AgentNodeExecutor extends NodeExecutor { - private readonly compileAgent: ( - renderedSystemPrompt: string, - ) => Promise; - protected readonly config: RunnableConfig; - /** Compiled agents cached by rendered system prompt. */ - private readonly agentsCache = new Map(); - - constructor( - node: AgentNode, - compileAgent: (renderedSystemPrompt: string) => Promise, - config: RunnableConfig, - ) { - super(node); - this.compileAgent = compileAgent; - this.config = config; - } - - private async createReactAgentWithGivenInputValues( - inputs: NodeOutputs, - ): Promise { - if (this.node.agent.componentType !== "Agent") { - throw new Error( - "AgentNodeExecutor can only be used with AgentSpecAgent agents", - ); - } - const agentComponent = this.node.agent as { systemPrompt?: unknown }; - const systemPrompt = renderTemplate( - String(agentComponent.systemPrompt ?? ""), - inputs, - ); - let agent = this.agentsCache.get(systemPrompt); - if (agent === undefined) { - agent = await this.compileAgent(systemPrompt); - this.agentsCache.set(systemPrompt, agent); - } - return agent as InvocableGraph; - } - - /** LangGraph's agent expects at least one user message to drive execution. */ - protected withDrivingMessage(messages: BaseMessage[]): unknown[] { - return messages.length > 0 ? messages : [{ role: "user", content: "" }]; - } - - /** Map an agent invoke result onto the node outputs (or a chat message). */ - protected formatAgentResult(result: Record): ExecuteOutput { - const nodeOutputs = this.node.outputs ?? []; - if (nodeOutputs.length === 0) { - const messages = Array.isArray(result["messages"]) - ? (result["messages"] as { content?: unknown }[]) - : []; - const generatedMessage = messages[messages.length - 1]; - return [ - {}, - { - generated_messages: [ - { - role: "assistant", - content: (generatedMessage?.content ?? "") as string, - }, - ], - }, - ]; - } - return [extractOutputsFromInvokeResult(result, nodeOutputs), {}]; - } - - protected async _execute( - inputs: NodeOutputs, - messages: BaseMessage[], - ): Promise { - const agent = await this.createReactAgentWithGivenInputValues(inputs); - const preparedInputs: Record = { - ...inputs, - messages: this.withDrivingMessage(messages), - }; - const result = await agent.invoke(preparedInputs, this.config); - return this.formatAgentResult(result); - } -} - -/** - * Executes an InputMessageNode: interrupts the graph with an empty-string - * payload; the resume value becomes both the node output and a new user - * message. - */ -export class InputMessageNodeExecutor extends NodeExecutor { - protected async _execute( - _inputs: NodeOutputs, - _messages: BaseMessage[], - ): Promise { - const response = interrupt(""); - const outputs = this.node.outputs ?? []; - const outputName = - outputs.length > 0 ? outputs[0]!.title : DEFAULT_INPUT_MESSAGE_OUTPUT; - return [ - { [outputName]: response }, - { - generated_messages: [ - { role: "user", content: response as string }, - ], - }, - ]; - } -} - -/** - * Executes an OutputMessageNode: renders the node's message template against - * the inputs and emits it as an assistant message. - */ -export class OutputMessageNodeExecutor extends NodeExecutor { - protected async _execute( - inputs: NodeOutputs, - _messages: BaseMessage[], - ): Promise { - const message = renderTemplate(this.node.message, inputs); - return [ - {}, - { generated_messages: [{ role: "assistant", content: message }] }, - ]; - } -} - -/** The chat-model surface the LlmNodeExecutor relies on. */ -interface ChatModelLike { - invoke(input: unknown, config?: unknown): Promise; - withStructuredOutput?(schema: Record): { - invoke(input: unknown, config?: unknown): Promise; - }; -} - -/** - * Executes an LlmNode: renders the prompt template against the inputs and - * invokes the chat model, using structured output whenever the declared - * outputs are anything but a single string. - */ -export class LlmNodeExecutor extends NodeExecutor { - private readonly llm: ChatModelLike; - private readonly requiresStructuredGeneration: boolean; - private readonly structuredLlm: - | { invoke(input: unknown, config?: unknown): Promise } - | undefined; - - constructor(node: LlmNode, llm: unknown) { - super(node); - if ( - typeof llm !== "object" || - llm === null || - typeof (llm as { invoke?: unknown }).invoke !== "function" - ) { - throw new Error("Llm can only be initialized with a BaseChatModel"); - } - this.llm = llm as ChatModelLike; - - const nodeOutputs = node.outputs ?? []; - this.requiresStructuredGeneration = !( - nodeOutputs.length === 1 && nodeOutputs[0]!.type === "string" - ); - if (this.requiresStructuredGeneration) { - if (typeof this.llm.withStructuredOutput !== "function") { - throw new Error( - "Llm can only be initialized with a BaseChatModel supporting withStructuredOutput", - ); - } - const jsonSchema: Record = { - // Title is required by langgraph - title: "structured_output", - type: "object", - properties: Object.fromEntries( - nodeOutputs.map((output) => [output.title, output.jsonSchema]), - ), - }; - this.structuredLlm = this.llm.withStructuredOutput(jsonSchema); - } else { - this.structuredLlm = undefined; - } - } - - private buildInvokeInputs(inputs: NodeOutputs): unknown[] { - const renderedPrompt = renderTemplate(this.node.promptTemplate, inputs); - return [{ role: "user", content: renderedPrompt }]; - } - - private formatStructuredOutput( - nodeOutputs: Property[], - generatedRaw: unknown, - ): NodeOutputs { - if (!isPlainRecord(generatedRaw)) { - throw new Error( - `Expected structured LLM to return a dict, got ${typeof generatedRaw}`, - ); - } - let generatedOutput: NodeOutputs = generatedRaw; - // LangGraph sometimes flattens a 1-property nested object; rebuild if needed - if ( - nodeOutputs.length === 1 && - nodeOutputs[0]!.title !== Object.keys(generatedOutput)[0] - ) { - generatedOutput = { [nodeOutputs[0]!.title]: generatedOutput }; - } - return generatedOutput; - } - - private formatUnstructuredOutput( - nodeOutputs: Property[], - generatedMessage: unknown, - ): NodeOutputs { - const outputName = - nodeOutputs.length > 0 ? nodeOutputs[0]!.title : "generated_text"; - if ( - typeof generatedMessage !== "object" || - generatedMessage === null || - !("content" in generatedMessage) - ) { - throw new Error( - "generated_message should not be a dict when not doing structured generation", - ); - } - return { - [outputName]: (generatedMessage as { content?: unknown }).content, - }; - } - - protected async _execute( - inputs: NodeOutputs, - _messages: BaseMessage[], - ): Promise { - const invokeInputs = this.buildInvokeInputs(inputs); - const nodeOutputs = this.node.outputs ?? []; - if (this.requiresStructuredGeneration) { - if (this.structuredLlm === undefined) { - throw new Error("Structured LLM was not initialized"); - } - const generatedRaw = await this.structuredLlm.invoke(invokeInputs); - return [this.formatStructuredOutput(nodeOutputs, generatedRaw), {}]; - } - const generatedMessage = await this.llm.invoke(invokeInputs); - return [this.formatUnstructuredOutput(nodeOutputs, generatedMessage), {}]; - } -} - -/** - * Executes an ApiNode: renders `{{placeholder}}` templates in the URL, data, - * headers and query params against the inputs, performs the HTTP request and - * returns the parsed JSON response body as the node output. - */ -export class ApiNodeExecutor extends NodeExecutor { - constructor(node: ApiNode) { - super(node); - // The TS SDK ApiNode has no urlAllowList field yet: the helpers are - // invoked with `undefined` (i.e. allow), matching the documented - // divergence, so the templated-URL warning fires per the Python rules. - maybeWarnAboutUnrestrictedTemplatedUrl( - node.url, - undefined, - `ApiNode \`${node.name}\``, - ); - } - - private buildRequest(inputs: NodeOutputs): { - url: string; - init: RequestInit; - } { - const apiNode = this.node; - const apiNodeData = renderNestedObjectTemplate(apiNode.data, inputs); - const apiNodeHeaders: Record = {}; - for (const [key, value] of Object.entries(apiNode.headers)) { - apiNodeHeaders[renderTemplate(key, inputs)] = renderNestedObjectTemplate( - value, - inputs, - ); - } - const apiNodeQueryParams: Record = {}; - for (const [key, value] of Object.entries(apiNode.queryParams)) { - apiNodeQueryParams[renderTemplate(key, inputs)] = - renderNestedObjectTemplate(value, inputs); - } - const apiNodeUrl = renderTemplate(apiNode.url, inputs); - - const contentTypeHeader = - apiNodeHeaders["Content-Type"] ?? apiNodeHeaders["content-type"]; - const expectUrlencodedFormData = - typeof contentTypeHeader === "string" && - contentTypeHeader.includes("application/x-www-form-urlencoded"); - - const requestHeaders: Record = {}; - for (const [key, value] of Object.entries(apiNodeHeaders)) { - requestHeaders[key] = - typeof value === "string" ? value : stringifyTemplateValue(value); - } - const callerSetContentType = Object.keys(requestHeaders).some( - (key) => key.toLowerCase() === "content-type", - ); - - const method = apiNode.httpMethod; - const methodUpper = method.toUpperCase(); - // fetch forbids request bodies on GET/HEAD (Python's httpx sends them). - const methodAllowsBody = methodUpper !== "GET" && methodUpper !== "HEAD"; - if (!methodAllowsBody) { - const hasDeclaredBody = - apiNodeData !== undefined && - apiNodeData !== null && - apiNodeData !== "" && - !(isPlainRecord(apiNodeData) && Object.keys(apiNodeData).length === 0); - if (hasDeclaredBody) { - // Forced divergence from Python: warn instead of silently dropping. - console.warn( - `ApiNode \`${apiNode.name}\` declares request data for HTTP method ` + - `${methodUpper}, but fetch forbids request bodies on GET/HEAD: ` + - `the declared body is not sent (the Python adapter sends it).`, - ); - } - } - - let body: string | URLSearchParams | Uint8Array | undefined; - if (methodAllowsBody) { - if (expectUrlencodedFormData && isPlainRecord(apiNodeData)) { - const form = new URLSearchParams(); - for (const [key, value] of Object.entries(apiNodeData)) { - form.append( - key, - typeof value === "string" ? value : stringifyTemplateValue(value), - ); - } - body = form; - } else if (typeof apiNodeData === "string") { - body = apiNodeData; - } else if (apiNodeData instanceof Uint8Array) { - body = apiNodeData; - } else if (apiNodeData !== undefined && apiNodeData !== null) { - body = JSON.stringify(apiNodeData); - if (!callerSetContentType) { - requestHeaders["Content-Type"] = "application/json"; - } - } - } - - // Kept as the seam for allow-list enforcement: the TS SDK ApiNode has no - // urlAllowList field yet, so this always allows. - validateUrlAgainstAllowList(apiNodeUrl, undefined); - - const searchParams = new URLSearchParams(); - for (const [key, value] of Object.entries(apiNodeQueryParams)) { - if (Array.isArray(value)) { - for (const item of value) { - searchParams.append( - key, - item == null ? "" : stringifyTemplateValue(item), - ); - } - } else { - searchParams.append( - key, - value == null ? "" : stringifyTemplateValue(value), - ); - } - } - const query = searchParams.toString(); - const requestUrl = - query.length > 0 - ? `${apiNodeUrl}${apiNodeUrl.includes("?") ? "&" : "?"}${query}` - : apiNodeUrl; - - return { - url: requestUrl, - init: { - method, - headers: requestHeaders, - ...(body !== undefined ? { body } : {}), - }, - }; - } - - protected async _execute( - inputs: NodeOutputs, - _messages: BaseMessage[], - ): Promise { - const { url, init } = this.buildRequest(inputs); - // Redirects are not followed and the request times out after the shared - // default, matching Python's httpx defaults (see fetchWithAdapterDefaults). - const response = await fetchWithAdapterDefaults( - url, - init, - `ApiNode \`${this.node.name}\``, - ); - // Python parses the JSON body regardless of the HTTP status (a 3xx - // response returned without following included). - const responseJson = (await response.json()) as unknown; - return [responseJson as NodeOutputs, {}]; - } -} - -/** - * Executes a FlowNode: invokes the compiled subflow with this node's inputs - * and messages; the subflow's outputs become the node outputs and its - * terminating EndNode branch propagates as this node's branch. - */ -export class FlowNodeExecutor extends NodeExecutor { - private readonly subflow: InvocableGraph; - private readonly config: RunnableConfig; - - constructor(node: FlowNode, subflow: unknown, config: RunnableConfig) { - super(node); - this.subflow = subflow as InvocableGraph; - this.config = config; - } - - protected async _execute( - inputs: NodeOutputs, - messages: BaseMessage[], - ): Promise { - const flowOutput = await this.subflow.invoke( - { messages, inputs }, - this.config, - ); - const details = flowOutput["node_execution_details"] as - | NodeExecutionDetails - | undefined; - return [ - (flowOutput["outputs"] ?? {}) as NodeOutputs, - { branch: details?.branch ?? DEFAULT_NEXT_BRANCH }, - ]; - } -} - -/** - * Executes a CatchExceptionNode: invokes the compiled subflow; on success the - * subflow outputs pass through with `caught_exception_info: null`, and on - * error the subflow's declared output defaults are emitted with the error - * message on the `caught_exception_branch`. - */ -export class CatchExceptionNodeExecutor extends NodeExecutor { - private readonly subflow: InvocableGraph; - private readonly config: RunnableConfig; - - constructor( - node: CatchExceptionNode, - subflow: unknown, - config: RunnableConfig, - ) { - super(node); - this.subflow = subflow as InvocableGraph; - this.config = config; - } - - protected async _execute( - inputs: NodeOutputs, - messages: BaseMessage[], - ): Promise { - try { - const flowOutput = await this.subflow.invoke( - { messages, inputs }, - this.config, - ); - const outputs: NodeOutputs = isPlainRecord(flowOutput["outputs"]) - ? { ...(flowOutput["outputs"] as NodeOutputs) } - : {}; - // As per the spec, when the subflow runs without error - // `caught_exception_info` is null. - outputs["caught_exception_info"] = null; - const details = flowOutput["node_execution_details"] as - | NodeExecutionDetails - | undefined; - return [outputs, { branch: details?.branch ?? DEFAULT_NEXT_BRANCH }]; - } catch (error) { - // Python emits an ExceptionRaised event on the current node span here; - // tracing is a no-op seam in the TS adapter, so nothing is emitted. - const defaultOutputs: NodeOutputs = {}; - const subflowOutputs = - (this.node.subflow["outputs"] as Property[] | undefined) ?? []; - for (const property of subflowOutputs) { - // Use default value for subflow outputs when exception occurs - defaultOutputs[property.title] = property.default; - } - defaultOutputs["caught_exception_info"] = - error instanceof Error ? error.message : String(error); - return [defaultOutputs, { branch: CAUGHT_EXCEPTION_BRANCH }]; - } - } -} - -/** - * Executes a MapNode: iterates the compiled subflow over the `iterated_` - * inputs the converter selected (broadcasting the others) and appends each - * run's subflow outputs into the node's `collected_` outputs. - */ -export class MapNodeExecutor extends NodeExecutor { - private readonly subflow: InvocableGraph; - private inputsToIterate: string[] = []; - - constructor(node: MapNode, subflow: unknown, _config: RunnableConfig) { - super(node); - if (!node.inputs || node.inputs.length === 0) { - throw new Error("MapNode has no inputs"); - } - // Mirroring Python, the subflow runs are not passed the ambient config. - this.subflow = subflow as InvocableGraph; - } - - /** Set which inputs to iterate over (decided by the converter). */ - setInputsToIterate(inputsToIterate: string[]): void { - this.inputsToIterate = inputsToIterate; - } - - private prepareIterations(inputs: NodeOutputs): { - subflowInputsList: Record[]; - outputs: Record; - } { - const outputs: Record = {}; - for (const output of this.node.outputs ?? []) { - outputs[output.title] = []; - } - - if (this.inputsToIterate.length === 0) { - throw new Error("MapNode has no inputs to iterate"); - } - - let numInputsToIterate: number | undefined; - for (const inputName of this.inputsToIterate) { - const iterable = inputs[inputName]; - const size = - Array.isArray(iterable) || typeof iterable === "string" - ? iterable.length - : undefined; - if (size === undefined) { - throw new Error( - `Found inputs to iterate with different sizes (${stringifyTemplateValue(iterable)} and ${String(numInputsToIterate)})`, - ); - } - if (numInputsToIterate === undefined) { - numInputsToIterate = size; - } else if (size !== numInputsToIterate) { - throw new Error( - `Found inputs to iterate with different sizes (${stringifyTemplateValue(iterable)} and ${numInputsToIterate})`, - ); - } - } - if (numInputsToIterate === undefined) { - throw new Error( - "MapNode inputs_to_iterate did not match any provided inputs", - ); - } - - const subflowInputsList: Record[] = []; - for (let i = 0; i < numInputsToIterate; i += 1) { - const subInputs: Record = {}; - for (const inputProperty of this.node.inputs ?? []) { - const title = inputProperty.title; - // Note: Python strips every `iterated_` occurrence here (str.replace - // with no count), not just the prefix. - const subflowInputName = title.replaceAll("iterated_", ""); - if (this.inputsToIterate.includes(title)) { - const collection = inputs[title]; - subInputs[subflowInputName] = Array.isArray(collection) - ? collection[i] - : typeof collection === "string" - ? collection[i] - : undefined; - } else { - subInputs[subflowInputName] = inputs[title]; - } - } - subflowInputsList.push(subInputs); - } - return { subflowInputsList, outputs }; - } - - private accumulateOutputs( - outputs: Record, - subflowOutputs: Record, - ): void { - for (const [outputName, outputValue] of Object.entries(subflowOutputs)) { - const collectedOutputName = `collected_${outputName}`; - // Not all outputs might be exposed: keep only those the node declares. - const collected = outputs[collectedOutputName]; - if (collected !== undefined) { - collected.push(outputValue); - } - } - } - - protected async _execute( - inputs: NodeOutputs, - messages: BaseMessage[], - ): Promise { - const { subflowInputsList, outputs } = this.prepareIterations(inputs); - for (const subflowInputs of subflowInputsList) { - const subflowResult = await this.subflow.invoke({ - inputs: subflowInputs, - messages, - }); - const subflowOutputs = subflowResult["outputs"]; - if (isPlainRecord(subflowOutputs)) { - this.accumulateOutputs(outputs, subflowOutputs); - } - } - return [outputs, {}]; - } -} + * Re-export barrel over the `node-execution/` modules (Python-parity value + * coercion, the executor base class, and the per-node executors); the + * runtime-contract and divergence notes live on each module. + */ +export { AgentNodeExecutor, extractOutputsFromInvokeResult } from "./node-execution/agent-node.js"; +export { ApiNodeExecutor } from "./node-execution/api-node.js"; +export { + BranchingNodeExecutor, + EndNodeExecutor, + InputMessageNodeExecutor, + OutputMessageNodeExecutor, + StartNodeExecutor, +} from "./node-execution/basic-nodes.js"; +export { NodeExecutor } from "./node-execution/executor.js"; +export { LlmNodeExecutor } from "./node-execution/llm-node.js"; +export { + castValuesAndAddDefaults, + pythonJsonDumps, +} from "./node-execution/python-parity.js"; +export { + CatchExceptionNodeExecutor, + FlowNodeExecutor, + MapNodeExecutor, +} from "./node-execution/subflow-nodes.js"; +export { ToolNodeExecutor } from "./node-execution/tool-node.js"; diff --git a/tsagentspec/src/adapters/langgraph/node-execution/agent-node.ts b/tsagentspec/src/adapters/langgraph/node-execution/agent-node.ts new file mode 100644 index 00000000..4b977fda --- /dev/null +++ b/tsagentspec/src/adapters/langgraph/node-execution/agent-node.ts @@ -0,0 +1,136 @@ +/** + * AgentNode executor for the LangGraph adapter. + * + * Port of `pyagentspec.adapters.langgraph._node_execution.AgentNodeExecutor`. + * Runtime contracts (state keys, error-message text) mirror the Python + * adapter exactly so specs behave the same across both SDKs. + * + * Divergence from Python (see the adapter README): the react-agent invoke + * payload adds no `remaining_steps` / `structured_response` keys — the + * langchain JS agent state has neither channel (structured output lands in + * `structuredResponse`). + */ +import type { BaseMessage } from "@langchain/core/messages"; +import type { RunnableConfig } from "@langchain/core/runnables"; +import type { AgentNode } from "../../../flows/index.js"; +import type { Property } from "../../../property.js"; +import { renderTemplate } from "../../common/index.js"; +import type { ExecuteOutput, NodeOutputs } from "../types.js"; +import type { InvocableGraph } from "./executor.js"; +import { NodeExecutor, isPlainRecord } from "./executor.js"; + +/** + * Extract the outputs of an agent invoke result for the expected output + * properties, merging (in increasing priority) property defaults, the + * structured response, and top-level result entries. Reads the langchain JS + * `structuredResponse` key, falling back to Python's `structured_response`. + */ +export function extractOutputsFromInvokeResult( + result: Record, + expectedOutputs: Property[], +): NodeOutputs { + const outputs: NodeOutputs = {}; + for (const output of expectedOutputs) { + if (output.default !== undefined) { + outputs[output.title] = output.default; + } + } + const structuredResponse = + result["structuredResponse"] ?? result["structured_response"]; + if (isPlainRecord(structuredResponse)) { + Object.assign(outputs, structuredResponse); + } + for (const output of expectedOutputs) { + if (Object.hasOwn(result, output.title)) { + outputs[output.title] = result[output.title]; + } + } + return outputs; +} + +/** + * Executes an AgentNode holding a plain Agent: renders the agent's system + * prompt against the node inputs, compiles (and caches) a react agent per + * rendered prompt through the converter-provided factory, and invokes it on + * the flow messages. + */ +export class AgentNodeExecutor extends NodeExecutor { + private readonly compileAgent: ( + renderedSystemPrompt: string, + ) => Promise; + protected readonly config: RunnableConfig; + /** Compiled agents cached by rendered system prompt. */ + private readonly agentsCache = new Map(); + + constructor( + node: AgentNode, + compileAgent: (renderedSystemPrompt: string) => Promise, + config: RunnableConfig, + ) { + super(node); + this.compileAgent = compileAgent; + this.config = config; + } + + private async createReactAgentWithGivenInputValues( + inputs: NodeOutputs, + ): Promise { + if (this.node.agent.componentType !== "Agent") { + throw new Error( + "AgentNodeExecutor can only be used with AgentSpecAgent agents", + ); + } + const agentComponent = this.node.agent as { systemPrompt?: unknown }; + const systemPrompt = renderTemplate( + String(agentComponent.systemPrompt ?? ""), + inputs, + ); + let agent = this.agentsCache.get(systemPrompt); + if (agent === undefined) { + agent = await this.compileAgent(systemPrompt); + this.agentsCache.set(systemPrompt, agent); + } + return agent as InvocableGraph; + } + + /** LangGraph's agent expects at least one user message to drive execution. */ + protected withDrivingMessage(messages: BaseMessage[]): unknown[] { + return messages.length > 0 ? messages : [{ role: "user", content: "" }]; + } + + /** Map an agent invoke result onto the node outputs (or a chat message). */ + protected formatAgentResult(result: Record): ExecuteOutput { + const nodeOutputs = this.node.outputs ?? []; + if (nodeOutputs.length === 0) { + const messages = Array.isArray(result["messages"]) + ? (result["messages"] as { content?: unknown }[]) + : []; + const generatedMessage = messages[messages.length - 1]; + return [ + {}, + { + generated_messages: [ + { + role: "assistant", + content: (generatedMessage?.content ?? "") as string, + }, + ], + }, + ]; + } + return [extractOutputsFromInvokeResult(result, nodeOutputs), {}]; + } + + protected async _execute( + inputs: NodeOutputs, + messages: BaseMessage[], + ): Promise { + const agent = await this.createReactAgentWithGivenInputValues(inputs); + const preparedInputs: Record = { + ...inputs, + messages: this.withDrivingMessage(messages), + }; + const result = await agent.invoke(preparedInputs, this.config); + return this.formatAgentResult(result); + } +} diff --git a/tsagentspec/src/adapters/langgraph/node-execution/api-node.ts b/tsagentspec/src/adapters/langgraph/node-execution/api-node.ts new file mode 100644 index 00000000..1a984380 --- /dev/null +++ b/tsagentspec/src/adapters/langgraph/node-execution/api-node.ts @@ -0,0 +1,77 @@ +/** + * ApiNode executor for the LangGraph adapter. + * + * Port of `pyagentspec.adapters.langgraph._node_execution.ApiNodeExecutor`. + * The request assembly is shared with the RemoteTool path through + * `buildTemplatedHttpRequest` (Python spells it out identically at both + * sites). + * + * Divergences from Python (see the adapter README): + * - The TS SDK ApiNode has no `urlAllowList` field, so the allow-list helpers + * are invoked with `undefined` (i.e. allow) and the templated-URL warning + * fires per the Python rules. + * - `fetch` forbids request bodies on GET/HEAD (Python's httpx sends them): + * the declared body is not sent for those methods and a warning is emitted + * instead of silently dropping it. + * + * Python-parity network behavior (NOT divergences): redirects are not + * followed and requests time out after the shared httpx-parity default — see + * `fetchWithAdapterDefaults`. + */ +import type { BaseMessage } from "@langchain/core/messages"; +import type { ApiNode } from "../../../flows/index.js"; +import { + buildTemplatedHttpRequest, + fetchWithAdapterDefaults, + maybeWarnAboutUnrestrictedTemplatedUrl, +} from "../../common/index.js"; +import type { ExecuteOutput, NodeOutputs } from "../types.js"; +import { NodeExecutor } from "./executor.js"; + +/** + * Executes an ApiNode: renders `{{placeholder}}` templates in the URL, data, + * headers and query params against the inputs, performs the HTTP request and + * returns the parsed JSON response body as the node output. + */ +export class ApiNodeExecutor extends NodeExecutor { + constructor(node: ApiNode) { + super(node); + // The TS SDK ApiNode has no urlAllowList field yet: the helpers are + // invoked with `undefined` (i.e. allow), matching the documented + // divergence, so the templated-URL warning fires per the Python rules. + maybeWarnAboutUnrestrictedTemplatedUrl( + node.url, + undefined, + `ApiNode \`${node.name}\``, + ); + } + + protected async _execute( + inputs: NodeOutputs, + _messages: BaseMessage[], + ): Promise { + const { url, init, bodyDropped } = buildTemplatedHttpRequest( + this.node, + inputs, + ); + if (bodyDropped) { + // Forced divergence from Python: warn instead of silently dropping. + console.warn( + `ApiNode \`${this.node.name}\` declares request data for HTTP method ` + + `${this.node.httpMethod.toUpperCase()}, but fetch forbids request bodies on GET/HEAD: ` + + `the declared body is not sent (the Python adapter sends it).`, + ); + } + // Redirects are not followed and the request times out after the shared + // default, matching Python's httpx defaults (see fetchWithAdapterDefaults). + const response = await fetchWithAdapterDefaults( + url, + init, + `ApiNode \`${this.node.name}\``, + ); + // Python parses the JSON body regardless of the HTTP status (a 3xx + // response returned without following included). + const responseJson = (await response.json()) as unknown; + return [responseJson as NodeOutputs, {}]; + } +} diff --git a/tsagentspec/src/adapters/langgraph/node-execution/basic-nodes.ts b/tsagentspec/src/adapters/langgraph/node-execution/basic-nodes.ts new file mode 100644 index 00000000..b17305a8 --- /dev/null +++ b/tsagentspec/src/adapters/langgraph/node-execution/basic-nodes.ts @@ -0,0 +1,195 @@ +/** + * Executors for the structural flow nodes: Start, End, Branching, + * InputMessage and OutputMessage. + * + * Port of the matching executors in + * `pyagentspec.adapters.langgraph._node_execution`. Runtime contracts (state + * keys, branch names, interrupt payloads, error-message text) mirror the + * Python adapter exactly so specs behave the same across both SDKs — the + * InputMessageNode interrupts the graph with the same empty-string payload. + */ +import type { BaseMessage } from "@langchain/core/messages"; +import { interrupt } from "@langchain/langgraph"; +import type { + BranchingNode, + EndNode, + InputMessageNode, + OutputMessageNode, + StartNode, +} from "../../../flows/index.js"; +import { + DEFAULT_BRANCH, + DEFAULT_INPUT_MESSAGE_OUTPUT, +} from "../../../flows/index.js"; +import type { Property } from "../../../property.js"; +import { renderTemplate } from "../../common/index.js"; +import type { + ExecuteOutput, + FlowState, + NodeExecutionDetails, + NodeOutputs, +} from "../types.js"; +import { NodeExecutor } from "./executor.js"; +import { castValuesAndAddDefaults } from "./python-parity.js"; + +/** + * Executes a StartNode: consumes the flow-level invocation inputs (plain + * string keys at the top level of `state.inputs`) and passes them through as + * outputs, flowing to downstream nodes along the data edges. + */ +export class StartNodeExecutor extends NodeExecutor { + protected override getInputs(state: FlowState): NodeOutputs { + // At StartNode time the state inputs hold the flow's initial call inputs + // as plain `{inputName: value}` keys (no node-id nesting): consume all of + // them (they are removed from the state in updateStatus below). + const ioInputs: Record = { ...(state.inputs ?? {}) }; + return castValuesAndAddDefaults( + ioInputs, + this.node.inputs ?? [], + this.node.name, + ); + } + + protected override updateStatus( + outputs: NodeOutputs, + executionDetails: NodeExecutionDetails, + previousState: FlowState, + ): FlowState { + // Python pops the consumed flow-level inputs out of the state; the + // non-mutating equivalent is starting the routing table from scratch. + return super.updateStatus(outputs, executionDetails, { + ...previousState, + inputs: {}, + }); + } + + protected async _execute( + inputs: NodeOutputs, + _messages: BaseMessage[], + ): Promise { + return [inputs, {}]; + } +} + +/** + * Executes an EndNode: passes its inputs through as outputs, reshapes them to + * the flow's declared outputs, and marks the run finished on the node's + * branch. + */ +export class EndNodeExecutor extends NodeExecutor { + private flowOutputs: Property[] = []; + + /** Give the executor the flow outputs used to reshape the final state. */ + setFlowOutputs(flowOutputs: Property[]): void { + this.flowOutputs = flowOutputs; + } + + protected async _execute( + inputs: NodeOutputs, + _messages: BaseMessage[], + ): Promise { + return [inputs, { branch: this.node.branchName, should_finish: true }]; + } + + protected override updateStatus( + outputs: NodeOutputs, + executionDetails: NodeExecutionDetails, + previousState: FlowState, + ): FlowState { + const newState = super.updateStatus( + outputs, + executionDetails, + previousState, + ); + const nodeOutputs = newState.outputs; + const filteredOutputs: NodeOutputs = {}; + for (const property of this.flowOutputs) { + filteredOutputs[property.title] = Object.hasOwn( + nodeOutputs, + property.title, + ) + ? nodeOutputs[property.title] + : property.default; + } + for (const [propertyName, propertyValue] of Object.entries(nodeOutputs)) { + if (propertyValue === undefined) { + throw new Error( + `EndNode \`${this.node.name}\` exited without any value generated for property \`${propertyName}\``, + ); + } + } + return { ...newState, outputs: filteredOutputs }; + } +} + +/** + * Executes a BranchingNode: reads its first input and selects the branch its + * mapping points to (the `default` branch when the value is unmapped). + */ +export class BranchingNodeExecutor extends NodeExecutor { + constructor(node: BranchingNode) { + super(node); + if (!node.inputs || node.inputs.length === 0) { + throw new Error("BranchingNode requires at least one input"); + } + } + + protected async _execute( + inputs: NodeOutputs, + _messages: BaseMessage[], + ): Promise { + const nodeInputs = this.node.inputs ?? []; + const inputBranchPropTitle = nodeInputs[0]!.title; + const inputBranchName = Object.hasOwn(inputs, inputBranchPropTitle) + ? inputs[inputBranchPropTitle] + : DEFAULT_BRANCH; + const selectedBranch = + typeof inputBranchName === "string" && + Object.hasOwn(this.node.mapping, inputBranchName) + ? this.node.mapping[inputBranchName]! + : DEFAULT_BRANCH; + return [{}, { branch: selectedBranch }]; + } +} + +/** + * Executes an InputMessageNode: interrupts the graph with an empty-string + * payload; the resume value becomes both the node output and a new user + * message. + */ +export class InputMessageNodeExecutor extends NodeExecutor { + protected async _execute( + _inputs: NodeOutputs, + _messages: BaseMessage[], + ): Promise { + const response = interrupt(""); + const outputs = this.node.outputs ?? []; + const outputName = + outputs.length > 0 ? outputs[0]!.title : DEFAULT_INPUT_MESSAGE_OUTPUT; + return [ + { [outputName]: response }, + { + generated_messages: [ + { role: "user", content: response as string }, + ], + }, + ]; + } +} + +/** + * Executes an OutputMessageNode: renders the node's message template against + * the inputs and emits it as an assistant message. + */ +export class OutputMessageNodeExecutor extends NodeExecutor { + protected async _execute( + inputs: NodeOutputs, + _messages: BaseMessage[], + ): Promise { + const message = renderTemplate(this.node.message, inputs); + return [ + {}, + { generated_messages: [{ role: "assistant", content: message }] }, + ]; + } +} diff --git a/tsagentspec/src/adapters/langgraph/node-execution/executor.ts b/tsagentspec/src/adapters/langgraph/node-execution/executor.ts new file mode 100644 index 00000000..8f70d755 --- /dev/null +++ b/tsagentspec/src/adapters/langgraph/node-execution/executor.ts @@ -0,0 +1,162 @@ +/** + * Base class of the LangGraph flow node executors. + * + * Port of `pyagentspec.adapters.langgraph._node_execution.NodeExecutor`. + * + * Runtime contracts (state keys, branch names, error-message text) mirror the + * Python adapter exactly so specs behave the same across both SDKs. + * + * Divergences from Python (see the adapter README): + * - Execution is async-only (no sync `__call__` / thread offloading). + * - Executors never mutate the incoming state: they return updated copies + * with the same accumulate semantics as Python's in-place mutation. + * - Executors receive their collaborators from the converter (converted + * tools, chat models, compiled subgraphs, agent compile factories) instead + * of importing the converter, so there are no module cycles. + * - Node execution spans/events are not emitted (tracing is a no-op seam). + */ +import type { BaseMessage } from "@langchain/core/messages"; +import type { RunnableConfig } from "@langchain/core/runnables"; +import { addMessages } from "@langchain/langgraph"; +import type { DataFlowEdge } from "../../../flows/index.js"; +import { DEFAULT_NEXT_BRANCH } from "../../../flows/index.js"; +import type { Property } from "../../../property.js"; +import type { + ExecuteOutput, + FlowState, + NextNodeInputs, + NodeExecutionDetails, + NodeOutputs, +} from "../types.js"; +import { castValuesAndAddDefaults } from "./python-parity.js"; + +/** The structural surface of an Agent Spec flow node used by the executors. */ +export interface FlowNodeLike { + id: string; + name: string; + inputs?: Property[]; + outputs?: Property[]; +} + +/** A compiled graph / react agent surface: everything invocable. */ +export interface InvocableGraph { + invoke( + input: unknown, + config?: RunnableConfig, + ): Promise>; +} + +/** Loose record check: any non-array object (class instances included). */ +export function isPlainRecord( + value: unknown, +): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Base class of the flow node executors. + * + * `call` is the LangGraph node function: it selects this node's pending + * inputs from the state, casts them against the declared input properties, + * executes the node, and returns the updated flow state (accumulated inputs + * routing table, cast outputs, merged messages and execution details). + */ +export abstract class NodeExecutor< + TNode extends FlowNodeLike = FlowNodeLike, +> { + protected readonly node: TNode; + protected readonly edges: DataFlowEdge[] = []; + + constructor(node: TNode) { + this.node = node; + } + + /** Attach a data-flow edge whose source is this node. */ + attachEdge(edge: DataFlowEdge): void { + this.edges.push(edge); + } + + /** Execute this node against the current flow state (LangGraph node fn). */ + async call(state: FlowState, _config?: RunnableConfig): Promise { + const inputs = this.getInputs(state); + const [outputs, executionDetails] = await this._execute( + inputs, + state.messages ?? [], + ); + return this.updateStatus(outputs, executionDetails, state); + } + + /** Execute the node with the given cast inputs; returns outputs + details. */ + protected abstract _execute( + inputs: NodeOutputs, + messages: BaseMessage[], + ): Promise; + + /** + * Retrieve the inputs for this node (the `state.inputs` entries keyed by + * this node's id), adding default values when missing and casting to the + * declared types. + */ + protected getInputs(state: FlowState): NodeOutputs { + const nodeInputs = state.inputs?.[this.node.id]; + const ioInputs: Record = isPlainRecord(nodeInputs) + ? { ...nodeInputs } + : {}; + return castValuesAndAddDefaults( + ioInputs, + this.node.inputs ?? [], + this.node.name, + ); + } + + /** + * Fold the node outputs and execution details into the flow state: cast the + * outputs, route them along the attached data-flow edges into the pending + * inputs of downstream nodes (accumulating into a copy of the previous + * routing table), default the execution details, and merge generated + * messages via LangGraph's `addMessages`. + */ + protected updateStatus( + outputs: NodeOutputs, + executionDetails: NodeExecutionDetails, + previousState: FlowState, + ): FlowState { + const castOutputs = castValuesAndAddDefaults( + outputs, + this.node.outputs ?? [], + this.node.name, + ); + const nextNodeInputs: NextNodeInputs = { ...(previousState.inputs ?? {}) }; + for (const edge of this.edges) { + const destinationNodeId = String(edge.destinationNode["id"]); + const existing = nextNodeInputs[destinationNodeId]; + const destinationInputs: Record = isPlainRecord(existing) + ? { ...existing } + : {}; + if (!Object.hasOwn(castOutputs, edge.sourceOutput)) { + // Python raises a bare KeyError here. + throw new Error( + `Node \`${this.node.name}\` produced no output ` + + `\`${edge.sourceOutput}\` required by data-flow edge \`${edge.name}\`.`, + ); + } + destinationInputs[edge.destinationInput] = castOutputs[edge.sourceOutput]; + nextNodeInputs[destinationNodeId] = destinationInputs; + } + + const details: NodeExecutionDetails = { + branch: executionDetails.branch ?? DEFAULT_NEXT_BRANCH, + generated_messages: executionDetails.generated_messages ?? [], + should_finish: executionDetails.should_finish ?? false, + }; + return { + inputs: nextNodeInputs, + outputs: castOutputs, + messages: addMessages( + previousState.messages ?? [], + details.generated_messages ?? [], + ), + node_execution_details: details, + }; + } +} diff --git a/tsagentspec/src/adapters/langgraph/node-execution/llm-node.ts b/tsagentspec/src/adapters/langgraph/node-execution/llm-node.ts new file mode 100644 index 00000000..61d8194e --- /dev/null +++ b/tsagentspec/src/adapters/langgraph/node-execution/llm-node.ts @@ -0,0 +1,128 @@ +/** + * LlmNode executor for the LangGraph adapter. + * + * Port of `pyagentspec.adapters.langgraph._node_execution.LlmNodeExecutor`. + * Runtime contracts (output naming, error-message text) mirror the Python + * adapter exactly so specs behave the same across both SDKs. + */ +import type { BaseMessage } from "@langchain/core/messages"; +import type { LlmNode } from "../../../flows/index.js"; +import type { Property } from "../../../property.js"; +import { renderTemplate } from "../../common/index.js"; +import type { ExecuteOutput, NodeOutputs } from "../types.js"; +import { NodeExecutor, isPlainRecord } from "./executor.js"; + +/** The chat-model surface the LlmNodeExecutor relies on. */ +interface ChatModelLike { + invoke(input: unknown, config?: unknown): Promise; + withStructuredOutput?(schema: Record): { + invoke(input: unknown, config?: unknown): Promise; + }; +} + +/** + * Executes an LlmNode: renders the prompt template against the inputs and + * invokes the chat model, using structured output whenever the declared + * outputs are anything but a single string. + */ +export class LlmNodeExecutor extends NodeExecutor { + private readonly llm: ChatModelLike; + /** Present exactly when the declared outputs require structured generation. */ + private readonly structuredLlm: + | { invoke(input: unknown, config?: unknown): Promise } + | undefined; + + constructor(node: LlmNode, llm: unknown) { + super(node); + if ( + typeof llm !== "object" || + llm === null || + typeof (llm as { invoke?: unknown }).invoke !== "function" + ) { + throw new Error("Llm can only be initialized with a BaseChatModel"); + } + this.llm = llm as ChatModelLike; + + const nodeOutputs = node.outputs ?? []; + const requiresStructuredGeneration = !( + nodeOutputs.length === 1 && nodeOutputs[0]!.type === "string" + ); + if (requiresStructuredGeneration) { + if (typeof this.llm.withStructuredOutput !== "function") { + throw new Error( + "Llm can only be initialized with a BaseChatModel supporting withStructuredOutput", + ); + } + const jsonSchema: Record = { + // Title is required by langgraph + title: "structured_output", + type: "object", + properties: Object.fromEntries( + nodeOutputs.map((output) => [output.title, output.jsonSchema]), + ), + }; + this.structuredLlm = this.llm.withStructuredOutput(jsonSchema); + } else { + this.structuredLlm = undefined; + } + } + + private buildInvokeInputs(inputs: NodeOutputs): unknown[] { + const renderedPrompt = renderTemplate(this.node.promptTemplate, inputs); + return [{ role: "user", content: renderedPrompt }]; + } + + private formatStructuredOutput( + nodeOutputs: Property[], + generatedRaw: unknown, + ): NodeOutputs { + if (!isPlainRecord(generatedRaw)) { + throw new Error( + `Expected structured LLM to return a dict, got ${typeof generatedRaw}`, + ); + } + let generatedOutput: NodeOutputs = generatedRaw; + // LangGraph sometimes flattens a 1-property nested object; rebuild if needed + if ( + nodeOutputs.length === 1 && + nodeOutputs[0]!.title !== Object.keys(generatedOutput)[0] + ) { + generatedOutput = { [nodeOutputs[0]!.title]: generatedOutput }; + } + return generatedOutput; + } + + private formatUnstructuredOutput( + nodeOutputs: Property[], + generatedMessage: unknown, + ): NodeOutputs { + const outputName = + nodeOutputs.length > 0 ? nodeOutputs[0]!.title : "generated_text"; + if ( + typeof generatedMessage !== "object" || + generatedMessage === null || + !("content" in generatedMessage) + ) { + throw new Error( + "generated_message should not be a dict when not doing structured generation", + ); + } + return { + [outputName]: (generatedMessage as { content?: unknown }).content, + }; + } + + protected async _execute( + inputs: NodeOutputs, + _messages: BaseMessage[], + ): Promise { + const invokeInputs = this.buildInvokeInputs(inputs); + const nodeOutputs = this.node.outputs ?? []; + if (this.structuredLlm !== undefined) { + const generatedRaw = await this.structuredLlm.invoke(invokeInputs); + return [this.formatStructuredOutput(nodeOutputs, generatedRaw), {}]; + } + const generatedMessage = await this.llm.invoke(invokeInputs); + return [this.formatUnstructuredOutput(nodeOutputs, generatedMessage), {}]; + } +} diff --git a/tsagentspec/src/adapters/langgraph/node-execution/python-parity.ts b/tsagentspec/src/adapters/langgraph/node-execution/python-parity.ts new file mode 100644 index 00000000..3d02f023 --- /dev/null +++ b/tsagentspec/src/adapters/langgraph/node-execution/python-parity.ts @@ -0,0 +1,160 @@ +/** + * Python-semantics value coercion for the LangGraph flow node executors. + * + * TS-only emulation layer with no direct Python counterpart: it reproduces + * the behavior Python gets for free from `json.dumps`, `int()` and `float()` + * inside `_cast_values_and_add_defaults` (`_node_execution.py`), so casting + * node values produces byte-identical flow state text across both SDKs. + */ +import type { Property } from "../../../property.js"; +import type { NodeOutputs } from "../types.js"; + +/** Serialize one string the way Python's `json.dumps` does (ensure_ascii). */ +function pythonJsonDumpsString(value: string): string { + let out = '"'; + for (const ch of value) { + const code = ch.codePointAt(0)!; + if (ch === '"') out += '\\"'; + else if (ch === "\\") out += "\\\\"; + else if (ch === "\b") out += "\\b"; + else if (ch === "\f") out += "\\f"; + else if (ch === "\n") out += "\\n"; + else if (ch === "\r") out += "\\r"; + else if (ch === "\t") out += "\\t"; + else if (code < 0x20 || code > 0x7e) { + if (code > 0xffff) { + // ensure_ascii escapes astral characters as a surrogate pair. + const high = 0xd800 + ((code - 0x10000) >> 10); + const low = 0xdc00 + ((code - 0x10000) & 0x3ff); + out += `\\u${high.toString(16).padStart(4, "0")}`; + out += `\\u${low.toString(16).padStart(4, "0")}`; + } else { + out += `\\u${code.toString(16).padStart(4, "0")}`; + } + } else out += ch; + } + return out + '"'; +} + +/** + * Serialize a value the way Python's `json.dumps` does with its default + * arguments: `", "` / `": "` separators, ensure_ascii `\uXXXX` escapes, and + * `Infinity`/`-Infinity`/`NaN` literals (allow_nan). Used when casting + * non-string values into `string`-typed properties so the resulting flow + * state text matches the Python adapter byte-for-byte. + */ +export function pythonJsonDumps(value: unknown): string { + if (value === null || value === undefined) return "null"; + if (typeof value === "boolean") return value ? "true" : "false"; + if (typeof value === "number") { + if (Number.isFinite(value)) return JSON.stringify(value); + if (value === Infinity) return "Infinity"; + if (value === -Infinity) return "-Infinity"; + return "NaN"; + } + if (typeof value === "string") return pythonJsonDumpsString(value); + if (Array.isArray(value)) { + return `[${value.map((item) => pythonJsonDumps(item)).join(", ")}]`; + } + if (typeof value === "object") { + const entries = Object.entries(value as Record) + .filter(([, v]) => v !== undefined && typeof v !== "function") + .map(([k, v]) => `${pythonJsonDumpsString(k)}: ${pythonJsonDumps(v)}`); + return `{${entries.join(", ")}}`; + } + return JSON.stringify(value) ?? "null"; +} + +/** Digit run with Python's underscore separators (`1_000`, not `1__0`). */ +const PY_DIGITS = String.raw`\d(?:_?\d)*`; + +/** Python `int()` string grammar: optional sign + underscore-separated digits. */ +const PYTHON_INT_REGEXP = new RegExp(`^[+-]?${PY_DIGITS}$`); + +/** Python `float()` numeric grammar (decimal/scientific, no hex/binary/octal). */ +const PYTHON_FLOAT_REGEXP = new RegExp( + `^[+-]?(?:(?:${PY_DIGITS})?\\.${PY_DIGITS}|${PY_DIGITS}\\.?)(?:[eE][+-]?${PY_DIGITS})?$`, +); + +/** + * Parse a (trimmed) string with Python `float()` semantics: decimal and + * scientific forms plus `inf`/`infinity`/`nan` (any case, optional sign) and + * underscore digit separators. Returns `undefined` for anything Python's + * `float()` rejects (hex/binary/octal literals, `1__0`, empty strings, ...). + */ +function parsePythonFloat(text: string): number | undefined { + const unsigned = text.toLowerCase().replace(/^[+-]/, ""); + if (unsigned === "inf" || unsigned === "infinity") { + return text.startsWith("-") ? -Infinity : Infinity; + } + if (unsigned === "nan") return NaN; + if (!PYTHON_FLOAT_REGEXP.test(text)) return undefined; + const parsed = Number(text.replace(/_/g, "")); + return Number.isNaN(parsed) ? undefined : parsed; +} + +/** + * Cast the given values to the types declared by the properties and add + * missing defaults, mirroring Python's `_cast_values_and_add_defaults`: + * non-strings are `json.dumps`-serialized into `string` properties, numbers + * become booleans, numeric strings parse into `integer`/`number` properties + * (an unparsable integer string raises like Python's `int()`; an unparsable + * number string is left as-is like Python's swallowed `float()` error), and + * a property with neither value nor default raises. Values for undeclared + * properties are dropped. + */ +export function castValuesAndAddDefaults( + valuesDict: Record, + properties: Property[], + nodeName: string, +): NodeOutputs { + const resultsDict: NodeOutputs = {}; + for (const property of properties) { + const key = property.title; + if (Object.hasOwn(valuesDict, key)) { + let value = valuesDict[key]; + const propertyType = property.type; + if (propertyType === "string" && typeof value !== "string") { + value = pythonJsonDumps(value); + } else if (propertyType === "boolean" && typeof value === "number") { + value = Boolean(value); + } else if (propertyType === "integer" && typeof value === "boolean") { + value = value ? 1 : 0; + } else if (propertyType === "integer" && typeof value === "number") { + value = Math.trunc(value); + } else if (propertyType === "integer" && typeof value === "string") { + // Python does `int(value.strip())` and re-raises for any unparsable + // string (its error-message guard never matches `int()`'s text), so + // an unparsable integer string aborts the flow here too. + const trimmed = value.trim(); + if (PYTHON_INT_REGEXP.test(trimmed)) { + value = parseInt(trimmed.replace(/_/g, ""), 10); + } else { + // Python raises ValueError with this exact message (repr'd value). + throw new Error( + `invalid literal for int() with base 10: ${JSON.stringify(trimmed)}`, + ); + } + } else if (propertyType === "number" && typeof value === "boolean") { + value = value ? 1 : 0; + } else if (propertyType === "number" && typeof value === "string") { + // Try converting numeric strings to floats with Python `float()` + // semantics; if the parse fails, leave the string as-is (Python + // swallows the `could not convert string to float:` error). + const parsed = parsePythonFloat(value.trim()); + if (parsed !== undefined) { + value = parsed; + } + } + resultsDict[key] = value; + } else if (property.default !== undefined) { + resultsDict[key] = property.default; + } else { + throw new Error( + `Expected node \`${nodeName}\` to have a value ` + + `for property \`${property.title}\`, but none was found.`, + ); + } + } + return resultsDict; +} diff --git a/tsagentspec/src/adapters/langgraph/node-execution/subflow-nodes.ts b/tsagentspec/src/adapters/langgraph/node-execution/subflow-nodes.ts new file mode 100644 index 00000000..5efee289 --- /dev/null +++ b/tsagentspec/src/adapters/langgraph/node-execution/subflow-nodes.ts @@ -0,0 +1,244 @@ +/** + * Executors for the subflow-holding nodes: FlowNode, CatchExceptionNode and + * MapNode. + * + * Port of the matching executors in + * `pyagentspec.adapters.langgraph._node_execution`. Runtime contracts (state + * keys, branch names, error-message text) mirror the Python adapter exactly + * so specs behave the same across both SDKs. + * + * Divergence from Python (see the adapter README): node execution + * spans/events are not emitted (tracing is a no-op seam), so the + * CatchExceptionNode emits no ExceptionRaised event on error. + */ +import type { BaseMessage } from "@langchain/core/messages"; +import type { RunnableConfig } from "@langchain/core/runnables"; +import type { + CatchExceptionNode, + FlowNode, + MapNode, +} from "../../../flows/index.js"; +import { + CAUGHT_EXCEPTION_BRANCH, + DEFAULT_NEXT_BRANCH, +} from "../../../flows/index.js"; +import type { Property } from "../../../property.js"; +import { stringifyTemplateValue } from "../../common/index.js"; +import type { + ExecuteOutput, + NodeExecutionDetails, + NodeOutputs, +} from "../types.js"; +import type { InvocableGraph } from "./executor.js"; +import { NodeExecutor, isPlainRecord } from "./executor.js"; + +/** + * Executes a FlowNode: invokes the compiled subflow with this node's inputs + * and messages; the subflow's outputs become the node outputs and its + * terminating EndNode branch propagates as this node's branch. + */ +export class FlowNodeExecutor extends NodeExecutor { + private readonly subflow: InvocableGraph; + private readonly config: RunnableConfig; + + constructor(node: FlowNode, subflow: unknown, config: RunnableConfig) { + super(node); + this.subflow = subflow as InvocableGraph; + this.config = config; + } + + protected async _execute( + inputs: NodeOutputs, + messages: BaseMessage[], + ): Promise { + const flowOutput = await this.subflow.invoke( + { messages, inputs }, + this.config, + ); + const details = flowOutput["node_execution_details"] as + | NodeExecutionDetails + | undefined; + return [ + (flowOutput["outputs"] ?? {}) as NodeOutputs, + { branch: details?.branch ?? DEFAULT_NEXT_BRANCH }, + ]; + } +} + +/** + * Executes a CatchExceptionNode: invokes the compiled subflow; on success the + * subflow outputs pass through with `caught_exception_info: null`, and on + * error the subflow's declared output defaults are emitted with the error + * message on the `caught_exception_branch`. + */ +export class CatchExceptionNodeExecutor extends NodeExecutor { + private readonly subflow: InvocableGraph; + private readonly config: RunnableConfig; + + constructor( + node: CatchExceptionNode, + subflow: unknown, + config: RunnableConfig, + ) { + super(node); + this.subflow = subflow as InvocableGraph; + this.config = config; + } + + protected async _execute( + inputs: NodeOutputs, + messages: BaseMessage[], + ): Promise { + try { + const flowOutput = await this.subflow.invoke( + { messages, inputs }, + this.config, + ); + const outputs: NodeOutputs = isPlainRecord(flowOutput["outputs"]) + ? { ...(flowOutput["outputs"] as NodeOutputs) } + : {}; + // As per the spec, when the subflow runs without error + // `caught_exception_info` is null. + outputs["caught_exception_info"] = null; + const details = flowOutput["node_execution_details"] as + | NodeExecutionDetails + | undefined; + return [outputs, { branch: details?.branch ?? DEFAULT_NEXT_BRANCH }]; + } catch (error) { + // Python emits an ExceptionRaised event on the current node span here; + // tracing is a no-op seam in the TS adapter, so nothing is emitted. + const defaultOutputs: NodeOutputs = {}; + const subflowOutputs = + (this.node.subflow["outputs"] as Property[] | undefined) ?? []; + for (const property of subflowOutputs) { + // Use default value for subflow outputs when exception occurs + defaultOutputs[property.title] = property.default; + } + defaultOutputs["caught_exception_info"] = + error instanceof Error ? error.message : String(error); + return [defaultOutputs, { branch: CAUGHT_EXCEPTION_BRANCH }]; + } + } +} + +/** + * Executes a MapNode: iterates the compiled subflow over the `iterated_` + * inputs the converter selected (broadcasting the others) and appends each + * run's subflow outputs into the node's `collected_` outputs. + */ +export class MapNodeExecutor extends NodeExecutor { + private readonly subflow: InvocableGraph; + private inputsToIterate: string[] = []; + + constructor(node: MapNode, subflow: unknown) { + super(node); + if (!node.inputs || node.inputs.length === 0) { + throw new Error("MapNode has no inputs"); + } + // Mirroring Python, the subflow runs are not passed the ambient config. + this.subflow = subflow as InvocableGraph; + } + + /** Set which inputs to iterate over (decided by the converter). */ + setInputsToIterate(inputsToIterate: string[]): void { + this.inputsToIterate = inputsToIterate; + } + + private prepareIterations(inputs: NodeOutputs): { + subflowInputsList: Record[]; + outputs: Record; + } { + const outputs: Record = {}; + for (const output of this.node.outputs ?? []) { + outputs[output.title] = []; + } + + if (this.inputsToIterate.length === 0) { + throw new Error("MapNode has no inputs to iterate"); + } + + let numInputsToIterate: number | undefined; + for (const inputName of this.inputsToIterate) { + const iterable = inputs[inputName]; + const size = + Array.isArray(iterable) || typeof iterable === "string" + ? iterable.length + : undefined; + if (size === undefined) { + // Python raises a TypeError from `len()` here; the adapter names the + // node and the offending input instead. + throw new Error( + `MapNode \`${this.node.name}\` cannot iterate over input ` + + `\`${inputName}\`: ${stringifyTemplateValue(iterable)} has no length`, + ); + } + if (numInputsToIterate === undefined) { + numInputsToIterate = size; + } else if (size !== numInputsToIterate) { + throw new Error( + `Found inputs to iterate with different sizes (${stringifyTemplateValue(iterable)} and ${numInputsToIterate})`, + ); + } + } + if (numInputsToIterate === undefined) { + throw new Error( + "MapNode inputs_to_iterate did not match any provided inputs", + ); + } + + const subflowInputsList: Record[] = []; + for (let i = 0; i < numInputsToIterate; i += 1) { + const subInputs: Record = {}; + for (const inputProperty of this.node.inputs ?? []) { + const title = inputProperty.title; + // Note: Python strips every `iterated_` occurrence here (str.replace + // with no count), not just the prefix. + const subflowInputName = title.replaceAll("iterated_", ""); + if (this.inputsToIterate.includes(title)) { + const collection = inputs[title]; + subInputs[subflowInputName] = Array.isArray(collection) + ? collection[i] + : typeof collection === "string" + ? collection[i] + : undefined; + } else { + subInputs[subflowInputName] = inputs[title]; + } + } + subflowInputsList.push(subInputs); + } + return { subflowInputsList, outputs }; + } + + private accumulateOutputs( + outputs: Record, + subflowOutputs: Record, + ): void { + for (const [outputName, outputValue] of Object.entries(subflowOutputs)) { + const collectedOutputName = `collected_${outputName}`; + // Not all outputs might be exposed: keep only those the node declares. + const collected = outputs[collectedOutputName]; + if (collected !== undefined) { + collected.push(outputValue); + } + } + } + + protected async _execute( + inputs: NodeOutputs, + messages: BaseMessage[], + ): Promise { + const { subflowInputsList, outputs } = this.prepareIterations(inputs); + for (const subflowInputs of subflowInputsList) { + const subflowResult = await this.subflow.invoke({ + inputs: subflowInputs, + messages, + }); + const subflowOutputs = subflowResult["outputs"]; + if (isPlainRecord(subflowOutputs)) { + this.accumulateOutputs(outputs, subflowOutputs); + } + } + return [outputs, {}]; + } +} diff --git a/tsagentspec/src/adapters/langgraph/node-execution/tool-node.ts b/tsagentspec/src/adapters/langgraph/node-execution/tool-node.ts new file mode 100644 index 00000000..bfd08e50 --- /dev/null +++ b/tsagentspec/src/adapters/langgraph/node-execution/tool-node.ts @@ -0,0 +1,167 @@ +/** + * ToolNode executor for the LangGraph adapter. + * + * Port of `pyagentspec.adapters.langgraph._node_execution.ToolNodeExecutor`. + * Runtime contracts (output mapping, error-message text) mirror the Python + * adapter exactly so specs behave the same across both SDKs. + * + * Divergence from Python (see the adapter README): JS has no tuple type, so + * arrays map positionally onto multiple declared tool-node outputs where + * Python only accepts tuples. + */ +import type { BaseMessage } from "@langchain/core/messages"; +import type { ToolNode } from "../../../flows/index.js"; +import { stringifyTemplateValue } from "../../common/index.js"; +import type { ExecuteOutput, NodeOutputs } from "../types.js"; +import type { InvocableGraph } from "./executor.js"; +import { NodeExecutor, isPlainRecord } from "./executor.js"; + +/** True for a list of MCP-style content blocks (text / image / file). */ +function isMcpContentBlocksList(items: unknown[]): boolean { + // Empty lists are ambiguous; treat them as non-MCP to avoid false positives + if (items.length === 0) { + return false; + } + for (const element of items) { + if (!isPlainRecord(element)) { + return false; + } + const blockType = element["type"]; + if (blockType !== "text" && blockType !== "image" && blockType !== "file") { + return false; + } + if (blockType === "text") { + if (typeof element["text"] !== "string") { + return false; + } + } else if ( + !("base64" in element) && + !("url" in element) && + !("file_id" in element) + ) { + return false; + } + } + return true; +} + +/** Extract the payload of one MCP content block. */ +function extractValueFromContentBlock(block: Record): unknown { + const blockType = block["type"]; + if (blockType === "text") { + return block["text"]; + } + if (blockType === "image" || blockType === "file") { + if ("base64" in block) { + return block["base64"]; + } + if ("url" in block) { + return block["url"]; + } + if ("file_id" in block) { + return block["file_id"]; + } + throw new Error( + `No payload found in ${blockType} block: ${JSON.stringify(block)}`, + ); + } + throw new Error( + `Unsupported message content block type: ${String(blockType)}`, + ); +} + +/** + * Executes a ToolNode: invokes the converted LangChain tool with the node + * inputs and maps the raw tool output onto the node's declared output + * properties (MCP content-block lists map positionally; dicts are filtered; + * arrays map positionally onto multiple outputs). + */ +export class ToolNodeExecutor extends NodeExecutor { + private readonly toolCallable: InvocableGraph; + + constructor(node: ToolNode, tool: unknown) { + super(node); + if ( + typeof tool !== "object" || + tool === null || + typeof (tool as { invoke?: unknown }).invoke !== "function" + ) { + throw new Error( + `ToolNodeExecutor expected a LangChain StructuredTool, but got ${typeof tool}.`, + ); + } + this.toolCallable = tool as InvocableGraph; + } + + /** Best-effort mapping of raw tool outputs to the declared node outputs. */ + private formatToolResult(toolOutput: unknown): ExecuteOutput { + const nodeOutputProperties = this.node.outputs ?? []; + let mapped: NodeOutputs; + if (Array.isArray(toolOutput) && isMcpContentBlocksList(toolOutput)) { + const extractedValues = (toolOutput as Record[]).map( + (block) => extractValueFromContentBlock(block), + ); + mapped = {}; + nodeOutputProperties.forEach((property, i) => { + if (i >= extractedValues.length) { + // Python raises a bare IndexError ("list index out of range") here. + throw new Error( + `Tool node \`${this.node.name}\` returned ${extractedValues.length} ` + + `content block(s) but declares ${nodeOutputProperties.length} ` + + `outputs; no value for output \`${property.title}\`.`, + ); + } + mapped[property.title] = extractedValues[i]; + }); + } else if (nodeOutputProperties.length === 1) { + // The tool returns a dict with a single key being the node's output + // property's title: use it as-is to avoid double-wrapping. + const onlyTitle = nodeOutputProperties[0]!.title; + if ( + isPlainRecord(toolOutput) && + Object.keys(toolOutput).length === 1 && + Object.hasOwn(toolOutput, onlyTitle) + ) { + mapped = toolOutput; + } else { + mapped = { [onlyTitle]: toolOutput }; + } + } else if (isPlainRecord(toolOutput)) { + // The node emits multiple outputs: filter the tool output. + mapped = {}; + for (const property of nodeOutputProperties) { + if (Object.hasOwn(toolOutput, property.title)) { + mapped[property.title] = toolOutput[property.title]; + } + } + } else if (Array.isArray(toolOutput)) { + // Multiple outputs from an array (Python: tuple): map positionally. + mapped = {}; + nodeOutputProperties.forEach((property, i) => { + if (i >= toolOutput.length) { + // Python raises a bare IndexError ("tuple index out of range") here. + throw new Error( + `Tool node \`${this.node.name}\` returned ${toolOutput.length} ` + + `value(s) but declares ${nodeOutputProperties.length} ` + + `outputs; no value for output \`${property.title}\`.`, + ); + } + mapped[property.title] = toolOutput[i]; + }); + } else { + throw new Error( + `Unsupported multi-output mapping for tool_output: ${stringifyTemplateValue(toolOutput)}` + + `(declared_outputs=${nodeOutputProperties.length}).`, + ); + } + return [mapped, {}]; + } + + protected async _execute( + inputs: NodeOutputs, + _messages: BaseMessage[], + ): Promise { + const toolOutput = await this.toolCallable.invoke(inputs); + return this.formatToolResult(toolOutput); + } +} diff --git a/tsagentspec/tests/adapters/langgraph/flow-nodes.test.ts b/tsagentspec/tests/adapters/langgraph/flow-nodes.test.ts index e243fde1..4f7d7cab 100644 --- a/tsagentspec/tests/adapters/langgraph/flow-nodes.test.ts +++ b/tsagentspec/tests/adapters/langgraph/flow-nodes.test.ts @@ -1119,6 +1119,40 @@ describe("MapNode", () => { ).rejects.toThrow("Found inputs to iterate with different sizes"); }); + it("raises naming the input when an iterated input has no length at runtime", async () => { + // The converter selects iterated_input statically (list-typed schema), + // but the runtime value is a scalar: the error names the node and the + // offending input instead of reusing the size-mismatch text. + const mapNode = createMapNode({ + name: "square_number_map_node", + subflow: buildSquareSubflow(), + inputs: [iteratedInput], + outputs: [collectedSquare], + }); + const inputList = listProperty({ + title: "input_list", + itemType: numberProperty({ title: "item" }), + }); + const start = ioStartNode("outer_start", [inputList]); + const end = ioEndNode("outer_end", [collectedSquare]); + const flow = createFlow({ + name: "flow to square all elements of a list", + startNode: start, + nodes: [start, mapNode, end], + controlFlowConnections: [ctrl(start, mapNode), ctrl(mapNode, end)], + dataFlowConnections: [ + dataEdge(start, mapNode, "input_list", "iterated_input"), + dataEdge(mapNode, end, "collected_input_square"), + ], + }); + + const graph = await loadFlow(flow, { toolRegistry: squareRegistry }); + await expect(graph.invoke({ inputs: { input_list: 7 } })).rejects.toThrow( + "MapNode `square_number_map_node` cannot iterate over input " + + "`iterated_input`: 7 has no length", + ); + }); + it("raises when no data-flow edge selects an input to iterate", async () => { const mapNode = createMapNode({ name: "square_map_scalar", @@ -1349,6 +1383,35 @@ describe("ApiNode", () => { expect(String(init.body)).toBe("a=1&b=static"); }); + it("POST: an empty-string Content-Type falls through to the lowercase header (Python `or` parity)", async () => { + // Python looks the content type up with `get("Content-Type") or + // get("content-type")`: an empty-string uppercase header is falsy, so + // the lowercase urlencoded header wins and dict data goes out as a form + // body (a `??` lookup would stop at the empty string and send JSON). + const echo = stringProperty({ title: "echo" }); + const apiNode = createApiNode({ + name: "api", + url: "https://api.example.com/form", + httpMethod: "POST", + data: { a: "1" }, + headers: { + "Content-Type": "", + "content-type": "application/x-www-form-urlencoded", + }, + outputs: [echo], + }); + const flow = buildApiFlow(apiNode, [], [echo]); + const graph = await loadFlow(flow); + + mockFetch = installMockFetch(() => ({ echo: "done" })); + const result = await graph.invoke({ inputs: {} }); + + expect(outputsOf(result)).toEqual({ echo: "done" }); + const init = mockFetch.calls[0]!.init!; + expect(init.body).toBeInstanceOf(URLSearchParams); + expect(String(init.body)).toBe("a=1"); + }); + it("does not follow redirects: a 3xx response body maps to the node outputs like any status", async () => { // Python's httpx does not follow redirects (follow_redirects defaults to // False) and parses the returned 3xx body like any other status; the From b70ec84a891b03080f04c015d05f28a464dbc7a2 Mon Sep 17 00:00:00 2001 From: Salah Date: Thu, 3 Sep 2026 19:11:18 +0400 Subject: [PATCH 03/14] refactor(tsagentspec): canonicalize JSON-schema comparison, derive policy groups, prune tracing seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/adapters/common/agentspec-exporter.ts | 82 +++++---- .../src/adapters/common/agentspec-loader.ts | 60 +++---- .../src/adapters/common/component-policy.ts | 119 +++++-------- tsagentspec/src/adapters/common/index.ts | 6 +- .../src/adapters/common/json-schema.ts | 161 +----------------- .../adapters/langgraph/langgraph-converter.ts | 16 +- .../src/adapters/langgraph/manager-workers.ts | 5 +- tsagentspec/src/adapters/langgraph/tracing.ts | 71 ++++---- tsagentspec/src/property.ts | 13 +- .../adapters/common/component-policy.test.ts | 32 +++- .../tests/adapters/common/json-schema.test.ts | 11 +- 11 files changed, 224 insertions(+), 352 deletions(-) diff --git a/tsagentspec/src/adapters/common/agentspec-exporter.ts b/tsagentspec/src/adapters/common/agentspec-exporter.ts index 2f75f1a5..6508eeae 100644 --- a/tsagentspec/src/adapters/common/agentspec-exporter.ts +++ b/tsagentspec/src/adapters/common/agentspec-exporter.ts @@ -58,9 +58,12 @@ export abstract class AdapterAgnosticAgentSpecExporter { runtimeComponent: unknown, options?: ExportOptions, ): string | [string, string] { - return this._export("json", runtimeComponent, options) as - | string - | [string, string]; + return this._export( + (serializer, agentspecAssistant, serializerOptions) => + serializer.toJson(agentspecAssistant, serializerOptions), + runtimeComponent, + options, + ); } /** @@ -72,9 +75,12 @@ export abstract class AdapterAgnosticAgentSpecExporter { runtimeComponent: unknown, options?: ExportOptions, ): string | [string, string] { - return this._export("yaml", runtimeComponent, options) as - | string - | [string, string]; + return this._export( + (serializer, agentspecAssistant, serializerOptions) => + serializer.toYaml(agentspecAssistant, serializerOptions), + runtimeComponent, + options, + ); } /** @@ -86,9 +92,26 @@ export abstract class AdapterAgnosticAgentSpecExporter { runtimeComponent: unknown, options?: ExportOptions, ): ExportedDict | [ExportedDict, ExportedDict] { - return this._export("dict", runtimeComponent, options) as - | ExportedDict - | [ExportedDict, ExportedDict]; + return this._export( + ( + serializer, + agentspecAssistant, + serializerOptions, + ): ExportedDict | [ExportedDict, ExportedDict] => { + // The TS AgentSpecSerializer has no public toDict, so the dictionary + // form is derived from the JSON serialization. + const json = serializer.toJson(agentspecAssistant, serializerOptions); + if (Array.isArray(json)) { + return [ + JSON.parse(json[0]) as ExportedDict, + JSON.parse(json[1]) as ExportedDict, + ]; + } + return JSON.parse(json) as ExportedDict; + }, + runtimeComponent, + options, + ); } /** @@ -100,19 +123,22 @@ export abstract class AdapterAgnosticAgentSpecExporter { } /** - * Common implementation of the export methods. The returned type depends on - * the type of exporter. + * Common implementation of the export methods. Each public method passes + * the closure that serializes the converted component to its output form. */ - protected _export( - exporter: "json" | "yaml" | "dict", + protected _export( + serialize: ( + serializer: AgentSpecSerializer, + agentspecAssistant: ComponentBase, + serializerOptions: { + agentspecVersion?: AgentSpecVersion; + disaggregatedComponents?: DisaggregatedComponentsConfig; + exportDisaggregatedComponents: boolean; + }, + ) => SerializedT, runtimeComponent: unknown, options?: ExportOptions, - ): string | [string, string] | ExportedDict | [ExportedDict, ExportedDict] { - if (exporter !== "json" && exporter !== "yaml" && exporter !== "dict") { - throw new Error( - `Unsupported exporter type: \`${String(exporter)}\`. Expected \`dict\`, \`json\`, or \`yaml\`.`, - ); - } + ): SerializedT { const serializer = new AgentSpecSerializer(this.plugins); const [convertedDisagComponents, referencedComponents] = @@ -129,23 +155,7 @@ export abstract class AdapterAgnosticAgentSpecExporter { exportDisaggregatedComponents: options?.exportDisaggregatedComponents ?? false, }; - - if (exporter === "yaml") { - return serializer.toYaml(agentspecAssistant, serializerOptions); - } - const json = serializer.toJson(agentspecAssistant, serializerOptions); - if (exporter === "json") { - return json; - } - // "dict": the TS AgentSpecSerializer has no public toDict, so the - // dictionary form is derived from the JSON serialization. - if (Array.isArray(json)) { - return [ - JSON.parse(json[0]) as ExportedDict, - JSON.parse(json[1]) as ExportedDict, - ]; - } - return JSON.parse(json) as ExportedDict; + return serialize(serializer, agentspecAssistant, serializerOptions); } /** diff --git a/tsagentspec/src/adapters/common/agentspec-loader.ts b/tsagentspec/src/adapters/common/agentspec-loader.ts index 362a298e..0770e61d 100644 --- a/tsagentspec/src/adapters/common/agentspec-loader.ts +++ b/tsagentspec/src/adapters/common/agentspec-loader.ts @@ -102,7 +102,11 @@ export abstract class AdapterAgnosticAgentSpecLoader { serializedAssistant: string, options?: LoadOptions, ): Promise { - return this._load("yaml", serializedAssistant, options); + return this._load( + (deserializer, deserializeOptions) => + deserializer.fromYaml(serializedAssistant, deserializeOptions), + options, + ); } /** @@ -113,7 +117,11 @@ export abstract class AdapterAgnosticAgentSpecLoader { serializedAssistant: string, options?: LoadOptions, ): Promise { - return this._load("json", serializedAssistant, options); + return this._load( + (deserializer, deserializeOptions) => + deserializer.fromJson(serializedAssistant, deserializeOptions), + options, + ); } /** @@ -124,7 +132,14 @@ export abstract class AdapterAgnosticAgentSpecLoader { serializedAssistant: Record, options?: LoadOptions, ): Promise { - return this._load("dict", serializedAssistant, options); + // The TS AgentSpecDeserializer has no public dict entry point; round-trip + // through JSON. + const json = JSON.stringify(serializedAssistant); + return this._load( + (deserializer, deserializeOptions) => + deserializer.fromJson(json, deserializeOptions), + options, + ); } /** @@ -173,34 +188,21 @@ export abstract class AdapterAgnosticAgentSpecLoader { return convertedRegistry; } - /** Common implementation of the load methods. */ + /** + * Common implementation of the load methods. Each public method passes the + * closure that runs its deserializer entry point. + */ protected async _load( - loader: "yaml" | "json" | "dict", - serializedAssistant: string | Record, + deserialize: ( + deserializer: AgentSpecDeserializer, + deserializeOptions: { + componentsRegistry?: ComponentsRegistry; + importOnlyReferencedComponents?: boolean; + }, + ) => ComponentBase | Record, options?: LoadOptions, ): Promise { const deserializer = new AgentSpecDeserializer(this.plugins); - let deserialize: (deserializeOptions: { - componentsRegistry?: ComponentsRegistry; - importOnlyReferencedComponents?: boolean; - }) => ComponentBase | Record; - if (loader === "yaml") { - deserialize = (deserializeOptions) => - deserializer.fromYaml(serializedAssistant as string, deserializeOptions); - } else if (loader === "json") { - deserialize = (deserializeOptions) => - deserializer.fromJson(serializedAssistant as string, deserializeOptions); - } else if (loader === "dict") { - // The TS AgentSpecDeserializer has no public dict entry point; - // round-trip through JSON. - const json = JSON.stringify(serializedAssistant); - deserialize = (deserializeOptions) => - deserializer.fromJson(json, deserializeOptions); - } else { - throw new Error( - `Unsupported loader type: \`${String(loader)}\`. Expected \`dict\`, \`json\`, or \`yaml\`.`, - ); - } const convertedRegistry = options?.componentsRegistry !== undefined @@ -209,7 +211,7 @@ export abstract class AdapterAgnosticAgentSpecLoader { if (options?.importOnlyReferencedComponents) { // Loading the disaggregated components - const referencedComponentsDict = deserialize({ + const referencedComponentsDict = deserialize(deserializer, { componentsRegistry: convertedRegistry, importOnlyReferencedComponents: true, }) as Record; @@ -223,7 +225,7 @@ export abstract class AdapterAgnosticAgentSpecLoader { return runtimeComponents; } - const agentspecComponent = deserialize({ + const agentspecComponent = deserialize(deserializer, { componentsRegistry: convertedRegistry, importOnlyReferencedComponents: false, }) as ComponentBase; diff --git a/tsagentspec/src/adapters/common/component-policy.ts b/tsagentspec/src/adapters/common/component-policy.ts index e19d0b63..bb555326 100644 --- a/tsagentspec/src/adapters/common/component-policy.ts +++ b/tsagentspec/src/adapters/common/component-policy.ts @@ -11,9 +11,20 @@ * known concrete type nor a group match only that exact serialized * componentType (distance 0), like unresolved names in Python. */ +import { AgenticComponentUnion } from "../../agents/index.js"; import type { ComponentBase } from "../../component.js"; +import { NodeUnion } from "../../flows/nodes/index.js"; +import { LlmConfigUnion } from "../../llms/index.js"; +import { OciClientConfigUnion } from "../../llms/oci-client-config.js"; +import { ClientTransportUnion } from "../../mcp/client-transport.js"; import { getChildrenFromFieldValue } from "../../serialization/referencing.js"; import { OPAQUE_FIELDS } from "../../serialization/types.js"; +import { ToolUnion } from "../../tools/index.js"; +import { ToolBoxUnion } from "../../tools/toolbox.js"; +import { + MessageTransformUnion, + SupportedDatastoresSchema, +} from "../../transforms/message-transform.js"; /** A single policy entry: a concrete or abstract componentType name. */ export type ComponentPolicyEntry = string; @@ -27,93 +38,53 @@ const CONCRETE_MATCH_DISTANCE = 0; const ABSTRACT_GROUP_MATCH_DISTANCE = 1; const WILDCARD_MATCH_DISTANCE = 2; -// Concrete members of each AgenticComponentUnion entry (src/agents/index.ts). -const AGENTIC_COMPONENT_TYPES = [ - "Agent", - "Swarm", - "ManagerWorkers", - "RemoteAgent", - "A2AAgent", - "SpecializedAgent", -]; +/** + * The structural surface of the SDK's runtime discriminated unions: every + * member is a Zod object whose `componentType` is a string literal. + */ +interface ComponentTypeUnion { + options: ReadonlyArray<{ shape: { componentType: { value: string } } }>; +} -// Concrete members of NodeUnion (src/flows/nodes/index.ts). -const NODE_TYPES = [ - "StartNode", - "EndNode", - "LlmNode", - "ToolNode", - "AgentNode", - "FlowNode", - "BranchingNode", - "MapNode", - "ParallelMapNode", - "ParallelFlowNode", - "ApiNode", - "InputMessageNode", - "OutputMessageNode", - "CatchExceptionNode", -]; +/** Concrete componentType names of the members of a discriminated union. */ +function unionMemberTypes(union: ComponentTypeUnion): ReadonlySet { + return new Set( + union.options.map((option) => option.shape.componentType.value), + ); +} -// Concrete members of ToolUnion (src/tools/index.ts). -const TOOL_TYPES = ["ServerTool", "ClientTool", "RemoteTool", "BuiltinTool", "MCPTool"]; +// Derived at module load from the SDK's runtime discriminated unions so a +// new union member can never silently escape a group-level policy entry. +const AGENTIC_COMPONENT_TYPES = unionMemberTypes(AgenticComponentUnion); +const NODE_TYPES = unionMemberTypes(NodeUnion); +const TOOL_TYPES = unionMemberTypes(ToolUnion); /** * Membership of the SDK's abstract component groups, keyed by - * `AbstractComponentType` name (src/component.ts). Hardcoded from the SDK's - * discriminated unions: - * - AgenticComponentUnion (src/agents/index.ts) - * - NodeUnion (src/flows/nodes/index.ts) - * - ToolUnion (src/tools/index.ts) - * - LlmConfigUnion (src/llms/index.ts) - * - ToolBoxUnion (src/tools/toolbox.ts) - * - OciClientConfigUnion (src/llms/oci-client-config.ts) - * - ClientTransportUnion (src/mcp/client-transport.ts) - * - SupportedDatastoresSchema (src/transforms/message-transform.ts) - * - MessageTransformUnion (src/transforms/message-transform.ts) + * `AbstractComponentType` name (src/component.ts), each derived from the + * runtime discriminated union owning that group. */ const ABSTRACT_COMPONENT_GROUPS: Record> = { - AgenticComponent: new Set(AGENTIC_COMPONENT_TYPES), - Node: new Set(NODE_TYPES), - Tool: new Set(TOOL_TYPES), - LlmConfig: new Set([ - "OpenAiCompatibleConfig", - "OllamaConfig", - "VllmConfig", - "OpenAiConfig", - "OciGenAiConfig", - ]), - ToolBox: new Set(["MCPToolBox"]), - OciClientConfig: new Set([ - "OciClientConfigWithApiKey", - "OciClientConfigWithInstancePrincipal", - "OciClientConfigWithResourcePrincipal", - "OciClientConfigWithSecurityToken", - ]), - ClientTransport: new Set([ - "StdioTransport", - "SSETransport", - "SSEmTLSTransport", - "StreamableHTTPTransport", - "StreamableHTTPmTLSTransport", - "RemoteTransport", - ]), - Datastore: new Set([ - "InMemoryCollectionDatastore", - "OracleDatabaseDatastore", - "PostgresDatabaseDatastore", - ]), - MessageTransform: new Set([ - "MessageSummarizationTransform", - "ConversationSummarizationTransform", - ]), + AgenticComponent: AGENTIC_COMPONENT_TYPES, + Node: NODE_TYPES, + Tool: TOOL_TYPES, + LlmConfig: unionMemberTypes(LlmConfigUnion), + ToolBox: unionMemberTypes(ToolBoxUnion), + OciClientConfig: unionMemberTypes(OciClientConfigUnion), + ClientTransport: unionMemberTypes(ClientTransportUnion), + Datastore: unionMemberTypes(SupportedDatastoresSchema), + MessageTransform: unionMemberTypes(MessageTransformUnion), }; /** * Every builtin componentType extending ComponentWithIOSchema (schemas built * on ComponentWithIOSchema / ToolBaseSchema / NodeBaseSchema across src/). + * The three tail entries have no runtime union to derive from; a unit test + * (tests/adapters/common/component-policy.test.ts) pins this set against the + * `BUILTIN_SCHEMA_MAP` schemas that carry inputs/outputs so drift fails + * loudly. Exported for that test only. */ -const COMPONENT_WITH_IO_TYPES: ReadonlySet = new Set([ +export const COMPONENT_WITH_IO_TYPES: ReadonlySet = new Set([ ...AGENTIC_COMPONENT_TYPES, ...NODE_TYPES, ...TOOL_TYPES, diff --git a/tsagentspec/src/adapters/common/index.ts b/tsagentspec/src/adapters/common/index.ts index 01202bb6..581c0387 100644 --- a/tsagentspec/src/adapters/common/index.ts +++ b/tsagentspec/src/adapters/common/index.ts @@ -23,10 +23,8 @@ export { type ComponentPolicyEntry, type ComponentPolicyInput, } from "./component-policy.js"; -export { - jsonSchemasHaveSameType, - buildJsonSchemaFromProperties, -} from "./json-schema.js"; +export { jsonSchemasHaveSameType } from "../../property.js"; +export { buildJsonSchemaFromProperties } from "./json-schema.js"; export { DEFAULT_HTTP_REQUEST_TIMEOUT_MS, buildTemplatedHttpRequest, diff --git a/tsagentspec/src/adapters/common/json-schema.ts b/tsagentspec/src/adapters/common/json-schema.ts index d01238be..c820b7c2 100644 --- a/tsagentspec/src/adapters/common/json-schema.ts +++ b/tsagentspec/src/adapters/common/json-schema.ts @@ -1,167 +1,14 @@ /** * JSON-schema helpers shared by the AgentSpec adapters. * - * `jsonSchemasHaveSameType` ports `pyagentspec.property.json_schemas_have_same_type`; * `buildJsonSchemaFromProperties` builds an object schema from AgentSpec - * properties, suitable as a LangChain tool argument schema. + * properties, suitable as a LangChain tool argument schema. Schema *type + * comparison* lives in the SDK's canonical property layer: + * `jsonSchemasHaveSameType` in `src/property.ts` (re-exported through + * `common/index.ts`). */ import type { JsonSchemaValue, Property } from "../../property.js"; -const MAX_JSON_SCHEMA_UNION_TYPE_ALLOWED_LENGTH = 100; - -/** - * Normalization merges the basic types and anyOf for a schema and returns a - * list containing all the schemas. - */ -function normalizeJsonSchemaUnionTypes( - schema: JsonSchemaValue, -): JsonSchemaValue[] { - const jsonSchemaType = schema["type"] ?? []; - const jsonSchemaTypes: unknown[] = Array.isArray(jsonSchemaType) - ? jsonSchemaType - : [jsonSchemaType]; - - const allTypes: JsonSchemaValue[] = [ - ...((schema["anyOf"] as JsonSchemaValue[] | undefined) ?? []), - ]; - for (const type of jsonSchemaTypes) { - if (type === "array") { - // If one of the basic types is array, we put the items definition in it - allTypes.push({ type: "array", items: schema["items"] ?? {} }); - } else if (type === "object") { - // If one of the basic types is object, we put the properties definition in it - allTypes.push({ - type: "object", - properties: schema["properties"] ?? {}, - additionalProperties: schema["additionalProperties"] ?? false, - }); - } else { - // Normally we just carry over the basic type - allTypes.push({ type }); - } - } - - if (allTypes.length > MAX_JSON_SCHEMA_UNION_TYPE_ALLOWED_LENGTH) { - throw new Error( - `The schema is the union of more than ${MAX_JSON_SCHEMA_UNION_TYPE_ALLOWED_LENGTH}` + - " types. This is not supported. Please consider simplifying the type definition or" + - " using 'Any'.", - ); - } - return allTypes; -} - -/** Check if the two schemas define the same type. */ -export function jsonSchemasHaveSameType( - jsonSchemaA: JsonSchemaValue, - jsonSchemaB: JsonSchemaValue, -): boolean { - if ("allOf" in jsonSchemaA || "allOf" in jsonSchemaB) { - throw new Error("Support for schemas using allOf is not implemented."); - } - if ("oneOf" in jsonSchemaA || "oneOf" in jsonSchemaB) { - throw new Error("Support for schemas using oneOf is not implemented."); - } - - // Basic types must match - if ( - "anyOf" in jsonSchemaA || - Array.isArray(jsonSchemaA["type"]) || - "anyOf" in jsonSchemaB || - Array.isArray(jsonSchemaB["type"]) - ) { - // We need to combine anyOf and the list of types specified in type. - // We normalize them to other json schemas, so that we can compare them - // afterward using this method. - const aTypeList = normalizeJsonSchemaUnionTypes(jsonSchemaA); - const bTypeList = normalizeJsonSchemaUnionTypes(jsonSchemaB); - // We make sure that the sets of possible types overlap correctly (same - // elements). We cannot check the length directly, as the same type could - // be repeated. - for (const aType of aTypeList) { - if (!bTypeList.some((bType) => jsonSchemasHaveSameType(aType, bType))) { - return false; - } - } - for (const bType of bTypeList) { - if (!aTypeList.some((aType) => jsonSchemasHaveSameType(aType, bType))) { - return false; - } - } - // We flattened everything in the anyOf, so no need to go on with the checks - return true; - } - if (jsonSchemaA["type"] !== jsonSchemaB["type"]) { - return false; - } - - // If it's an array, the items type must match - if ("items" in jsonSchemaA || "items" in jsonSchemaB) { - if ( - !jsonSchemasHaveSameType( - (jsonSchemaA["items"] as JsonSchemaValue | undefined) ?? {}, - (jsonSchemaB["items"] as JsonSchemaValue | undefined) ?? {}, - ) - ) { - return false; - } - } - - // If it's an object, the set of properties must match, and their types must match too - if ("properties" in jsonSchemaA || "properties" in jsonSchemaB) { - const aProperties = (jsonSchemaA["properties"] ?? {}) as Record< - string, - JsonSchemaValue - >; - const bProperties = (jsonSchemaB["properties"] ?? {}) as Record< - string, - JsonSchemaValue - >; - const aKeys = Object.keys(aProperties).sort(); - const bKeys = Object.keys(bProperties).sort(); - if ( - aKeys.length !== bKeys.length || - aKeys.some((key, index) => key !== bKeys[index]) - ) { - return false; - } - for (const propertyName of aKeys) { - if ( - !jsonSchemasHaveSameType( - aProperties[propertyName]!, - bProperties[propertyName]!, - ) - ) { - return false; - } - } - } - - if ( - "additionalProperties" in jsonSchemaA || - "additionalProperties" in jsonSchemaB - ) { - const aAdditionalProperties = jsonSchemaA["additionalProperties"] ?? {}; - const bAdditionalProperties = jsonSchemaB["additionalProperties"] ?? {}; - // If any of the two additional properties is a boolean, check strict equality - if ( - typeof aAdditionalProperties === "boolean" || - typeof bAdditionalProperties === "boolean" - ) { - return aAdditionalProperties === bAdditionalProperties; - } - if ( - !jsonSchemasHaveSameType( - aAdditionalProperties as JsonSchemaValue, - bAdditionalProperties as JsonSchemaValue, - ) - ) { - return false; - } - } - return true; -} - /** * Build an object JSON schema from AgentSpec properties, suitable as a * LangChain tool argument schema. Each property contributes its own diff --git a/tsagentspec/src/adapters/langgraph/langgraph-converter.ts b/tsagentspec/src/adapters/langgraph/langgraph-converter.ts index 9886f465..72e6a4e1 100644 --- a/tsagentspec/src/adapters/langgraph/langgraph-converter.ts +++ b/tsagentspec/src/adapters/langgraph/langgraph-converter.ts @@ -20,8 +20,10 @@ * schema (`Annotation.Root` is silently ignored by the JS `createAgent`); * the langchain JS agent state has no `remaining_steps` channel, so no such * key is added. - * - No tracing callbacks/spans are attached; `patchWithExecutionSpan` is a - * no-op seam invoked at the same sites as Python. + * - No tracing callbacks/spans are attached; `patchWithExecutionSpan` is an + * identity seam invoked at the same graph-compilation sites as Python with + * the compiled-from component, while LLM/tool callback attachment has no + * seam at all (see `tracing.ts`). * - Python's "async interrupts on Python < 3.11" load-time warning has no JS * equivalent and is not ported. */ @@ -501,7 +503,10 @@ export class AgentSpecToLangGraphConverter { createAgentParams as unknown as Parameters[0], ); applyPythonToolErrorSemantics(reactAgent); - return patchWithExecutionSpan(reactAgent); + return patchWithExecutionSpan(reactAgent, { + kind: "agent", + component: info.agent, + }); } private async convertAgent( @@ -783,7 +788,10 @@ export class AgentSpecToLangGraphConverter { ? { checkpointer: context.checkpointer } : {}, ); - return patchWithExecutionSpan(compiledGraph); + return patchWithExecutionSpan(compiledGraph, { + kind: "flow", + component: flow, + }); } /** Add one conditional edge per source node, routing on the last branch. */ diff --git a/tsagentspec/src/adapters/langgraph/manager-workers.ts b/tsagentspec/src/adapters/langgraph/manager-workers.ts index 69bd6953..e9f08ffd 100644 --- a/tsagentspec/src/adapters/langgraph/manager-workers.ts +++ b/tsagentspec/src/adapters/langgraph/manager-workers.ts @@ -410,7 +410,10 @@ export async function compileManagerWorkers( : {}), name: managerWorkers.name, }); - return patchWithExecutionSpan(compiledGraph); + return patchWithExecutionSpan(compiledGraph, { + kind: "manager-workers", + component: managerWorkers, + }); } /** diff --git a/tsagentspec/src/adapters/langgraph/tracing.ts b/tsagentspec/src/adapters/langgraph/tracing.ts index b03ddef7..e0457f5d 100644 --- a/tsagentspec/src/adapters/langgraph/tracing.ts +++ b/tsagentspec/src/adapters/langgraph/tracing.ts @@ -1,55 +1,48 @@ /** - * Tracing seams for the LangGraph adapter. + * Tracing seam for the LangGraph adapter. * - * The Python adapter attaches tracing callbacks and execution spans at three - * kinds of sites: LLM callbacks on every converted chat model, tool callbacks - * on converted server/remote/MCP tools, and stream-wrapping execution spans on - * every compiled agent / flow / manager-workers graph. + * The Python adapter wraps every compiled agent / flow / manager-workers + * graph in an execution span (patching `stream`/`astream`). The TypeScript + * SDK has no tracing package yet, so `patchWithExecutionSpan` is an identity + * seam: it is invoked from the same graph-compilation sites as Python, and + * receives the Agent Spec component the graph was compiled from, so a future + * port of `pyagentspec.tracing` only needs to fill in the implementation here + * without touching the converter. * - * The TypeScript SDK has no tracing package yet, so these functions are no-op - * seams: they are invoked from the exact same attachment sites as Python so - * that a future port of `pyagentspec.tracing` only needs to fill in the - * implementations here (returning real `BaseCallbackHandler`s and wrapping - * `stream`/`streamEvents` in execution spans) without touching the converter. + * Python's LLM and tool callback handlers are NOT seamed here: no callbacks + * are attached in this adapter (see the divergence notes in `llm.ts`, + * `tools.ts` and `mcp.ts`), so a tracing port must add those attachment + * sites itself. */ -import type { LlmConfig } from "../../llms/index.js"; -import type { Tool } from "../../tools/index.js"; +import type { Agent, ManagerWorkers } from "../../agents/index.js"; +import type { Flow } from "../../flows/index.js"; /** - * Build the tracing callbacks to attach to a chat model created for the given - * Agent Spec LLM config. + * Which execution span Python opens around a compiled graph, and the Agent + * Spec component that span reports on. * - * Python attaches an `AgentSpecLlmCallbackHandler` emitting - * `LlmGenerationRequest` / `LlmGenerationChunkReceived` / - * `LlmGenerationResponse` events inside an `LlmGenerationSpan`. No-op until - * the tracing package is ported. + * Python opens an `AgentExecutionSpan` for react agents, a + * `FlowExecutionSpan` for compiled flows and a `ManagerWorkersExecutionSpan` + * for hierarchical manager-workers graphs. */ -export function buildLlmCallbacks(_llmConfig: LlmConfig): unknown[] { - return []; -} - -/** - * Build the tracing callbacks to attach to a LangChain tool created for the - * given Agent Spec tool. - * - * Python attaches an `AgentSpecToolCallbackHandler` emitting - * `ToolExecutionRequest` / `ToolExecutionResponse` events inside a - * `ToolExecutionSpan`. No-op until the tracing package is ported. - */ -export function buildToolCallbacks(_tool: Tool): unknown[] { - return []; -} +export type ExecutionSpanTarget = + | { kind: "agent"; component: Agent } + | { kind: "flow"; component: Flow } + | { kind: "manager-workers"; component: ManagerWorkers }; /** * Wrap a compiled graph (or react agent) so each run is traced inside an * execution span. * - * Python monkey-patches `stream`/`astream` to open an - * `AgentExecutionSpan` / `FlowExecutionSpan` / `ManagerWorkersExecutionSpan`, - * emit the start event with the invocation inputs, fold the streamed chunks - * into a final state and emit the end event with the run outputs. Returns the - * graph unchanged until the tracing package is ported. + * Python monkey-patches `stream`/`astream` to open the span named by + * `target.kind` for `target.component`, emit the start event with the + * invocation inputs, fold the streamed chunks into a final state and emit the + * end event with the run outputs. Returns the graph unchanged until the + * tracing package is ported. */ -export function patchWithExecutionSpan(graph: T): T { +export function patchWithExecutionSpan( + graph: T, + _target: ExecutionSpanTarget, +): T { return graph; } diff --git a/tsagentspec/src/property.ts b/tsagentspec/src/property.ts index f2694203..7fc0035a 100644 --- a/tsagentspec/src/property.ts +++ b/tsagentspec/src/property.ts @@ -248,6 +248,9 @@ export function propertyFromJsonSchema(jsonSchema: JsonSchemaValue): Property { // --- Comparison helpers --- +// Mirrors pyagentspec/property.py MAX_JSON_SCHEMA_UNION_TYPE_ALLOWED_LENGTH. +const MAX_JSON_SCHEMA_UNION_TYPE_ALLOWED_LENGTH = 100; + function normalizeUnionTypes( schema: JsonSchemaValue, ): JsonSchemaValue[] { @@ -276,10 +279,18 @@ function normalizeUnionTypes( } } + if (allTypes.length > MAX_JSON_SCHEMA_UNION_TYPE_ALLOWED_LENGTH) { + throw new Error( + `The schema is the union of more than ${MAX_JSON_SCHEMA_UNION_TYPE_ALLOWED_LENGTH}` + + " types. This is not supported. Please consider simplifying the type definition or" + + " using 'Any'.", + ); + } return allTypes; } -function jsonSchemasHaveSameType( +/** Check if the two schemas define the same type. */ +export function jsonSchemasHaveSameType( a: JsonSchemaValue, b: JsonSchemaValue, ): boolean { diff --git a/tsagentspec/tests/adapters/common/component-policy.test.ts b/tsagentspec/tests/adapters/common/component-policy.test.ts index f739b913..d18f7e97 100644 --- a/tsagentspec/tests/adapters/common/component-policy.test.ts +++ b/tsagentspec/tests/adapters/common/component-policy.test.ts @@ -8,13 +8,18 @@ * loaders block `StdioTransport` by default. */ import { describe, expect, it } from "vitest"; +import { z } from "zod"; import { + BUILTIN_SCHEMA_MAP, createAgent, createMCPTool, createStdioTransport, createVllmConfig, } from "../../../src/index.js"; -import { ComponentLoadPolicy } from "../../../src/adapters/common/component-policy.js"; +import { + COMPONENT_WITH_IO_TYPES, + ComponentLoadPolicy, +} from "../../../src/adapters/common/component-policy.js"; import { AgentSpecLoader } from "../../../src/adapters/langgraph/agentspec-loader.js"; function blockedError(componentType: string): string { @@ -181,6 +186,31 @@ describe("ComponentLoadPolicy.validateComponentTree", () => { }); }); +describe("COMPONENT_WITH_IO_TYPES drift pin", () => { + // The abstract policy groups are derived from the SDK's runtime + // discriminated unions at module load, but the ComponentWithIO wildcard set + // has no owning union: its tail entries (MCPToolSpec, Flow, + // AgentSpecializationParameters) are hand-listed. This test derives the + // real membership from the builtin schemas so any drift fails loudly. + it("matches the BUILTIN_SCHEMA_MAP schemas that declare inputs/outputs", () => { + const derived = new Set(); + for (const [componentType, schema] of Object.entries(BUILTIN_SCHEMA_MAP)) { + let unwrapped: z.ZodTypeAny = schema as z.ZodTypeAny; + while (unwrapped instanceof z.ZodEffects) { + unwrapped = unwrapped._def.schema as z.ZodTypeAny; + } + if ( + unwrapped instanceof z.ZodObject && + "inputs" in unwrapped.shape && + "outputs" in unwrapped.shape + ) { + derived.add(componentType); + } + } + expect([...COMPONENT_WITH_IO_TYPES].sort()).toEqual([...derived].sort()); + }); +}); + describe("loader default policy", () => { it("blocks StdioTransport by default", () => { const loader = new AgentSpecLoader(); diff --git a/tsagentspec/tests/adapters/common/json-schema.test.ts b/tsagentspec/tests/adapters/common/json-schema.test.ts index dc107f3a..83698ac3 100644 --- a/tsagentspec/tests/adapters/common/json-schema.test.ts +++ b/tsagentspec/tests/adapters/common/json-schema.test.ts @@ -1,8 +1,9 @@ /** * Tests for the shared JSON-schema helpers. * - * `jsonSchemasHaveSameType` ports - * `pyagentspec.property.json_schemas_have_same_type`; + * `jsonSchemasHaveSameType` is the SDK's canonical port of + * `pyagentspec.property.json_schemas_have_same_type` (in `src/property.ts`, + * re-exported through the adapter common barrel); * `buildJsonSchemaFromProperties` builds LangChain tool argument schemas from * AgentSpec properties (defaults excluded from `required`, mirroring the * Python generated pydantic models). @@ -10,10 +11,8 @@ import { describe, expect, it } from "vitest"; import type { JsonSchemaValue } from "../../../src/index.js"; import { integerProperty, stringProperty } from "../../../src/index.js"; -import { - buildJsonSchemaFromProperties, - jsonSchemasHaveSameType, -} from "../../../src/adapters/common/json-schema.js"; +import { jsonSchemasHaveSameType } from "../../../src/property.js"; +import { buildJsonSchemaFromProperties } from "../../../src/adapters/common/json-schema.js"; describe("jsonSchemasHaveSameType", () => { it("matches identical basic types and rejects different ones", () => { From 741ac4623591bc42f7fabbb36cba71513b7960cd Mon Sep 17 00:00:00 2001 From: Salah Date: Thu, 3 Sep 2026 23:36:25 +0400 Subject: [PATCH 04/14] refactor(tsagentspec/adapters): tighten converter boundaries and dedupe 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. --- .../src/adapters/common/component-policy.ts | 20 +- tsagentspec/src/adapters/common/guards.ts | 30 + tsagentspec/src/adapters/common/index.ts | 5 + .../src/adapters/common/optional-peer.ts | 29 + tsagentspec/src/adapters/common/templating.ts | 9 +- .../src/adapters/common/tools-common.ts | 9 +- .../langgraph/agentspec-converter-flow.ts | 117 +- .../adapters/langgraph/agentspec-converter.ts | 40 +- .../adapters/langgraph/agentspec-loader.ts | 9 + .../adapters/langgraph/graph-introspection.ts | 103 ++ .../langgraph/langgraph-converter-flow.ts | 256 +++ .../adapters/langgraph/langgraph-converter.ts | 549 ++---- tsagentspec/src/adapters/langgraph/llm.ts | 89 +- .../src/adapters/langgraph/manager-workers.ts | 95 +- tsagentspec/src/adapters/langgraph/mcp.ts | 23 +- .../langgraph/node-execution/agent-node.ts | 51 +- .../langgraph/node-execution/executor.ts | 20 +- .../langgraph/node-execution/llm-node.ts | 6 +- .../langgraph/node-execution/subflow-nodes.ts | 22 +- .../langgraph/node-execution/tool-node.ts | 13 +- tsagentspec/src/adapters/langgraph/tools.ts | 60 +- tsagentspec/src/adapters/langgraph/types.ts | 42 +- .../adapters/langgraph/flow-nodes.test.ts | 1497 ----------------- .../langgraph/flow-nodes/agent-node.test.ts | 150 ++ .../langgraph/flow-nodes/api-node.test.ts | 344 ++++ .../flow-nodes/branching-node.test.ts | 98 ++ .../flow-nodes/catch-exception-node.test.ts | 196 +++ .../langgraph/flow-nodes/flow-node.test.ts | 54 + .../langgraph/flow-nodes/llm-node.test.ts | 145 ++ .../langgraph/flow-nodes/map-node.test.ts | 250 +++ .../langgraph/flow-nodes/message-node.test.ts | 98 ++ .../langgraph/flow-nodes/tool-node.test.ts | 248 +++ .../adapters/langgraph/flow-state.test.ts | 97 +- .../tests/adapters/langgraph/llm.test.ts | 25 - .../adapters/langgraph/loader-agent.test.ts | 7 +- .../langgraph/manager-workers.test.ts | 6 +- .../tests/adapters/langgraph/test-helpers.ts | 110 +- 37 files changed, 2504 insertions(+), 2418 deletions(-) create mode 100644 tsagentspec/src/adapters/common/guards.ts create mode 100644 tsagentspec/src/adapters/common/optional-peer.ts create mode 100644 tsagentspec/src/adapters/langgraph/graph-introspection.ts create mode 100644 tsagentspec/src/adapters/langgraph/langgraph-converter-flow.ts delete mode 100644 tsagentspec/tests/adapters/langgraph/flow-nodes.test.ts create mode 100644 tsagentspec/tests/adapters/langgraph/flow-nodes/agent-node.test.ts create mode 100644 tsagentspec/tests/adapters/langgraph/flow-nodes/api-node.test.ts create mode 100644 tsagentspec/tests/adapters/langgraph/flow-nodes/branching-node.test.ts create mode 100644 tsagentspec/tests/adapters/langgraph/flow-nodes/catch-exception-node.test.ts create mode 100644 tsagentspec/tests/adapters/langgraph/flow-nodes/flow-node.test.ts create mode 100644 tsagentspec/tests/adapters/langgraph/flow-nodes/llm-node.test.ts create mode 100644 tsagentspec/tests/adapters/langgraph/flow-nodes/map-node.test.ts create mode 100644 tsagentspec/tests/adapters/langgraph/flow-nodes/message-node.test.ts create mode 100644 tsagentspec/tests/adapters/langgraph/flow-nodes/tool-node.test.ts diff --git a/tsagentspec/src/adapters/common/component-policy.ts b/tsagentspec/src/adapters/common/component-policy.ts index bb555326..b41ea1c6 100644 --- a/tsagentspec/src/adapters/common/component-policy.ts +++ b/tsagentspec/src/adapters/common/component-policy.ts @@ -55,10 +55,24 @@ function unionMemberTypes(union: ComponentTypeUnion): ReadonlySet { // Derived at module load from the SDK's runtime discriminated unions so a // new union member can never silently escape a group-level policy entry. +// The exported sets double as the membership tests of the LangGraph +// converter's dispatch, keeping "which types belong to which family" derived +// from the unions in exactly one place. const AGENTIC_COMPONENT_TYPES = unionMemberTypes(AgenticComponentUnion); -const NODE_TYPES = unionMemberTypes(NodeUnion); + +/** Concrete componentType names of the SDK's flow-node union. */ +export const NODE_TYPES: ReadonlySet = unionMemberTypes(NodeUnion); const TOOL_TYPES = unionMemberTypes(ToolUnion); +/** Concrete componentType names of the SDK's LLM-config union. */ +export const LLM_CONFIG_TYPES: ReadonlySet = + unionMemberTypes(LlmConfigUnion); + +/** Concrete componentType names of the SDK's MCP client-transport union. */ +export const CLIENT_TRANSPORT_TYPES: ReadonlySet = unionMemberTypes( + ClientTransportUnion, +); + /** * Membership of the SDK's abstract component groups, keyed by * `AbstractComponentType` name (src/component.ts), each derived from the @@ -68,10 +82,10 @@ const ABSTRACT_COMPONENT_GROUPS: Record> = { AgenticComponent: AGENTIC_COMPONENT_TYPES, Node: NODE_TYPES, Tool: TOOL_TYPES, - LlmConfig: unionMemberTypes(LlmConfigUnion), + LlmConfig: LLM_CONFIG_TYPES, ToolBox: unionMemberTypes(ToolBoxUnion), OciClientConfig: unionMemberTypes(OciClientConfigUnion), - ClientTransport: unionMemberTypes(ClientTransportUnion), + ClientTransport: CLIENT_TRANSPORT_TYPES, Datastore: unionMemberTypes(SupportedDatastoresSchema), MessageTransform: unionMemberTypes(MessageTransformUnion), }; diff --git a/tsagentspec/src/adapters/common/guards.ts b/tsagentspec/src/adapters/common/guards.ts new file mode 100644 index 00000000..b4b3e301 --- /dev/null +++ b/tsagentspec/src/adapters/common/guards.ts @@ -0,0 +1,30 @@ +/** + * Record-shape guards shared by the AgentSpec adapters. + * + * Two DIFFERENT contracts live here on purpose — pick the one matching the + * call site's semantics, they are not interchangeable: + * - `isPlainRecord` is strict: only plain objects (`Object.prototype` or + * `null` prototype). Used where the value is about to be treated as pure + * data (body encoding, template recursion) and class instances / Maps must + * NOT match. + * - `isRecordLike` is loose: any non-array object, class instances included. + * Used for structural probes over runtime state and third-party objects. + */ + +/** Strict record check: plain objects only (prototype-checked). */ +export function isPlainRecord( + value: unknown, +): value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + const prototype: unknown = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +/** Loose record check: any non-array object (class instances included). */ +export function isRecordLike( + value: unknown, +): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/tsagentspec/src/adapters/common/index.ts b/tsagentspec/src/adapters/common/index.ts index 581c0387..6c03a29f 100644 --- a/tsagentspec/src/adapters/common/index.ts +++ b/tsagentspec/src/adapters/common/index.ts @@ -6,6 +6,8 @@ * RemoteTool execution, converter interfaces, and the loader/exporter base * classes. */ +export { isPlainRecord, isRecordLike } from "./guards.js"; +export { importOptionalPeer } from "./optional-peer.js"; export { renderTemplate, renderNestedObjectTemplate, @@ -19,7 +21,10 @@ export { validateUrlAgainstAllowList, } from "./url-validation.js"; export { + CLIENT_TRANSPORT_TYPES, ComponentLoadPolicy, + LLM_CONFIG_TYPES, + NODE_TYPES, type ComponentPolicyEntry, type ComponentPolicyInput, } from "./component-policy.js"; diff --git a/tsagentspec/src/adapters/common/optional-peer.ts b/tsagentspec/src/adapters/common/optional-peer.ts new file mode 100644 index 00000000..6952e41a --- /dev/null +++ b/tsagentspec/src/adapters/common/optional-peer.ts @@ -0,0 +1,29 @@ +/** + * Optional-peer-dependency import helper shared by the AgentSpec adapters. + * + * Adapter integrations (chat models, MCP, swarm assembly) live behind + * optional peer dependencies loaded via dynamic `import()`. Call sites keep + * the `import("...")` literal inside the `load` thunk so bundlers and TS can + * still analyze it; this helper only owns the shared failure message shape. + */ + +/** + * Await `load()`, rethrowing an import failure as an actionable error naming + * the missing package, what it is needed for, and how to proceed. + */ +export async function importOptionalPeer( + load: () => Promise, + packageName: string, + purpose: string, + hint: string, +): Promise { + try { + return await load(); + } catch (error) { + throw new Error( + `${packageName} is required to ${purpose}. ` + + `Install it (e.g., npm install ${packageName}) or ${hint}`, + { cause: error }, + ); + } +} diff --git a/tsagentspec/src/adapters/common/templating.ts b/tsagentspec/src/adapters/common/templating.ts index daf83529..e83ea4df 100644 --- a/tsagentspec/src/adapters/common/templating.ts +++ b/tsagentspec/src/adapters/common/templating.ts @@ -10,6 +10,7 @@ * objects/arrays. */ import { TEMPLATE_PLACEHOLDER_REGEXP } from "../../templating.js"; +import { isPlainRecord } from "./guards.js"; /** Render a value for insertion into a template string. */ export function stringifyTemplateValue(value: unknown): string { @@ -19,12 +20,6 @@ export function stringifyTemplateValue(value: unknown): string { return String(value); } -function isPlainObject(value: unknown): value is Record { - if (typeof value !== "object" || value === null) return false; - const prototype: unknown = Object.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null; -} - /** * Render a template string using the given inputs. * @@ -90,7 +85,7 @@ export function renderNestedObjectTemplate( [...object].map((item) => renderNestedObjectTemplate(item, inputs)), ); } - if (isPlainObject(object)) { + if (isPlainRecord(object)) { const rendered: Record = {}; for (const [key, value] of Object.entries(object)) { rendered[renderTemplate(key, inputs)] = renderNestedObjectTemplate( diff --git a/tsagentspec/src/adapters/common/tools-common.ts b/tsagentspec/src/adapters/common/tools-common.ts index bc5e3289..2eaebe3c 100644 --- a/tsagentspec/src/adapters/common/tools-common.ts +++ b/tsagentspec/src/adapters/common/tools-common.ts @@ -22,6 +22,7 @@ * `fetchWithAdapterDefaults`. */ import type { RemoteTool } from "../../tools/remote-tool.js"; +import { isPlainRecord } from "./guards.js"; import { renderNestedObjectTemplate, renderTemplate, @@ -32,14 +33,6 @@ import { validateUrlAgainstAllowList, } from "./url-validation.js"; -function isPlainRecord(value: unknown): value is Record { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - return false; - } - const prototype: unknown = Object.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null; -} - /** * Default timeout for RemoteTool / ApiNode HTTP requests, in milliseconds. * diff --git a/tsagentspec/src/adapters/langgraph/agentspec-converter-flow.ts b/tsagentspec/src/adapters/langgraph/agentspec-converter-flow.ts index d4311e89..9951d163 100644 --- a/tsagentspec/src/adapters/langgraph/agentspec-converter-flow.ts +++ b/tsagentspec/src/adapters/langgraph/agentspec-converter-flow.ts @@ -35,104 +35,19 @@ import { unionProperty, } from "../../property.js"; import { createServerTool } from "../../tools/index.js"; +import type { RuntimeToAgentSpecConverter } from "../common/converters.js"; +import type { BranchLike, BuilderLike } from "./graph-introspection.js"; +import { + definitionKeys, + getGraphBuilder, + isCompiledGraphLike, + isStateGraphBuilderLike, + stateSchemaKeys, +} from "./graph-introspection.js"; const START = "__start__"; const END = "__end__"; -/** The converter surface needed for subgraph recursion (avoids a cycle). */ -export interface GraphConverterLike { - convert( - runtimeComponent: unknown, - referencedObjects?: Map, - ): ComponentBase; -} - -/** Runtime shape of one LangGraph builder node spec. */ -interface NodeSpecLike { - runnable?: unknown; - input?: unknown; -} - -/** Runtime shape of one LangGraph conditional-edge branch. */ -interface BranchLike { - path?: unknown; - ends?: Record; -} - -/** Runtime shape of a LangGraph StateGraph builder. */ -interface BuilderLike { - nodes: Record; - edges: Iterable<[string, string]>; - branches?: Record>; - channels?: Record; - _schemaDefinition?: unknown; - _inputDefinition?: unknown; - _outputDefinition?: unknown; -} - -function isPlainRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -/** Duck-type check for a compiled LangGraph graph. */ -export function isCompiledGraphLike( - value: unknown, -): value is { builder: BuilderLike; name?: unknown } { - return ( - isPlainRecord(value) && - (value as { lg_is_pregel?: unknown }).lg_is_pregel === true - ); -} - -/** Duck-type check for a StateGraph builder. */ -export function isStateGraphBuilderLike(value: unknown): value is BuilderLike { - if (!isPlainRecord(value)) { - return false; - } - const candidate = value as { - nodes?: unknown; - compile?: unknown; - addNode?: unknown; - }; - return ( - isPlainRecord(candidate.nodes) && - typeof candidate.compile === "function" && - typeof candidate.addNode === "function" - ); -} - -/** Duck-type check for anything convertible to a Flow (builder or compiled). */ -export function isStateGraphLike(value: unknown): boolean { - return isCompiledGraphLike(value) || isStateGraphBuilderLike(value); -} - -/** Normalize a compiled graph or builder to the builder. */ -export function getGraphBuilder(graph: unknown): BuilderLike { - if (isCompiledGraphLike(graph)) { - return graph.builder; - } - return graph as BuilderLike; -} - -/** - * Extract the state-key names of a schema definition: a langgraph channel - * map, an `Annotation.Root` (via `.spec`) or a zod object (via `.shape`). - */ -function definitionKeys(definition: unknown): string[] | undefined { - if (!isPlainRecord(definition)) { - return undefined; - } - const spec = (definition as { spec?: unknown }).spec; - if (isPlainRecord(spec)) { - return Object.keys(spec); - } - const shape = (definition as { shape?: unknown }).shape; - if (isPlainRecord(shape)) { - return Object.keys(shape); - } - return Object.keys(definition); -} - /** Build the `state` property listing the given state keys. */ function statePropertyFromKeys(keys: string[] | undefined): Property { const properties: Record = {}; @@ -146,12 +61,6 @@ function statePropertyFromKeys(keys: string[] | undefined): Property { }); } -function stateSchemaKeys(builder: BuilderLike): string[] | undefined { - return ( - definitionKeys(builder._schemaDefinition) ?? definitionKeys(builder.channels) - ); -} - function getStateProperty(builder: BuilderLike): Property { return statePropertyFromKeys(stateSchemaKeys(builder)); } @@ -509,7 +418,7 @@ function branchConvertToAgentSpec( * Flow of synthetic ToolNodes / FlowNodes plus Start/End nodes and edges. */ export function langgraphGraphConvertToAgentSpec( - converter: GraphConverterLike, + converter: RuntimeToAgentSpecConverter, graph: unknown, referencedObjects: Map, ): Flow { @@ -531,7 +440,7 @@ export function langgraphGraphConvertToAgentSpec( const subflow = converter.convert(runnable, new Map()) as Flow; const flowNode = createFlowNode({ name: nodeName, - subflow: subflow as unknown as Record, + subflow, }); referencedObjects.set(nodeName, flowNode); nodes.push(flowNode); @@ -587,8 +496,8 @@ export function langgraphGraphConvertToAgentSpec( return createFlow({ name: flowName, - startNode: startNode as unknown as Record, - nodes: nodes as unknown as Record[], + startNode, + nodes, controlFlowConnections: controlFlowEdges, dataFlowConnections: dataFlowEdges, }); diff --git a/tsagentspec/src/adapters/langgraph/agentspec-converter.ts b/tsagentspec/src/adapters/langgraph/agentspec-converter.ts index d3f03c5f..0a020d14 100644 --- a/tsagentspec/src/adapters/langgraph/agentspec-converter.ts +++ b/tsagentspec/src/adapters/langgraph/agentspec-converter.ts @@ -38,15 +38,14 @@ import type { JsonSchemaValue, Property } from "../../property.js"; import type { Tool } from "../../tools/index.js"; import { createServerTool } from "../../tools/index.js"; import type { RuntimeToAgentSpecConverter } from "../common/index.js"; +import { isRecordLike } from "../common/index.js"; +import { langgraphGraphConvertToAgentSpec } from "./agentspec-converter-flow.js"; import { getGraphBuilder, + isCompiledGraphLike, isStateGraphLike, - langgraphGraphConvertToAgentSpec, -} from "./agentspec-converter-flow.js"; - -function isPlainRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} + stateSchemaKeys, +} from "./graph-introspection.js"; /** * True for a LangChain structured tool. `isStructuredTool` alone only tests @@ -116,7 +115,7 @@ function isReactAgentInstance(value: unknown): value is ReactAgentLike { options?: unknown; graph?: unknown; }; - if (!isPlainRecord(candidate.options)) { + if (!isRecordLike(candidate.options)) { return false; } if (candidate.constructor?.name === "ReactAgent") { @@ -132,7 +131,7 @@ function isReactAgentGraph(value: unknown): boolean { return false; } const builder = getGraphBuilder(value); - return isPlainRecord(builder.nodes) && "model_request" in builder.nodes; + return isRecordLike(builder.nodes) && "model_request" in builder.nodes; } /** True for a graph built by `@langchain/langgraph-swarm`'s `createSwarm`. */ @@ -140,31 +139,18 @@ function isSwarmGraph(value: unknown): boolean { if (!isStateGraphLike(value)) { return false; } - const builder = getGraphBuilder(value) as { - nodes?: Record; - branches?: Record; - channels?: Record; - _schemaDefinition?: unknown; - }; - const channels = - (isPlainRecord(builder._schemaDefinition) - ? builder._schemaDefinition - : undefined) ?? builder.channels; - if (!isPlainRecord(channels) || !("activeAgent" in channels)) { + const builder = getGraphBuilder(value); + if (!(stateSchemaKeys(builder) ?? []).includes("activeAgent")) { return false; } const startBranches = builder.branches?.["__start__"]; - if (!isPlainRecord(startBranches)) { + if (!isRecordLike(startBranches)) { return false; } const nodeSpecs = Object.values(builder.nodes ?? {}); return ( nodeSpecs.length > 0 && - nodeSpecs.every( - (spec) => - (spec.runnable as { lg_is_pregel?: unknown } | undefined) - ?.lg_is_pregel === true, - ) + nodeSpecs.every((spec) => isCompiledGraphLike(spec.runnable)) ); } @@ -290,7 +276,7 @@ export class LangGraphToAgentSpecConverter const toolSchema = toJsonSchema( (tool as { schema: Parameters[0] }).schema, ) as JsonSchemaValue; - const argumentSchemas = isPlainRecord(toolSchema["properties"]) + const argumentSchemas = isRecordLike(toolSchema["properties"]) ? (toolSchema["properties"] as Record) : {}; const inputs = Object.entries(argumentSchemas).map( @@ -396,7 +382,7 @@ export class LangGraphToAgentSpecConverter if (typeof systemPromptRaw === "string") { systemPrompt = systemPromptRaw; } else if ( - isPlainRecord(systemPromptRaw) || + isRecordLike(systemPromptRaw) || (typeof systemPromptRaw === "object" && systemPromptRaw !== null) ) { const content = (systemPromptRaw as { content?: unknown }).content; diff --git a/tsagentspec/src/adapters/langgraph/agentspec-loader.ts b/tsagentspec/src/adapters/langgraph/agentspec-loader.ts index 161ccdfd..f73fa7c0 100644 --- a/tsagentspec/src/adapters/langgraph/agentspec-loader.ts +++ b/tsagentspec/src/adapters/langgraph/agentspec-loader.ts @@ -16,10 +16,17 @@ import { } from "../common/index.js"; import { LangGraphToAgentSpecConverter } from "./agentspec-converter.js"; import { AgentSpecToLangGraphConverter } from "./langgraph-converter.js"; +import type { ToolRegistry } from "./types.js"; /** Constructor options for the LangGraph `AgentSpecLoader`. */ export interface AgentSpecLoaderOptions extends AdapterAgnosticAgentSpecLoaderOptions { + /** + * Tool implementations keyed by tool name: LangChain structured tools or + * plain (sync or async) functions. Narrows the adapter-agnostic + * `Record` to the LangGraph registry contract. + */ + toolRegistry?: ToolRegistry; /** * LangGraph checkpointer wired into created graphs; enables features that * require one (e.g., client tools and tool confirmation interrupts). @@ -45,6 +52,8 @@ export interface AgentSpecLoaderOptions * runtime components). */ export class AgentSpecLoader extends AdapterAgnosticAgentSpecLoader { + /** The LangGraph registry contract for the base loader's registry field. */ + declare readonly toolRegistry: ToolRegistry; /** Checkpointer wired into created graphs. */ readonly checkpointer?: BaseCheckpointSaver; /** RunnableConfig passed to created runnables/graphs. */ diff --git a/tsagentspec/src/adapters/langgraph/graph-introspection.ts b/tsagentspec/src/adapters/langgraph/graph-introspection.ts new file mode 100644 index 00000000..da672a1a --- /dev/null +++ b/tsagentspec/src/adapters/langgraph/graph-introspection.ts @@ -0,0 +1,103 @@ +/** + * Structural introspection over LangGraph internals. + * + * Single owner of everything the adapter probes off LangGraph runtime + * objects — the `lg_is_pregel` compiled-graph fingerprint, the StateGraph + * builder surface, and the state-schema key extraction — shared by both + * converter directions. These are probed-stable but private surfaces of + * `@langchain/langgraph`, so a langgraph version bump gets fixed here and + * nowhere else. + */ +import { isRecordLike } from "../common/index.js"; + +/** Runtime shape of one LangGraph builder node spec. */ +export interface NodeSpecLike { + runnable?: unknown; + input?: unknown; +} + +/** Runtime shape of one LangGraph conditional-edge branch. */ +export interface BranchLike { + path?: unknown; + ends?: Record; +} + +/** Runtime shape of a LangGraph StateGraph builder. */ +export interface BuilderLike { + nodes: Record; + edges: Iterable<[string, string]>; + branches?: Record>; + channels?: Record; + _schemaDefinition?: unknown; + _inputDefinition?: unknown; + _outputDefinition?: unknown; +} + +/** Duck-type check for a compiled LangGraph graph (the `lg_is_pregel` probe). */ +export function isCompiledGraphLike( + value: unknown, +): value is { builder: BuilderLike; name?: unknown } { + return ( + isRecordLike(value) && + (value as { lg_is_pregel?: unknown }).lg_is_pregel === true + ); +} + +/** Duck-type check for a StateGraph builder. */ +export function isStateGraphBuilderLike(value: unknown): value is BuilderLike { + if (!isRecordLike(value)) { + return false; + } + const candidate = value as { + nodes?: unknown; + compile?: unknown; + addNode?: unknown; + }; + return ( + isRecordLike(candidate.nodes) && + typeof candidate.compile === "function" && + typeof candidate.addNode === "function" + ); +} + +/** Duck-type check for anything convertible to a Flow (builder or compiled). */ +export function isStateGraphLike(value: unknown): boolean { + return isCompiledGraphLike(value) || isStateGraphBuilderLike(value); +} + +/** Normalize a compiled graph or builder to the builder. */ +export function getGraphBuilder(graph: unknown): BuilderLike { + if (isCompiledGraphLike(graph)) { + return graph.builder; + } + return graph as BuilderLike; +} + +/** + * Extract the state-key names of a schema definition: a langgraph channel + * map, an `Annotation.Root` (via `.spec`) or a zod object (via `.shape`). + */ +export function definitionKeys(definition: unknown): string[] | undefined { + if (!isRecordLike(definition)) { + return undefined; + } + const spec = (definition as { spec?: unknown }).spec; + if (isRecordLike(spec)) { + return Object.keys(spec); + } + const shape = (definition as { shape?: unknown }).shape; + if (isRecordLike(shape)) { + return Object.keys(shape); + } + return Object.keys(definition); +} + +/** + * The builder's state-schema keys, honoring the `_schemaDefinition` (JS + * builders constructed from Annotation.Root / zod) over the channel map. + */ +export function stateSchemaKeys(builder: BuilderLike): string[] | undefined { + return ( + definitionKeys(builder._schemaDefinition) ?? definitionKeys(builder.channels) + ); +} diff --git a/tsagentspec/src/adapters/langgraph/langgraph-converter-flow.ts b/tsagentspec/src/adapters/langgraph/langgraph-converter-flow.ts new file mode 100644 index 00000000..449c3986 --- /dev/null +++ b/tsagentspec/src/adapters/langgraph/langgraph-converter-flow.ts @@ -0,0 +1,256 @@ +/** + * Agent Spec Flow -> LangGraph StateGraph compilation. + * + * Port of the Flow section of + * `pyagentspec.adapters.langgraph._langgraphconverter`: one graph node per + * flow node (named by the AgentSpec node id), conditional edges routed on the + * last executed branch, MapNode iteration wiring and data-flow edge + * auto-generation. Node executors are built by the converter through the + * `convertNode` callback, keeping the recursive (memoized) component + * conversion out of this module. + * + * Runtime contracts (state keys, node names, error-message text) mirror the + * Python adapter exactly so specs behave the same across both SDKs. + */ +import type { BaseMessage } from "@langchain/core/messages"; +import type { RunnableConfig } from "@langchain/core/runnables"; +import type { BaseCheckpointSaver } from "@langchain/langgraph"; +import { Annotation, START, StateGraph } from "@langchain/langgraph"; +import type { ComponentWithIO } from "../../component.js"; +import type { + ControlFlowEdge, + DataFlowEdge, + Flow, + MapNode, + Node, +} from "../../flows/index.js"; +import { DEFAULT_NEXT_BRANCH, createDataFlowEdge } from "../../flows/index.js"; +import type { Property } from "../../property.js"; +import { jsonSchemasHaveSameType } from "../common/index.js"; +import type { NodeExecutor } from "./node-execution.js"; +import { EndNodeExecutor, MapNodeExecutor } from "./node-execution.js"; +import { patchWithExecutionSpan } from "./tracing.js"; +import type { + DynamicStateGraph, + FlowState, + NextNodeInputs, + NodeExecutionDetails, + NodeOutputs, +} from "./types.js"; + +/** Options for `compileFlow`. */ +export interface CompileFlowOptions { + /** + * Builds the node executor for one flow node. Provided by the converter + * (recursive conversion, memoized by component id). + */ + convertNode: (node: Node) => Promise; + /** Checkpointer wired into the compiled graph. */ + checkpointer?: BaseCheckpointSaver; +} + +/** A last-value channel with an initial default. */ +function lastValueChannel(defaultValue: () => T) { + return Annotation({ + reducer: (_current: T, update: T) => update, + default: defaultValue, + }); +} + +function findPropertyByTitle( + properties: Property[], + title: string, + context: string, +): Property { + const property = properties.find((candidate) => candidate.title === title); + if (property === undefined) { + throw new Error(`Property \`${title}\` was not found in ${context}.`); + } + return property; +} + +/** + * Select which of a MapNode's inputs it should iterate over, based on the + * type of the outputs they are connected to: a declared data-flow edge whose + * source output is an array of the matching subflow input type iterates. + * Mirroring Python, only explicitly declared data-flow connections take part. + */ +function selectMapNodeIteratedInputs(flow: Flow, node: MapNode): string[] { + const inputsToIterate: string[] = []; + for (const dataFlowEdge of flow.dataFlowConnections ?? []) { + if (String(dataFlowEdge.destinationNode["id"]) !== node.id) { + continue; + } + const sourceProperty = findPropertyByTitle( + (dataFlowEdge.sourceNode["outputs"] as Property[] | undefined) ?? [], + dataFlowEdge.sourceOutput, + `the outputs of node \`${String(dataFlowEdge.sourceNode["name"])}\``, + ); + const innerFlowInputProperty = findPropertyByTitle( + (node.subflow["inputs"] as Property[] | undefined) ?? [], + dataFlowEdge.destinationInput.replace("iterated_", ""), + `the inputs of the subflow of MapNode \`${node.name}\``, + ); + // Compare against an array-of-inner-input schema, like Python's + // ListProperty(item_type=inner).json_schema (titles are ignored by + // the comparison). + if ( + jsonSchemasHaveSameType(sourceProperty.jsonSchema, { + type: "array", + items: innerFlowInputProperty.jsonSchema, + }) + ) { + inputsToIterate.push(dataFlowEdge.destinationInput); + } + } + return inputsToIterate; +} + +/** + * Manually create data flow connections when they are not given in the flow: + * one edge per matching-title (source output, destination input) pair. This + * is the conversion recommended by the Agent Spec language specification. + */ +function autoGenerateDataFlowEdges(flowNodes: Node[]): DataFlowEdge[] { + const dataFlowConnections: DataFlowEdge[] = []; + for (const sourceNode of flowNodes) { + for (const destinationNode of flowNodes) { + for (const sourceOutput of sourceNode.outputs ?? []) { + for (const destinationInput of destinationNode.inputs ?? []) { + if (sourceOutput.title === destinationInput.title) { + dataFlowConnections.push( + createDataFlowEdge({ + name: `${sourceNode.name}-${destinationNode.name}-${sourceOutput.title}`, + sourceNode: sourceNode as unknown as ComponentWithIO, + sourceOutput: sourceOutput.title, + destinationNode: destinationNode as unknown as ComponentWithIO, + destinationInput: destinationInput.title, + }), + ); + } + } + } + } + } + return dataFlowConnections; +} + +/** Add one conditional edge per source node, routing on the last branch. */ +function addConditionalEdgesToGraph( + controlFlowConnections: ControlFlowEdge[], + graphBuilder: DynamicStateGraph, +): void { + const controlFlow = new Map>(); + for (const controlFlowEdge of controlFlowConnections) { + const sourceNodeId = String(controlFlowEdge.fromNode["id"]); + let mapping = controlFlow.get(sourceNodeId); + if (mapping === undefined) { + mapping = {}; + controlFlow.set(sourceNodeId, mapping); + } + // Python's `from_branch or DEFAULT_NEXT_BRANCH`: an empty-string + // branch coerces to the default branch too, not just null/undefined. + const branchName = controlFlowEdge.fromBranch || DEFAULT_NEXT_BRANCH; + mapping[branchName] = String(controlFlowEdge.toNode["id"]); + } + for (const [sourceNodeId, controlFlowMapping] of controlFlow) { + graphBuilder.addConditionalEdges( + sourceNodeId, + (state: FlowState) => + state.node_execution_details?.branch ?? DEFAULT_NEXT_BRANCH, + controlFlowMapping, + ); + } +} + +/** + * Compile an Agent Spec Flow into a LangGraph StateGraph wrapped in the flow + * execution span. + */ +export async function compileFlow( + flow: Flow, + options: CompileFlowOptions, +): Promise { + // The input/output schemas must reference the SAME channel instances as + // the state schema, or StateGraph rejects them as conflicting channels. + const inputsChannel = lastValueChannel(() => ({})); + const outputsChannel = lastValueChannel(() => ({})); + const messagesChannel = lastValueChannel(() => []); + const nodeExecutionDetailsChannel = lastValueChannel( + () => ({}), + ); + const graphBuilder = new StateGraph({ + state: Annotation.Root({ + inputs: inputsChannel, + outputs: outputsChannel, + messages: messagesChannel, + node_execution_details: nodeExecutionDetailsChannel, + }), + input: Annotation.Root({ + inputs: inputsChannel, + messages: messagesChannel, + }), + output: Annotation.Root({ + outputs: outputsChannel, + messages: messagesChannel, + node_execution_details: nodeExecutionDetailsChannel, + }), + }) as unknown as DynamicStateGraph; + + graphBuilder.addEdge(START, String(flow.startNode["id"])); + + const flowNodes = flow.nodes as unknown as Node[]; + const nodeExecutors = new Map(); + for (const node of flowNodes) { + nodeExecutors.set(node.id, await options.convertNode(node)); + } + + // Tell the MapNodes which inputs they should iterate over; give EndNodes + // the flow outputs to reshape their result. + for (const node of flowNodes) { + if (node.componentType === "MapNode") { + const nodeExecutor = nodeExecutors.get(node.id); + if (nodeExecutor instanceof MapNodeExecutor) { + nodeExecutor.setInputsToIterate( + selectMapNodeIteratedInputs(flow, node), + ); + } + } else if (node.componentType === "EndNode") { + const nodeExecutor = nodeExecutors.get(node.id); + if (nodeExecutor instanceof EndNodeExecutor) { + nodeExecutor.setFlowOutputs(flow.outputs ?? []); + } + } + } + + for (const [nodeId, nodeExecutor] of nodeExecutors) { + // Graph node names are the AgentSpec node ids. + graphBuilder.addNode(nodeId, (state: FlowState, config: RunnableConfig) => + nodeExecutor.call(state, config), + ); + } + + const dataFlowConnections = + flow.dataFlowConnections === undefined + ? autoGenerateDataFlowEdges(flowNodes) + : flow.dataFlowConnections; + + for (const dataFlowEdge of dataFlowConnections) { + // Flow validation guarantees every edge endpoint is a node of the flow. + nodeExecutors + .get(String(dataFlowEdge.sourceNode["id"]))! + .attachEdge(dataFlowEdge); + } + + addConditionalEdgesToGraph(flow.controlFlowConnections, graphBuilder); + + const compiledGraph = graphBuilder.compile( + options.checkpointer !== undefined + ? { checkpointer: options.checkpointer } + : {}, + ); + return patchWithExecutionSpan(compiledGraph, { + kind: "flow", + component: flow, + }); +} diff --git a/tsagentspec/src/adapters/langgraph/langgraph-converter.ts b/tsagentspec/src/adapters/langgraph/langgraph-converter.ts index 72e6a4e1..3cfb16ca 100644 --- a/tsagentspec/src/adapters/langgraph/langgraph-converter.ts +++ b/tsagentspec/src/adapters/langgraph/langgraph-converter.ts @@ -27,35 +27,36 @@ * - Python's "async interrupts on Python < 3.11" load-time warning has no JS * equivalent and is not ported. */ -import { ToolMessage, type BaseMessage } from "@langchain/core/messages"; +import { ToolMessage } from "@langchain/core/messages"; import type { RunnableConfig } from "@langchain/core/runnables"; import type { BaseCheckpointSaver } from "@langchain/langgraph"; -import { Annotation, START, StateGraph } from "@langchain/langgraph"; import { ToolInvocationError, createAgent, toolStrategy } from "langchain"; import { z } from "zod"; import type { Agent, ManagerWorkers, Swarm } from "../../agents/index.js"; import { HandoffMode } from "../../agents/index.js"; import { type ComponentBase, isComponent } from "../../component.js"; -import type { - AgentNode, - ControlFlowEdge, - DataFlowEdge, - Flow, - Node, -} from "../../flows/index.js"; -import { DEFAULT_NEXT_BRANCH, createDataFlowEdge } from "../../flows/index.js"; +import type { AgentNode, Flow, Node } from "../../flows/index.js"; import type { LlmConfig } from "../../llms/index.js"; import type { ClientTransport, MCPTool } from "../../mcp/index.js"; import type { Property } from "../../property.js"; -import type { MCPToolBox, Tool, ToolBox } from "../../tools/index.js"; -import type { ComponentWithIO } from "../../component.js"; -import { buildJsonSchemaFromProperties, jsonSchemasHaveSameType } from "../common/index.js"; +import type { MCPToolBox, Tool } from "../../tools/index.js"; +import { + CLIENT_TRANSPORT_TYPES, + LLM_CONFIG_TYPES, + NODE_TYPES, + buildJsonSchemaFromProperties, + importOptionalPeer, + isRecordLike, +} from "../common/index.js"; +import { isCompiledGraphLike } from "./graph-introspection.js"; +import { compileFlow } from "./langgraph-converter-flow.js"; import { convertLlmConfig as convertLlmConfigToChatModel } from "./llm.js"; import { ManagerWorkersNodeExecutor, compileManagerWorkers, } from "./manager-workers.js"; import { convertClientTransport, convertMcpTool, convertMcpToolbox } from "./mcp.js"; +import type { NodeExecutor } from "./node-execution.js"; import { AgentNodeExecutor, ApiNodeExecutor, @@ -79,10 +80,7 @@ import { import { patchWithExecutionSpan } from "./tracing.js"; import type { ConvertOptions, - FlowState, - NextNodeInputs, - NodeExecutionDetails, - NodeOutputs, + InvocableGraph, ToolRegistry, } from "./types.js"; @@ -95,86 +93,37 @@ interface ConversionContext { middleware: unknown[]; } -/** Inputs of the react-agent assembly helper. */ -interface ReactAgentInfo { - name: string; - systemPrompt: string; - agent: Agent; - llmConfig: LlmConfig; - tools: Tool[]; - toolboxes: ToolBox[]; - inputs: Property[]; - outputs: Property[]; - additionalLangGraphTools?: unknown[]; -} - -/** The structural surface of a flow node executor used by the converter. */ -interface NodeExecutorLike { - attachEdge(edge: DataFlowEdge): void; - call(state: FlowState, config: RunnableConfig): Promise>; -} - -interface EndNodeExecutorLike extends NodeExecutorLike { - setFlowOutputs(flowOutputs: Property[]): void; -} - -interface MapNodeExecutorLike extends NodeExecutorLike { - setInputsToIterate(inputsToIterate: string[]): void; -} - -/** Loosely-typed StateGraph surface for graphs with dynamic node names. */ -interface DynamicStateGraph { - addNode(key: string, action: unknown): DynamicStateGraph; - addEdge(start: string, end: string): DynamicStateGraph; - addConditionalEdges( - source: string, - path: (state: FlowState) => string, - pathMap?: Record, - ): DynamicStateGraph; - compile(options?: { - checkpointer?: BaseCheckpointSaver; - name?: string; - }): unknown; -} - -type LangGraphSwarmModule = typeof import("@langchain/langgraph-swarm"); - -async function importLangGraphSwarmModule(): Promise { - try { - return await import("@langchain/langgraph-swarm"); - } catch (error) { - throw new Error( - "@langchain/langgraph-swarm is required to convert Swarm components. " + - "Install it (e.g., npm install @langchain/langgraph-swarm) or remove Swarms from the spec.", - { cause: error }, - ); - } -} - /** * Unwrap a langchain `ReactAgent` to its compiled graph; compiled graphs (and * anything else) pass through unchanged. */ function resolveCompiledGraph(agentOrGraph: unknown): unknown { - if (typeof agentOrGraph === "object" && agentOrGraph !== null) { - const candidate = agentOrGraph as { - lg_is_pregel?: unknown; - graph?: { lg_is_pregel?: unknown }; - }; - if (candidate.lg_is_pregel === true) { - return agentOrGraph; - } - if ( - typeof candidate.graph === "object" && - candidate.graph !== null && - candidate.graph.lg_is_pregel === true - ) { - return candidate.graph; - } + if (isCompiledGraphLike(agentOrGraph)) { + return agentOrGraph; + } + if ( + isRecordLike(agentOrGraph) && + isCompiledGraphLike(agentOrGraph["graph"]) + ) { + return agentOrGraph["graph"]; } return agentOrGraph; } +/** + * Assert that a converted subflow is an invocable compiled graph. One home + * for the validation the subflow executor constructors rely on; each call + * site keeps its exact (Python-parity) error text. + */ +function assertInvocableGraph( + value: unknown, + errorMessage: string, +): asserts value is InvocableGraph { + if (!isCompiledGraphLike(value)) { + throw new Error(errorMessage); + } +} + /** * Python-parity tool error handling for react-agent tool nodes. * @@ -226,52 +175,6 @@ function applyPythonToolErrorSemantics(reactAgent: unknown): void { } } -/** Duck-type check for a compiled LangGraph graph. */ -function isCompiledGraph(value: unknown): boolean { - return ( - typeof value === "object" && - value !== null && - (value as { lg_is_pregel?: unknown }).lg_is_pregel === true - ); -} - -/** A last-value channel with an initial default. */ -function lastValueChannel(defaultValue: () => T) { - return Annotation({ - reducer: (_current: T, update: T) => update, - default: defaultValue, - }); -} - -function findPropertyByTitle( - properties: Property[], - title: string, - context: string, -): Property { - const property = properties.find((candidate) => candidate.title === title); - if (property === undefined) { - throw new Error(`Property \`${title}\` was not found in ${context}.`); - } - return property; -} - -const NODE_COMPONENT_TYPES = new Set([ - "StartNode", - "EndNode", - "ToolNode", - "LlmNode", - "AgentNode", - "FlowNode", - "BranchingNode", - "MapNode", - "ParallelMapNode", - "ParallelFlowNode", - "ApiNode", - "InputMessageNode", - "OutputMessageNode", - "CatchExceptionNode", -]); - /** * Convert Agent Spec components into LangGraph runtime components. * @@ -340,7 +243,7 @@ export class AgentSpecToLangGraphConverter { const componentType = agentspecComponent.componentType; switch (componentType) { case "Agent": - return this.convertAgent(agentspecComponent as Agent, context); + return this.createReactAgent(agentspecComponent as Agent, context); case "Swarm": return this.convertSwarm(agentspecComponent as Swarm, context); case "ManagerWorkers": @@ -348,19 +251,6 @@ export class AgentSpecToLangGraphConverter { agentspecComponent as ManagerWorkers, context, ); - case "OpenAiConfig": - case "OpenAiCompatibleConfig": - case "VllmConfig": - case "OllamaConfig": - case "OciGenAiConfig": - return this.convertLlmConfig(agentspecComponent as LlmConfig); - case "StdioTransport": - case "SSETransport": - case "SSEmTLSTransport": - case "StreamableHTTPTransport": - case "StreamableHTTPmTLSTransport": - case "RemoteTransport": - return convertClientTransport(agentspecComponent as ClientTransport); case "MCPTool": { const mcpTool = agentspecComponent as MCPTool; ensureCheckpointerAndValidToolConfig(mcpTool, context.checkpointer); @@ -404,7 +294,15 @@ export class AgentSpecToLangGraphConverter { case "Flow": return this.convertFlow(agentspecComponent as Flow, context); default: - if (NODE_COMPONENT_TYPES.has(componentType)) { + // Membership tests over the SDK-derived component families, so a new + // union member can never silently miss its dispatch group. + if (LLM_CONFIG_TYPES.has(componentType)) { + return this.convertLlmConfig(agentspecComponent as LlmConfig); + } + if (CLIENT_TRANSPORT_TYPES.has(componentType)) { + return convertClientTransport(agentspecComponent as ClientTransport); + } + if (NODE_TYPES.has(componentType)) { return this.convertNode( agentspecComponent as unknown as Node, context, @@ -440,22 +338,33 @@ export class AgentSpecToLangGraphConverter { } /** - * Assemble a langchain react agent from Agent Spec information, mirroring + * Assemble a langchain react agent from an Agent Spec Agent, mirroring * Python's `_create_react_agent_with_given_info`: converted model and * tools, tool-strategy structured output for declared outputs (with the * structured-output sentence appended to the system prompt), extended state * for declared inputs, middleware forwarded only when non-empty. + * + * `overrides` carries the per-call-site signal: a replacement system prompt + * (a flow step's rendered template, a ManagerWorkers roster), extra + * LangGraph-native tools prepended to the converted ones (swarm handoffs, + * delegation tools), and `dropDeclaredInputs` for prompts that already have + * the declared inputs baked in. */ - protected async createReactAgentWithGivenInfo( - info: ReactAgentInfo, + protected async createReactAgent( + agent: Agent, context: ConversionContext, + overrides?: { + systemPrompt?: string; + extraLangGraphTools?: unknown[]; + dropDeclaredInputs?: boolean; + }, ): Promise { - const model = await this.convertWithContext(info.llmConfig, context); - const langgraphTools: unknown[] = [...(info.additionalLangGraphTools ?? [])]; - for (const agentspecTool of info.tools) { + const model = await this.convertWithContext(agent.llmConfig, context); + const langgraphTools: unknown[] = [...(overrides?.extraLangGraphTools ?? [])]; + for (const agentspecTool of agent.tools ?? []) { langgraphTools.push(await this.convertWithContext(agentspecTool, context)); } - for (const toolbox of info.toolboxes) { + for (const toolbox of agent.toolboxes ?? []) { const toolboxTools = (await this.convertWithContext( toolbox, context, @@ -463,14 +372,16 @@ export class AgentSpecToLangGraphConverter { langgraphTools.push(...toolboxTools); } - let systemPrompt = info.systemPrompt; + const inputs = overrides?.dropDeclaredInputs ? [] : (agent.inputs ?? []); + const outputs = agent.outputs ?? []; + let systemPrompt = overrides?.systemPrompt ?? agent.systemPrompt; let responseFormat: unknown; - if (info.outputs.length > 0) { + if (outputs.length > 0) { // Explicitly use the tool strategy instead of letting LangChain select // a provider strategy: OpenAI-compatible models do not necessarily // support provider-native structured output. responseFormat = toolStrategy( - buildJsonSchemaFromProperties("AgentOutputModel", info.outputs) as { + buildJsonSchemaFromProperties("AgentOutputModel", outputs) as { type: "object"; [key: string]: unknown; }, @@ -482,7 +393,7 @@ export class AgentSpecToLangGraphConverter { } const createAgentParams: Record = { - name: info.name, + name: agent.name, model, tools: langgraphTools, systemPrompt, @@ -493,8 +404,8 @@ export class AgentSpecToLangGraphConverter { if (responseFormat !== undefined) { createAgentParams["responseFormat"] = responseFormat; } - if (info.inputs.length > 0) { - createAgentParams["stateSchema"] = this.buildAgentStateSchema(info.inputs); + if (inputs.length > 0) { + createAgentParams["stateSchema"] = this.buildAgentStateSchema(inputs); } if (context.middleware.length > 0) { createAgentParams["middleware"] = context.middleware; @@ -505,29 +416,10 @@ export class AgentSpecToLangGraphConverter { applyPythonToolErrorSemantics(reactAgent); return patchWithExecutionSpan(reactAgent, { kind: "agent", - component: info.agent, + component: agent, }); } - private async convertAgent( - agent: Agent, - context: ConversionContext, - ): Promise { - return this.createReactAgentWithGivenInfo( - { - name: agent.name, - systemPrompt: agent.systemPrompt, - agent, - llmConfig: agent.llmConfig, - tools: agent.tools, - toolboxes: agent.toolboxes, - inputs: agent.inputs ?? [], - outputs: agent.outputs ?? [], - }, - context, - ); - } - private async convertSwarm( swarm: Swarm, context: ConversionContext, @@ -571,28 +463,22 @@ export class AgentSpecToLangGraphConverter { handoffs.get(String(fromAgent["name"]))?.push(String(toAgent["name"])); } - const swarmModule = await importLangGraphSwarmModule(); + const swarmModule = await importOptionalPeer( + () => import("@langchain/langgraph-swarm"), + "@langchain/langgraph-swarm", + "convert Swarm components", + "remove Swarms from the spec.", + ); // Re-create the agents with the additional handoff tools. const langgraphAgents: unknown[] = []; for (const participant of agentsByName.values()) { const agent = participant as unknown as Agent; - const reactAgent = await this.createReactAgentWithGivenInfo( - { - name: agent.name, - systemPrompt: agent.systemPrompt, - agent, - llmConfig: agent.llmConfig, - tools: agent.tools, - toolboxes: agent.toolboxes, - inputs: agent.inputs ?? [], - outputs: agent.outputs ?? [], - additionalLangGraphTools: (handoffs.get(agent.name) ?? []).map( - (toAgentName) => - swarmModule.createHandoffTool({ agentName: toAgentName }), - ), - }, - context, - ); + const reactAgent = await this.createReactAgent(agent, context, { + extraLangGraphTools: (handoffs.get(agent.name) ?? []).map( + (toAgentName) => + swarmModule.createHandoffTool({ agentName: toAgentName }), + ), + }); langgraphAgents.push(resolveCompiledGraph(reactAgent)); } const workflow = swarmModule.createSwarm({ @@ -626,23 +512,13 @@ export class AgentSpecToLangGraphConverter { compileManagerAgent: async (rosterSystemPrompt, delegationTools) => { // compileManagerWorkers already validated the group manager type. const managerAgent = managerWorkers.groupManager as unknown as Agent; - const reactAgent = await this.createReactAgentWithGivenInfo( - { - name: managerAgent.name, - systemPrompt: rosterSystemPrompt, - agent: managerAgent, - llmConfig: managerAgent.llmConfig, - tools: managerAgent.tools ?? [], - toolboxes: managerAgent.toolboxes ?? [], - inputs: - systemPromptOverride !== undefined - ? [] - : (managerAgent.inputs ?? []), - outputs: managerAgent.outputs ?? [], - additionalLangGraphTools: delegationTools, - }, - context, - ); + const reactAgent = await this.createReactAgent(managerAgent, context, { + systemPrompt: rosterSystemPrompt, + extraLangGraphTools: delegationTools, + // A prompt override means the declared inputs are already baked + // into the rendered prompt. + dropDeclaredInputs: systemPromptOverride !== undefined, + }); return resolveCompiledGraph(reactAgent); }, convertWorker: (worker) => @@ -650,183 +526,30 @@ export class AgentSpecToLangGraphConverter { }); } + /** + * Compile a Flow into a LangGraph StateGraph (see + * `langgraph-converter-flow.ts`); node executors are built through the + * memoized recursive conversion. + */ private async convertFlow( flow: Flow, context: ConversionContext, ): Promise { - // The input/output schemas must reference the SAME channel instances as - // the state schema, or StateGraph rejects them as conflicting channels. - const inputsChannel = lastValueChannel(() => ({})); - const outputsChannel = lastValueChannel(() => ({})); - const messagesChannel = lastValueChannel(() => []); - const nodeExecutionDetailsChannel = lastValueChannel( - () => ({}), - ); - const graphBuilder = new StateGraph({ - state: Annotation.Root({ - inputs: inputsChannel, - outputs: outputsChannel, - messages: messagesChannel, - node_execution_details: nodeExecutionDetailsChannel, - }), - input: Annotation.Root({ - inputs: inputsChannel, - messages: messagesChannel, - }), - output: Annotation.Root({ - outputs: outputsChannel, - messages: messagesChannel, - node_execution_details: nodeExecutionDetailsChannel, - }), - }) as unknown as DynamicStateGraph; - - graphBuilder.addEdge(START, String(flow.startNode["id"])); - - const flowNodes = flow.nodes as unknown as Node[]; - const nodeExecutors = new Map(); - for (const node of flowNodes) { - nodeExecutors.set( - node.id, + return compileFlow(flow, { + convertNode: async (node: Node) => (await this.convertWithContext( node as unknown as ComponentBase, context, - )) as NodeExecutorLike, - ); - } - - // Tell the MapNodes which inputs they should iterate over, based on the - // type of the outputs they are connected to; give EndNodes the flow - // outputs to reshape their result. Mirroring Python, only explicitly - // declared data-flow connections take part in MapNode iteration wiring. - for (const node of flowNodes) { - if (node.componentType === "MapNode") { - const inputsToIterate: string[] = []; - for (const dataFlowEdge of flow.dataFlowConnections ?? []) { - if (String(dataFlowEdge.destinationNode["id"]) !== node.id) { - continue; - } - const sourceProperty = findPropertyByTitle( - (dataFlowEdge.sourceNode["outputs"] as Property[] | undefined) ?? [], - dataFlowEdge.sourceOutput, - `the outputs of node \`${String(dataFlowEdge.sourceNode["name"])}\``, - ); - const innerFlowInputProperty = findPropertyByTitle( - (node.subflow["inputs"] as Property[] | undefined) ?? [], - dataFlowEdge.destinationInput.replace("iterated_", ""), - `the inputs of the subflow of MapNode \`${node.name}\``, - ); - // Compare against an array-of-inner-input schema, like Python's - // ListProperty(item_type=inner).json_schema (titles are ignored by - // the comparison). - if ( - jsonSchemasHaveSameType(sourceProperty.jsonSchema, { - type: "array", - items: innerFlowInputProperty.jsonSchema, - }) - ) { - inputsToIterate.push(dataFlowEdge.destinationInput); - } - } - (nodeExecutors.get(node.id) as MapNodeExecutorLike).setInputsToIterate( - inputsToIterate, - ); - } else if (node.componentType === "EndNode") { - (nodeExecutors.get(node.id) as EndNodeExecutorLike).setFlowOutputs( - flow.outputs ?? [], - ); - } - } - - for (const [nodeId, nodeExecutor] of nodeExecutors) { - // Graph node names are the AgentSpec node ids. - graphBuilder.addNode(nodeId, (state: FlowState, config: RunnableConfig) => - nodeExecutor.call(state, config), - ); - } - - let dataFlowConnections: DataFlowEdge[]; - if (flow.dataFlowConnections === undefined) { - // Manually create data flow connections if they are not given in the - // flow: one edge per matching-title (source output, destination input) - // pair. This is the conversion recommended by the Agent Spec language - // specification. - dataFlowConnections = []; - for (const sourceNode of flowNodes) { - for (const destinationNode of flowNodes) { - for (const sourceOutput of sourceNode.outputs ?? []) { - for (const destinationInput of destinationNode.inputs ?? []) { - if (sourceOutput.title === destinationInput.title) { - dataFlowConnections.push( - createDataFlowEdge({ - name: `${sourceNode.name}-${destinationNode.name}-${sourceOutput.title}`, - sourceNode: sourceNode as unknown as ComponentWithIO, - sourceOutput: sourceOutput.title, - destinationNode: destinationNode as unknown as ComponentWithIO, - destinationInput: destinationInput.title, - }), - ); - } - } - } - } - } - } else { - dataFlowConnections = flow.dataFlowConnections; - } - - for (const dataFlowEdge of dataFlowConnections) { - // Flow validation guarantees every edge endpoint is a node of the flow. - nodeExecutors - .get(String(dataFlowEdge.sourceNode["id"]))! - .attachEdge(dataFlowEdge); - } - - this.addConditionalEdgesToGraph(flow.controlFlowConnections, graphBuilder); - - const compiledGraph = graphBuilder.compile( - context.checkpointer !== undefined - ? { checkpointer: context.checkpointer } - : {}, - ); - return patchWithExecutionSpan(compiledGraph, { - kind: "flow", - component: flow, + )) as NodeExecutor, + checkpointer: context.checkpointer, }); } - /** Add one conditional edge per source node, routing on the last branch. */ - private addConditionalEdgesToGraph( - controlFlowConnections: ControlFlowEdge[], - graphBuilder: DynamicStateGraph, - ): void { - const controlFlow = new Map>(); - for (const controlFlowEdge of controlFlowConnections) { - const sourceNodeId = String(controlFlowEdge.fromNode["id"]); - let mapping = controlFlow.get(sourceNodeId); - if (mapping === undefined) { - mapping = {}; - controlFlow.set(sourceNodeId, mapping); - } - // Python's `from_branch or DEFAULT_NEXT_BRANCH`: an empty-string - // branch coerces to the default branch too, not just null/undefined. - const branchName = controlFlowEdge.fromBranch || DEFAULT_NEXT_BRANCH; - mapping[branchName] = String(controlFlowEdge.toNode["id"]); - } - for (const [sourceNodeId, controlFlowMapping] of controlFlow) { - graphBuilder.addConditionalEdges( - sourceNodeId, - (state: FlowState) => - state.node_execution_details?.branch ?? DEFAULT_NEXT_BRANCH, - controlFlowMapping, - ); - } - } - /** Build the node executor for one flow node. */ protected async convertNode( node: Node, context: ConversionContext, - ): Promise { + ): Promise { switch (node.componentType) { case "StartNode": return new StartNodeExecutor(node); @@ -851,9 +574,10 @@ export class AgentSpecToLangGraphConverter { node.subflow as unknown as ComponentBase, context, ); - if (!isCompiledGraph(subflow)) { - throw new Error("FlowNodeExecutor can only initialize FlowNode"); - } + assertInvocableGraph( + subflow, + "FlowNodeExecutor can only initialize FlowNode", + ); return new FlowNodeExecutor(node, subflow, context.config); } case "CatchExceptionNode": { @@ -861,12 +585,11 @@ export class AgentSpecToLangGraphConverter { node.subflow as unknown as ComponentBase, context, ); - if (!isCompiledGraph(subflow)) { - throw new Error( - "Internal error: CatchExceptionNodeExecutor expects `subflow` " + - `to be a CompiledStateGraph, was ${typeof subflow}`, - ); - } + assertInvocableGraph( + subflow, + "Internal error: CatchExceptionNodeExecutor expects `subflow` " + + `to be a CompiledStateGraph, was ${typeof subflow}`, + ); return new CatchExceptionNodeExecutor(node, subflow, context.config); } case "InputMessageNode": @@ -878,9 +601,10 @@ export class AgentSpecToLangGraphConverter { node.subflow as unknown as ComponentBase, context, ); - if (!isCompiledGraph(subflow)) { - throw new Error("MapNodeExecutor can only be initialized with MapNode"); - } + assertInvocableGraph( + subflow, + "MapNodeExecutor can only be initialized with MapNode", + ); return new MapNodeExecutor(node, subflow); } default: @@ -897,44 +621,31 @@ export class AgentSpecToLangGraphConverter { * (executors render templates against node inputs and cache per rendered * prompt). */ - private convertAgentNode(node: AgentNode, context: ConversionContext): unknown { - if (node.agent.componentType === "ManagerWorkers") { - const managerWorkers = node.agent as ManagerWorkers; + private convertAgentNode( + node: AgentNode, + context: ConversionContext, + ): NodeExecutor { + const agentComponent = node.agent; + if (agentComponent.componentType === "ManagerWorkers") { return new ManagerWorkersNodeExecutor( node, (renderedSystemPrompt: string) => this.compileManagerWorkersGraph( - managerWorkers, + agentComponent, context, renderedSystemPrompt, ), context.config, ); } - const agentComponent = node.agent; - const compileAgentFactory = async ( + // The executor's (Python-faithful) guard rejects anything but a plain + // Agent before the factory ever runs, so the factory can assume one. + const compileAgentFactory = ( renderedSystemPrompt: string, - ): Promise => { - if (agentComponent.componentType !== "Agent") { - throw new Error( - "AgentNodeExecutor can only be used with AgentSpecAgent agents", - ); - } - const agent = agentComponent as Agent; - return this.createReactAgentWithGivenInfo( - { - name: agent.name, - systemPrompt: renderedSystemPrompt, - agent, - llmConfig: agent.llmConfig, - tools: agent.tools, - toolboxes: agent.toolboxes, - inputs: agent.inputs ?? [], - outputs: agent.outputs ?? [], - }, - context, - ); - }; + ): Promise => + this.createReactAgent(agentComponent as Agent, context, { + systemPrompt: renderedSystemPrompt, + }); return new AgentNodeExecutor(node, compileAgentFactory, context.config); } } diff --git a/tsagentspec/src/adapters/langgraph/llm.ts b/tsagentspec/src/adapters/langgraph/llm.ts index 36b2e20d..b8db7a8a 100644 --- a/tsagentspec/src/adapters/langgraph/llm.ts +++ b/tsagentspec/src/adapters/langgraph/llm.ts @@ -15,36 +15,7 @@ import type { BaseChatModel } from "@langchain/core/language_models/chat_models"; import type { LlmConfig, LlmGenerationConfig } from "../../llms/index.js"; import { OpenAIAPIType } from "../../llms/index.js"; - -/** Normalized Agent Spec generation settings supported by the LangGraph adapter. */ -export interface GenerationConfig { - temperature?: number; - maxTokens?: number; - topP?: number; -} - -/** - * Copy only the generation parameters that are set (temperature, maxTokens, - * topP) from an Agent Spec `LlmGenerationConfig`. - */ -export function generationConfigFromAgentSpec( - generationParameters: LlmGenerationConfig | undefined, -): GenerationConfig { - const generationConfig: GenerationConfig = {}; - if (generationParameters === undefined) { - return generationConfig; - } - if (generationParameters.temperature !== undefined) { - generationConfig.temperature = generationParameters.temperature; - } - if (generationParameters.maxTokens !== undefined) { - generationConfig.maxTokens = generationParameters.maxTokens; - } - if (generationParameters.topP !== undefined) { - generationConfig.topP = generationParameters.topP; - } - return generationConfig; -} +import { importOptionalPeer } from "../common/index.js"; function ensureUrlHasScheme(url: string): string { const trimmed = url.trim(); @@ -75,33 +46,6 @@ export function prepareOpenAiCompatibleUrl(url: string): string { return parsed.toString(); } -type ChatOpenAiModule = typeof import("@langchain/openai"); -type ChatOllamaModule = typeof import("@langchain/ollama"); - -async function importChatOpenAiModule(): Promise { - try { - return await import("@langchain/openai"); - } catch (error) { - throw new Error( - "@langchain/openai is required to convert OpenAI-compatible LLM configs. " + - "Install it (e.g., npm install @langchain/openai) or remove them from the spec.", - { cause: error }, - ); - } -} - -async function importChatOllamaModule(): Promise { - try { - return await import("@langchain/ollama"); - } catch (error) { - throw new Error( - "@langchain/ollama is required to convert OllamaConfig LLM configs. " + - "Install it (e.g., npm install @langchain/ollama) or remove them from the spec.", - { cause: error }, - ); - } -} - /** * Create a ChatOpenAI model without overriding env-based defaults. * @@ -111,11 +55,16 @@ async function importChatOllamaModule(): Promise { async function createChatOpenAiModel(options: { modelId: string; useResponsesApi: boolean; - generationConfig: GenerationConfig; + generationConfig: LlmGenerationConfig; baseUrl?: string; apiKey?: string; }): Promise { - const { ChatOpenAI } = await importChatOpenAiModule(); + const { ChatOpenAI } = await importOptionalPeer( + () => import("@langchain/openai"), + "@langchain/openai", + "convert OpenAI-compatible LLM configs", + "remove them from the spec.", + ); // Mirror the Python fallback chain: a MISSING config value falls back to // OPENAI_API_KEY -> "EMPTY", but an explicit key (even an empty string) is // used as-is — the spec's key must never be silently replaced by the @@ -145,12 +94,13 @@ async function createChatOpenAiModel(options: { export async function convertLlmConfig( llmConfig: LlmConfig, ): Promise { - const generationConfig = generationConfigFromAgentSpec( - llmConfig.defaultGenerationParameters, - ); + // Only temperature / maxTokens / topP are supported; each use site reads + // the fields individually, so unset ones simply stay undefined. + const generationConfig = llmConfig.defaultGenerationParameters ?? {}; switch (llmConfig.componentType) { case "VllmConfig": + case "OpenAiCompatibleConfig": return createChatOpenAiModel({ modelId: llmConfig.modelId, baseUrl: prepareOpenAiCompatibleUrl(llmConfig.url), @@ -159,7 +109,12 @@ export async function convertLlmConfig( generationConfig, }); case "OllamaConfig": { - const { ChatOllama } = await importChatOllamaModule(); + const { ChatOllama } = await importOptionalPeer( + () => import("@langchain/ollama"), + "@langchain/ollama", + "convert OllamaConfig LLM configs", + "remove them from the spec.", + ); return new ChatOllama({ baseUrl: llmConfig.url, model: llmConfig.modelId, @@ -175,14 +130,6 @@ export async function convertLlmConfig( useResponsesApi: llmConfig.apiType === OpenAIAPIType.RESPONSES, generationConfig, }); - case "OpenAiCompatibleConfig": - return createChatOpenAiModel({ - modelId: llmConfig.modelId, - baseUrl: prepareOpenAiCompatibleUrl(llmConfig.url), - apiKey: llmConfig.apiKey, - useResponsesApi: llmConfig.apiType === OpenAIAPIType.RESPONSES, - generationConfig, - }); case "OciGenAiConfig": throw new Error( "The Agent Spec type 'OciGenAiConfig' is not supported by the LangGraph TypeScript adapter yet.", diff --git a/tsagentspec/src/adapters/langgraph/manager-workers.ts b/tsagentspec/src/adapters/langgraph/manager-workers.ts index e9f08ffd..c80e207d 100644 --- a/tsagentspec/src/adapters/langgraph/manager-workers.ts +++ b/tsagentspec/src/adapters/langgraph/manager-workers.ts @@ -13,7 +13,7 @@ * Runtime contracts (node names, tool names, Send payload keys, roster text) * mirror the Python adapter exactly so specs behave the same across SDKs. */ -import type { BaseMessage, BaseMessageLike } from "@langchain/core/messages"; +import type { BaseMessage } from "@langchain/core/messages"; import { HumanMessage, ToolMessage } from "@langchain/core/messages"; import type { RunnableConfig } from "@langchain/core/runnables"; import type { StructuredToolInterface } from "@langchain/core/tools"; @@ -33,7 +33,12 @@ import type { AgentNode } from "../../flows/index.js"; import { renderTemplate } from "../common/index.js"; import { AgentNodeExecutor } from "./node-execution.js"; import { patchWithExecutionSpan } from "./tracing.js"; -import type { ExecuteOutput, NodeOutputs } from "./types.js"; +import type { + DynamicStateGraph, + ExecuteOutput, + InvocableGraph, + NodeOutputs, +} from "./types.js"; /** * Prefix of the synthetic `__delegate_to__` tool names the manager's @@ -213,14 +218,6 @@ function makeManagerRouter( }; } -/** A graph-like runtime object exposing `invoke`. */ -interface InvocableGraph { - invoke( - input: unknown, - config?: RunnableConfig, - ): Promise>; -} - /** * Wrap a worker subgraph as a node of the ManagerWorkers parent graph. * @@ -268,21 +265,6 @@ function wrapWorkerForSubgraph( }; } -/** Loosely-typed StateGraph surface for graphs with dynamic node names. */ -interface DynamicStateGraph { - addNode(key: string, action: unknown): DynamicStateGraph; - addEdge(start: string, end: string): DynamicStateGraph; - addConditionalEdges( - source: string, - path: (state: Record) => Send[] | string, - pathMap?: Record, - ): DynamicStateGraph; - compile(options?: { - checkpointer?: BaseCheckpointSaver; - name?: string; - }): unknown; -} - /** Options for `compileManagerWorkers`. */ export interface CompileManagerWorkersOptions { /** Checkpointer wired into the compiled parent graph. */ @@ -423,16 +405,15 @@ export async function compileManagerWorkers( * structured inputs inward nor a `structured_response` outward. Inputs are * therefore rendered into the group-manager's system prompt before compiling, * and the manager's final message is the node's single string output. + * + * Mirroring Python, only the parent's two template-method hooks are + * overridden: `prepareAgentAndInputs` (compile the hierarchical graph, run it + * on messages alone) and `formatAgentResult` (single-string output). The + * compile callback, the rendered-prompt cache and `withDrivingMessage` are + * the inherited ones. */ export class ManagerWorkersNodeExecutor extends AgentNodeExecutor { - private readonly agentNode: AgentNode; private readonly managerWorkers: ManagerWorkers; - private readonly compileManagerWorkersFn: ( - renderedSystemPrompt: string, - ) => Promise; - private readonly invokeConfig: RunnableConfig; - /** Compiled graphs cached by rendered group-manager system prompt. */ - private readonly graphCache = new Map(); constructor( node: AgentNode, @@ -445,10 +426,7 @@ export class ManagerWorkersNodeExecutor extends AgentNodeExecutor { "ManagerWorkersNodeExecutor requires an AgentNode holding a ManagerWorkers", ); } - this.agentNode = node; - this.managerWorkers = node.agent as ManagerWorkers; - this.compileManagerWorkersFn = compileManagerWorkers; - this.invokeConfig = config; + this.managerWorkers = node.agent; // Anything but a single string output cannot be honored (see class // docstring); raising here fails at conversion time rather than mid-run. const outputs = node.outputs ?? []; @@ -462,8 +440,8 @@ export class ManagerWorkersNodeExecutor extends AgentNodeExecutor { /** * Compile the `ManagerWorkers` with the node inputs rendered into the - * group-manager's system prompt, cached by rendered prompt (the same key - * `AgentNodeExecutor` uses for its react-agent cache). + * group-manager's system prompt, cached by rendered prompt (the same + * `agentsCache` key `AgentNodeExecutor` uses for its react-agent cache). */ private async createManagerWorkersWithGivenInputValues( inputs: NodeOutputs, @@ -473,43 +451,38 @@ export class ManagerWorkersNodeExecutor extends AgentNodeExecutor { String(groupManager["systemPrompt"] ?? ""), inputs, ); - let graph = this.graphCache.get(systemPrompt); + let graph = this.agentsCache.get(systemPrompt); if (graph === undefined) { - graph = await this.compileManagerWorkersFn(systemPrompt); - this.graphCache.set(systemPrompt, graph); + graph = await this.compileAgent(systemPrompt); + this.agentsCache.set(systemPrompt, graph); } return graph; } - protected async _execute( + protected override async prepareAgentAndInputs( inputs: NodeOutputs, messages: BaseMessage[], - ): Promise { + ): Promise<[InvocableGraph, Record]> { // Inputs were baked into the group-manager's prompt, so this graph runs // on messages alone rather than the react-agent's remaining_steps state. const graph = await this.createManagerWorkersWithGivenInputValues(inputs); - // LangGraph's agent expects at least one user message to drive execution. - const drivingMessages: BaseMessageLike[] = - messages.length > 0 ? messages : [{ role: "user", content: "" }]; - const result = await (graph as InvocableGraph).invoke( - { messages: drivingMessages }, - this.invokeConfig, - ); + return [ + graph as InvocableGraph, + { messages: this.withDrivingMessage(messages) }, + ]; + } + + protected override formatAgentResult( + result: Record, + ): ExecuteOutput { + const nodeOutputs = this.node.outputs ?? []; + if (nodeOutputs.length === 0) { + return super.formatAgentResult(result); + } const resultMessages = Array.isArray(result["messages"]) ? (result["messages"] as { content?: unknown }[]) : []; const lastMessage = resultMessages[resultMessages.length - 1]; - const nodeOutputs = this.agentNode.outputs ?? []; - if (nodeOutputs.length === 0) { - return [ - {}, - { - generated_messages: [ - { role: "assistant", content: (lastMessage?.content ?? "") as string }, - ], - }, - ]; - } // The constructor already rejected any shape but a single string output. return [{ [nodeOutputs[0]!.title]: lastMessage?.content }, {}]; } diff --git a/tsagentspec/src/adapters/langgraph/mcp.ts b/tsagentspec/src/adapters/langgraph/mcp.ts index 6f276932..44746a24 100644 --- a/tsagentspec/src/adapters/langgraph/mcp.ts +++ b/tsagentspec/src/adapters/langgraph/mcp.ts @@ -25,23 +25,9 @@ import type { Connection } from "@langchain/mcp-adapters"; import type { ClientTransport, MCPTool, MCPToolSpec } from "../../mcp/index.js"; import type { JsonSchemaValue } from "../../property.js"; import type { MCPToolBox } from "../../tools/index.js"; -import { jsonSchemasHaveSameType } from "../common/index.js"; +import { importOptionalPeer, jsonSchemasHaveSameType } from "../common/index.js"; import type { ToolRegistry } from "./types.js"; -type McpAdaptersModule = typeof import("@langchain/mcp-adapters"); - -async function importMcpAdaptersModule(): Promise { - try { - return await import("@langchain/mcp-adapters"); - } catch (error) { - throw new Error( - "@langchain/mcp-adapters is required to preload MCP tools. " + - "Install it (e.g., npm install @langchain/mcp-adapters) or remove MCP tools from the spec.", - { cause: error }, - ); - } -} - /** * Convert an AgentSpec MCP client transport into a `@langchain/mcp-adapters` * connection. @@ -166,7 +152,12 @@ export async function getOrCreateMcpTools( return existing; } - const { MultiServerMCPClient } = await importMcpAdaptersModule(); + const { MultiServerMCPClient } = await importOptionalPeer( + () => import("@langchain/mcp-adapters"), + "@langchain/mcp-adapters", + "preload MCP tools", + "remove MCP tools from the spec.", + ); const serverName = clientTransport.id; // The client stays referenced by the loaded tools; it is intentionally not // closed here (closing it would break later tool invocations). diff --git a/tsagentspec/src/adapters/langgraph/node-execution/agent-node.ts b/tsagentspec/src/adapters/langgraph/node-execution/agent-node.ts index 4b977fda..76632be5 100644 --- a/tsagentspec/src/adapters/langgraph/node-execution/agent-node.ts +++ b/tsagentspec/src/adapters/langgraph/node-execution/agent-node.ts @@ -14,10 +14,9 @@ import type { BaseMessage } from "@langchain/core/messages"; import type { RunnableConfig } from "@langchain/core/runnables"; import type { AgentNode } from "../../../flows/index.js"; import type { Property } from "../../../property.js"; -import { renderTemplate } from "../../common/index.js"; -import type { ExecuteOutput, NodeOutputs } from "../types.js"; -import type { InvocableGraph } from "./executor.js"; -import { NodeExecutor, isPlainRecord } from "./executor.js"; +import { isRecordLike, renderTemplate } from "../../common/index.js"; +import type { ExecuteOutput, InvocableGraph, NodeOutputs } from "../types.js"; +import { NodeExecutor } from "./executor.js"; /** * Extract the outputs of an agent invoke result for the expected output @@ -37,7 +36,7 @@ export function extractOutputsFromInvokeResult( } const structuredResponse = result["structuredResponse"] ?? result["structured_response"]; - if (isPlainRecord(structuredResponse)) { + if (isRecordLike(structuredResponse)) { Object.assign(outputs, structuredResponse); } for (const output of expectedOutputs) { @@ -53,14 +52,21 @@ export function extractOutputsFromInvokeResult( * prompt against the node inputs, compiles (and caches) a react agent per * rendered prompt through the converter-provided factory, and invokes it on * the flow messages. + * + * `_execute` is a template method, mirroring Python: prepare (compile the + * cached agent, shape the invoke payload) then invoke then format. Subclasses + * (`ManagerWorkersNodeExecutor`) override only `prepareAgentAndInputs` and + * `formatAgentResult`, sharing the compile callback, the rendered-prompt + * cache and `withDrivingMessage`. */ export class AgentNodeExecutor extends NodeExecutor { - private readonly compileAgent: ( + /** Compiles a runnable graph for one rendered system prompt (converter-provided). */ + protected readonly compileAgent: ( renderedSystemPrompt: string, ) => Promise; protected readonly config: RunnableConfig; /** Compiled agents cached by rendered system prompt. */ - private readonly agentsCache = new Map(); + protected readonly agentsCache = new Map(); constructor( node: AgentNode, @@ -75,16 +81,13 @@ export class AgentNodeExecutor extends NodeExecutor { private async createReactAgentWithGivenInputValues( inputs: NodeOutputs, ): Promise { - if (this.node.agent.componentType !== "Agent") { + const agentComponent = this.node.agent; + if (agentComponent.componentType !== "Agent") { throw new Error( "AgentNodeExecutor can only be used with AgentSpecAgent agents", ); } - const agentComponent = this.node.agent as { systemPrompt?: unknown }; - const systemPrompt = renderTemplate( - String(agentComponent.systemPrompt ?? ""), - inputs, - ); + const systemPrompt = renderTemplate(agentComponent.systemPrompt, inputs); let agent = this.agentsCache.get(systemPrompt); if (agent === undefined) { agent = await this.compileAgent(systemPrompt); @@ -98,6 +101,19 @@ export class AgentNodeExecutor extends NodeExecutor { return messages.length > 0 ? messages : [{ role: "user", content: "" }]; } + /** Compile (or reuse) the agent for the inputs and shape its invoke payload. */ + protected async prepareAgentAndInputs( + inputs: NodeOutputs, + messages: BaseMessage[], + ): Promise<[InvocableGraph, Record]> { + const agent = await this.createReactAgentWithGivenInputValues(inputs); + const preparedInputs: Record = { + ...inputs, + messages: this.withDrivingMessage(messages), + }; + return [agent, preparedInputs]; + } + /** Map an agent invoke result onto the node outputs (or a chat message). */ protected formatAgentResult(result: Record): ExecuteOutput { const nodeOutputs = this.node.outputs ?? []; @@ -125,11 +141,10 @@ export class AgentNodeExecutor extends NodeExecutor { inputs: NodeOutputs, messages: BaseMessage[], ): Promise { - const agent = await this.createReactAgentWithGivenInputValues(inputs); - const preparedInputs: Record = { - ...inputs, - messages: this.withDrivingMessage(messages), - }; + const [agent, preparedInputs] = await this.prepareAgentAndInputs( + inputs, + messages, + ); const result = await agent.invoke(preparedInputs, this.config); return this.formatAgentResult(result); } diff --git a/tsagentspec/src/adapters/langgraph/node-execution/executor.ts b/tsagentspec/src/adapters/langgraph/node-execution/executor.ts index 8f70d755..25441ee3 100644 --- a/tsagentspec/src/adapters/langgraph/node-execution/executor.ts +++ b/tsagentspec/src/adapters/langgraph/node-execution/executor.ts @@ -21,6 +21,7 @@ import { addMessages } from "@langchain/langgraph"; import type { DataFlowEdge } from "../../../flows/index.js"; import { DEFAULT_NEXT_BRANCH } from "../../../flows/index.js"; import type { Property } from "../../../property.js"; +import { isRecordLike } from "../../common/index.js"; import type { ExecuteOutput, FlowState, @@ -38,21 +39,6 @@ export interface FlowNodeLike { outputs?: Property[]; } -/** A compiled graph / react agent surface: everything invocable. */ -export interface InvocableGraph { - invoke( - input: unknown, - config?: RunnableConfig, - ): Promise>; -} - -/** Loose record check: any non-array object (class instances included). */ -export function isPlainRecord( - value: unknown, -): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - /** * Base class of the flow node executors. * @@ -99,7 +85,7 @@ export abstract class NodeExecutor< */ protected getInputs(state: FlowState): NodeOutputs { const nodeInputs = state.inputs?.[this.node.id]; - const ioInputs: Record = isPlainRecord(nodeInputs) + const ioInputs: Record = isRecordLike(nodeInputs) ? { ...nodeInputs } : {}; return castValuesAndAddDefaults( @@ -130,7 +116,7 @@ export abstract class NodeExecutor< for (const edge of this.edges) { const destinationNodeId = String(edge.destinationNode["id"]); const existing = nextNodeInputs[destinationNodeId]; - const destinationInputs: Record = isPlainRecord(existing) + const destinationInputs: Record = isRecordLike(existing) ? { ...existing } : {}; if (!Object.hasOwn(castOutputs, edge.sourceOutput)) { diff --git a/tsagentspec/src/adapters/langgraph/node-execution/llm-node.ts b/tsagentspec/src/adapters/langgraph/node-execution/llm-node.ts index 61d8194e..87951d5d 100644 --- a/tsagentspec/src/adapters/langgraph/node-execution/llm-node.ts +++ b/tsagentspec/src/adapters/langgraph/node-execution/llm-node.ts @@ -8,9 +8,9 @@ import type { BaseMessage } from "@langchain/core/messages"; import type { LlmNode } from "../../../flows/index.js"; import type { Property } from "../../../property.js"; -import { renderTemplate } from "../../common/index.js"; +import { isRecordLike, renderTemplate } from "../../common/index.js"; import type { ExecuteOutput, NodeOutputs } from "../types.js"; -import { NodeExecutor, isPlainRecord } from "./executor.js"; +import { NodeExecutor } from "./executor.js"; /** The chat-model surface the LlmNodeExecutor relies on. */ interface ChatModelLike { @@ -76,7 +76,7 @@ export class LlmNodeExecutor extends NodeExecutor { nodeOutputs: Property[], generatedRaw: unknown, ): NodeOutputs { - if (!isPlainRecord(generatedRaw)) { + if (!isRecordLike(generatedRaw)) { throw new Error( `Expected structured LLM to return a dict, got ${typeof generatedRaw}`, ); diff --git a/tsagentspec/src/adapters/langgraph/node-execution/subflow-nodes.ts b/tsagentspec/src/adapters/langgraph/node-execution/subflow-nodes.ts index 5efee289..c5f71419 100644 --- a/tsagentspec/src/adapters/langgraph/node-execution/subflow-nodes.ts +++ b/tsagentspec/src/adapters/langgraph/node-execution/subflow-nodes.ts @@ -23,14 +23,14 @@ import { DEFAULT_NEXT_BRANCH, } from "../../../flows/index.js"; import type { Property } from "../../../property.js"; -import { stringifyTemplateValue } from "../../common/index.js"; +import { isRecordLike, stringifyTemplateValue } from "../../common/index.js"; import type { ExecuteOutput, + InvocableGraph, NodeExecutionDetails, NodeOutputs, } from "../types.js"; -import type { InvocableGraph } from "./executor.js"; -import { NodeExecutor, isPlainRecord } from "./executor.js"; +import { NodeExecutor } from "./executor.js"; /** * Executes a FlowNode: invokes the compiled subflow with this node's inputs @@ -41,9 +41,9 @@ export class FlowNodeExecutor extends NodeExecutor { private readonly subflow: InvocableGraph; private readonly config: RunnableConfig; - constructor(node: FlowNode, subflow: unknown, config: RunnableConfig) { + constructor(node: FlowNode, subflow: InvocableGraph, config: RunnableConfig) { super(node); - this.subflow = subflow as InvocableGraph; + this.subflow = subflow; this.config = config; } @@ -77,11 +77,11 @@ export class CatchExceptionNodeExecutor extends NodeExecutor constructor( node: CatchExceptionNode, - subflow: unknown, + subflow: InvocableGraph, config: RunnableConfig, ) { super(node); - this.subflow = subflow as InvocableGraph; + this.subflow = subflow; this.config = config; } @@ -94,7 +94,7 @@ export class CatchExceptionNodeExecutor extends NodeExecutor { messages, inputs }, this.config, ); - const outputs: NodeOutputs = isPlainRecord(flowOutput["outputs"]) + const outputs: NodeOutputs = isRecordLike(flowOutput["outputs"]) ? { ...(flowOutput["outputs"] as NodeOutputs) } : {}; // As per the spec, when the subflow runs without error @@ -130,13 +130,13 @@ export class MapNodeExecutor extends NodeExecutor { private readonly subflow: InvocableGraph; private inputsToIterate: string[] = []; - constructor(node: MapNode, subflow: unknown) { + constructor(node: MapNode, subflow: InvocableGraph) { super(node); if (!node.inputs || node.inputs.length === 0) { throw new Error("MapNode has no inputs"); } // Mirroring Python, the subflow runs are not passed the ambient config. - this.subflow = subflow as InvocableGraph; + this.subflow = subflow; } /** Set which inputs to iterate over (decided by the converter). */ @@ -235,7 +235,7 @@ export class MapNodeExecutor extends NodeExecutor { messages, }); const subflowOutputs = subflowResult["outputs"]; - if (isPlainRecord(subflowOutputs)) { + if (isRecordLike(subflowOutputs)) { this.accumulateOutputs(outputs, subflowOutputs); } } diff --git a/tsagentspec/src/adapters/langgraph/node-execution/tool-node.ts b/tsagentspec/src/adapters/langgraph/node-execution/tool-node.ts index bfd08e50..eb0d43f9 100644 --- a/tsagentspec/src/adapters/langgraph/node-execution/tool-node.ts +++ b/tsagentspec/src/adapters/langgraph/node-execution/tool-node.ts @@ -11,10 +11,9 @@ */ import type { BaseMessage } from "@langchain/core/messages"; import type { ToolNode } from "../../../flows/index.js"; -import { stringifyTemplateValue } from "../../common/index.js"; -import type { ExecuteOutput, NodeOutputs } from "../types.js"; -import type { InvocableGraph } from "./executor.js"; -import { NodeExecutor, isPlainRecord } from "./executor.js"; +import { isRecordLike, stringifyTemplateValue } from "../../common/index.js"; +import type { ExecuteOutput, InvocableGraph, NodeOutputs } from "../types.js"; +import { NodeExecutor } from "./executor.js"; /** True for a list of MCP-style content blocks (text / image / file). */ function isMcpContentBlocksList(items: unknown[]): boolean { @@ -23,7 +22,7 @@ function isMcpContentBlocksList(items: unknown[]): boolean { return false; } for (const element of items) { - if (!isPlainRecord(element)) { + if (!isRecordLike(element)) { return false; } const blockType = element["type"]; @@ -118,7 +117,7 @@ export class ToolNodeExecutor extends NodeExecutor { // property's title: use it as-is to avoid double-wrapping. const onlyTitle = nodeOutputProperties[0]!.title; if ( - isPlainRecord(toolOutput) && + isRecordLike(toolOutput) && Object.keys(toolOutput).length === 1 && Object.hasOwn(toolOutput, onlyTitle) ) { @@ -126,7 +125,7 @@ export class ToolNodeExecutor extends NodeExecutor { } else { mapped = { [onlyTitle]: toolOutput }; } - } else if (isPlainRecord(toolOutput)) { + } else if (isRecordLike(toolOutput)) { // The node emits multiple outputs: filter the tool output. mapped = {}; for (const property of nodeOutputProperties) { diff --git a/tsagentspec/src/adapters/langgraph/tools.ts b/tsagentspec/src/adapters/langgraph/tools.ts index beaf6c0d..f2a06455 100644 --- a/tsagentspec/src/adapters/langgraph/tools.ts +++ b/tsagentspec/src/adapters/langgraph/tools.ts @@ -34,17 +34,14 @@ import type { import { buildJsonSchemaFromProperties, createRemoteToolFunc, + isRecordLike, } from "../common/index.js"; -import type { ToolRegistry } from "./types.js"; +import type { ToolImplementation, ToolRegistry } from "./types.js"; const ALLOWED_DECISIONS = ["approve", "reject"]; /** A tool implementation function: receives the parsed input object. */ -export type ToolFunction = (input: unknown, config?: unknown) => unknown; - -function isPlainRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} +export type ToolFunction = ToolImplementation; /** * Merge each declared input property's default into the tool-call input when @@ -137,7 +134,7 @@ export function confirmToolUse( const response = interrupt( confirmationPayload, ); - if (!isPlainRecord(response) || !("decisions" in response)) { + if (!isRecordLike(response) || !("decisions" in response)) { throw new Error( `Tool confirmation result for tool ${toolName} is not valid, should be a ` + `dict with a 'decisions' key, was ${JSON.stringify(response)} of type ${typeof response}.`, @@ -153,7 +150,7 @@ export function confirmToolUse( } const decision: unknown = decisionList[0]; if ( - !isPlainRecord(decision) || + !isRecordLike(decision) || !("type" in decision) || typeof decision["type"] !== "string" || !ALLOWED_DECISIONS.includes(decision["type"]) @@ -187,7 +184,7 @@ export function confirmThen( input: unknown, config?: unknown, ): unknown { - const confirmationArguments = isPlainRecord(input) + const confirmationArguments = isRecordLike(input) ? input : { args: [input] }; const [confirmed, reason] = confirmToolUse(toolName, confirmationArguments); @@ -270,14 +267,10 @@ export function convertServerTool( } if (typeof toolObj === "function") { const toolInputs = agentspecServerTool.inputs ?? []; - const wrapped = confirmThen( - toolObj as ToolFunction, - toolName, - requiresConfirmation, - ); + const wrapped = confirmThen(toolObj, toolName, requiresConfirmation); const withDefaults: ToolFunction = (input, config) => wrapped( - isPlainRecord(input) ? applyInputDefaults(input, toolInputs) : input, + isRecordLike(input) ? applyInputDefaults(input, toolInputs) : input, config, ); return tool(withDefaults as (input: unknown) => unknown, { @@ -302,32 +295,33 @@ export function convertClientTool( ): StructuredToolInterface { const toolName = agentspecClientTool.name; const toolDescription = agentspecClientTool.description ?? ""; - const requiresConfirmation = agentspecClientTool.requiresConfirmation; - const clientToolFunc = (kwargs: unknown): unknown => { - const kwargsRecord = applyInputDefaults( - isPlainRecord(kwargs) ? kwargs : {}, - agentspecClientTool.inputs ?? [], - ); - if (requiresConfirmation) { - const [confirmed, reason] = confirmToolUse(toolName, kwargsRecord); - if (!confirmed) { - throw new Error( - `Tool '${toolName}' was denied by the user (reason: ${reason}).`, - ); - } - } + const requestClientTool: ToolFunction = (kwargs) => { const toolRequest = { type: "client_tool_request", name: toolName, description: toolDescription, inputs: { args: [] as unknown[], - kwargs: kwargsRecord, + kwargs: kwargs as Record, }, }; return interrupt(toolRequest); }; + // Confirmation composes AFTER default injection, so the confirmation + // interrupt sees the defaulted kwargs (like Python's pydantic models). + const confirmed = confirmThen( + requestClientTool, + toolName, + agentspecClientTool.requiresConfirmation, + ); + const clientToolFunc = (kwargs: unknown): unknown => + confirmed( + applyInputDefaults( + isRecordLike(kwargs) ? kwargs : {}, + agentspecClientTool.inputs ?? [], + ), + ); // Note: no tool execution callback is attached, matching Python. return tool(clientToolFunc, { @@ -350,14 +344,14 @@ export function convertRemoteTool( const toolInputs = agentspecRemoteTool.inputs ?? []; const remoteToolFunc = createRemoteToolFunc(agentspecRemoteTool); const wrapped = confirmThen( - (input: unknown) => - remoteToolFunc(isPlainRecord(input) ? input : {}), + // `withDefaults` below always hands over the defaulted kwargs record. + (input: unknown) => remoteToolFunc(input as Record), toolName, agentspecRemoteTool.requiresConfirmation, ); const withDefaults: ToolFunction = (input, config) => wrapped( - applyInputDefaults(isPlainRecord(input) ? input : {}, toolInputs), + applyInputDefaults(isRecordLike(input) ? input : {}, toolInputs), config, ); return tool(withDefaults as (input: unknown) => unknown, { diff --git a/tsagentspec/src/adapters/langgraph/types.ts b/tsagentspec/src/adapters/langgraph/types.ts index 87469421..d51dbeb7 100644 --- a/tsagentspec/src/adapters/langgraph/types.ts +++ b/tsagentspec/src/adapters/langgraph/types.ts @@ -7,7 +7,8 @@ */ import type { BaseMessage, BaseMessageLike } from "@langchain/core/messages"; import type { RunnableConfig } from "@langchain/core/runnables"; -import type { BaseCheckpointSaver } from "@langchain/langgraph"; +import type { StructuredToolInterface } from "@langchain/core/tools"; +import type { BaseCheckpointSaver, Send } from "@langchain/langgraph"; /** Execution metadata produced by every flow node step. */ export interface NodeExecutionDetails { @@ -36,12 +37,49 @@ export interface FlowState { /** Result of a node executor: outputs plus execution details. */ export type ExecuteOutput = [NodeOutputs, NodeExecutionDetails]; +/** + * A compiled graph / react agent surface: everything invocable. + */ +export interface InvocableGraph { + invoke( + input: unknown, + config?: RunnableConfig, + ): Promise>; +} + +/** + * Loosely-typed StateGraph surface for graphs with dynamic node names (the + * `StateGraph` generics cannot express node sets decided at conversion time). + * The `path` signature is the superset of the adapter's routing functions: + * flow conditional edges route on `FlowState`, the ManagerWorkers router + * returns per-delegation `Send`s off a plain messages state. + */ +export interface DynamicStateGraph { + addNode(key: string, action: unknown): DynamicStateGraph; + addEdge(start: string, end: string): DynamicStateGraph; + addConditionalEdges( + source: string, + path: (state: FlowState & Record) => Send[] | string, + pathMap?: Record, + ): DynamicStateGraph; + compile(options?: { + checkpointer?: BaseCheckpointSaver; + name?: string; + }): unknown; +} + +/** A plain (sync or async) tool implementation: receives the parsed input object. */ +export type ToolImplementation = (input: unknown, config?: unknown) => unknown; + /** * Registry mapping tool names to runtime implementations: a LangChain structured * tool or a plain (sync or async) function. MCP tools are cached here under * `${clientTransportId}::${toolName}` keys. */ -export type ToolRegistry = Record; +export type ToolRegistry = Record< + string, + StructuredToolInterface | ToolImplementation +>; /** Options threaded through AgentSpec-to-LangGraph conversion. */ export interface ConvertOptions { diff --git a/tsagentspec/tests/adapters/langgraph/flow-nodes.test.ts b/tsagentspec/tests/adapters/langgraph/flow-nodes.test.ts deleted file mode 100644 index 4f7d7cab..00000000 --- a/tsagentspec/tests/adapters/langgraph/flow-nodes.test.ts +++ /dev/null @@ -1,1497 +0,0 @@ -/** - * Per-node flow execution tests for the LangGraph adapter. - * - * Mirrors `pyagentspec/tests/adapters/langgraph/flows/` (test_toolnode, - * test_branchingnode, test_llmnode, test_agentnode, test_flownode, - * test_catchexceptionode, test_inputmessagenode, test_outputmessagenode, - * test_mapnode, test_apinode) with fake chat models and a mocked fetch so - * every test runs offline. - * - * Documented divergences exercised here: - * - Tuples do not exist in JS: arrays map positionally onto multiple declared - * tool-node outputs (Python restricts positional mapping to tuples). - * - The TS SDK ApiNode has no `urlAllowList` field yet, so the Python - * allow-list rejection test has no TS equivalent (the adapter always calls - * the validation helper with `undefined`). - * - * Note on node construction: the Python SDK infers the missing IO side of - * Start/End nodes, so Python specs always carry both sides on the wire; the - * TS factories default the missing side to `[]`, so these tests pass both - * sides explicitly, matching the serialized wire format. - */ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { AIMessage, type BaseMessage } from "@langchain/core/messages"; -import { Command, MemorySaver } from "@langchain/langgraph"; -import { - createAgentNode, - createBranchingNode, - createCatchExceptionNode, - createClientTool, - createControlFlowEdge, - createDataFlowEdge, - createEndNode, - createFlow, - createFlowNode, - createInputMessageNode, - createLlmNode, - createMapNode, - createOutputMessageNode, - createServerTool, - createStartNode, - createToolNode, - createApiNode, - integerProperty, - listProperty, - nullProperty, - numberProperty, - objectProperty, - stringProperty, - unionProperty, - type ComponentWithIO, - type EndNode, - type Flow, - type LlmConfig, - type Property, - type ServerTool, - type StartNode, -} from "../../../src/index.js"; -import { DEFAULT_HTTP_REQUEST_TIMEOUT_MS } from "../../../src/adapters/common/tools-common.js"; -import { AgentSpecLoader } from "../../../src/adapters/langgraph/agentspec-loader.js"; -import { AgentSpecToLangGraphConverter } from "../../../src/adapters/langgraph/langgraph-converter.js"; -import { - FakeToolCallingChatModel, - getInterrupts, - installMockFetch, - loadWithFakeLlm, - makeAgent, - makeLlmConfig, - threadConfig, - toolCallMessage, - type MockFetchController, -} from "./test-helpers.js"; - -/** The invocable surface of a compiled flow graph. */ -interface CompiledFlow { - invoke( - input: unknown, - config?: unknown, - ): Promise>; -} - -/** A StartNode declaring the same properties as inputs and outputs. */ -function ioStartNode(name: string, props: Property[] = []): StartNode { - return createStartNode({ name, inputs: props, outputs: props }); -} - -/** An EndNode declaring the same properties as inputs and outputs. */ -function ioEndNode( - name: string, - props: Property[] = [], - branchName?: string, -): EndNode { - return createEndNode({ - name, - inputs: props, - outputs: props, - ...(branchName !== undefined ? { branchName } : {}), - }); -} - -function ctrl( - fromNode: Record, - toNode: Record, - fromBranch?: string, -) { - return createControlFlowEdge({ - name: `${String(fromNode["name"])}_to_${String(toNode["name"])}${ - fromBranch !== undefined ? `_${fromBranch}` : "" - }`, - fromNode, - toNode, - ...(fromBranch !== undefined ? { fromBranch } : {}), - }); -} - -function dataEdge( - sourceNode: ComponentWithIO, - destinationNode: ComponentWithIO, - sourceOutput: string, - destinationInput: string = sourceOutput, -) { - return createDataFlowEdge({ - name: `${sourceNode.name}.${sourceOutput}_to_${destinationNode.name}.${destinationInput}`, - sourceNode, - sourceOutput, - destinationNode, - destinationInput, - }); -} - -function outputsOf(result: Record): Record { - return result["outputs"] as Record; -} - -function messagesOf(result: Record): BaseMessage[] { - return result["messages"] as BaseMessage[]; -} - -function detailsOf(result: Record): Record { - return result["node_execution_details"] as Record; -} - -async function loadFlow( - flow: Flow, - options?: { - toolRegistry?: Record; - checkpointer?: MemorySaver; - }, -): Promise { - const loader = new AgentSpecLoader({ - ...(options?.toolRegistry !== undefined - ? { toolRegistry: options.toolRegistry } - : {}), - ...(options?.checkpointer !== undefined - ? { checkpointer: options.checkpointer } - : {}), - }); - return (await loader.loadComponent(flow)) as CompiledFlow; -} - -describe("ToolNode output-mapping matrix", () => { - /** Python's `_build_flow_with_client_tool`: start -> ClientTool -> end. */ - function buildClientToolFlow( - inputProp: Property, - outputProps: Property[], - ): Flow { - const start = ioStartNode("start", [inputProp]); - const clientTool = createClientTool({ - name: "echo_tool", - description: "Client-side tool used for testing", - inputs: [inputProp], - outputs: outputProps, - }); - const toolNode = createToolNode({ name: "tool", tool: clientTool }); - const end = ioEndNode("end", outputProps); - return createFlow({ - name: "tool_output_flow", - startNode: start, - nodes: [start, toolNode, end], - controlFlowConnections: [ctrl(start, toolNode), ctrl(toolNode, end)], - dataFlowConnections: [ - dataEdge(start, toolNode, inputProp.title), - ...outputProps.map((prop) => dataEdge(toolNode, end, prop.title)), - ], - }); - } - - /** Interrupt at the client tool, then resume with the given payload. */ - async function runFlowAndResume( - flow: Flow, - resumePayload: unknown, - ): Promise> { - const graph = await loadFlow(flow, { checkpointer: new MemorySaver() }); - const config = threadConfig("t"); - const first = await graph.invoke( - { inputs: { [flow.inputs![0]!.title]: 123 } }, - config, - ); - expect(getInterrupts(first)).toHaveLength(1); - const resumed = await graph.invoke( - new Command({ resume: resumePayload }), - config, - ); - return outputsOf(resumed); - } - - it("interrupts with the client_tool_request payload and resumes with the value", async () => { - const inputProp = numberProperty({ title: "input" }); - const outputProp = numberProperty({ title: "input_square" }); - const squareTool = createClientTool({ - name: "square_tool", - description: "Computes the square of a number", - inputs: [inputProp], - outputs: [outputProp], - }); - const start = ioStartNode("subflow_start", [inputProp]); - const toolNode = createToolNode({ name: "square_tool_node", tool: squareTool }); - const end = ioEndNode("subflow_end", [outputProp]); - const flow = createFlow({ - name: "Square number flow", - startNode: start, - nodes: [start, toolNode, end], - controlFlowConnections: [ctrl(start, toolNode), ctrl(toolNode, end)], - dataFlowConnections: [ - dataEdge(start, toolNode, "input"), - dataEdge(toolNode, end, "input_square"), - ], - }); - - const graph = await loadFlow(flow, { checkpointer: new MemorySaver() }); - const config = threadConfig("1"); - const first = await graph.invoke({ inputs: { input: 4 } }, config); - const interrupts = getInterrupts(first); - expect(interrupts).toHaveLength(1); - expect(interrupts[0]!.value).toEqual({ - type: "client_tool_request", - name: "square_tool", - description: "Computes the square of a number", - inputs: { args: [], kwargs: { input: 4 } }, - }); - - const resumed = await graph.invoke(new Command({ resume: 16 }), config); - expect(outputsOf(resumed)["input_square"]).toBe(16); - }); - - it("single ObjectProperty output wraps a multi-key dict under the declared key", async () => { - const flow = buildClientToolFlow(numberProperty({ title: "x" }), [ - objectProperty({ title: "out_dict", properties: {} }), - ]); - const outputs = await runFlowAndResume(flow, { a: 1, b: 2 }); - expect(outputs).toEqual({ out_dict: { a: 1, b: 2 } }); - }); - - it("single ObjectProperty output wraps a single-key dict under the declared key", async () => { - const flow = buildClientToolFlow(numberProperty({ title: "x" }), [ - objectProperty({ title: "out_dict", properties: {} }), - ]); - const outputs = await runFlowAndResume(flow, { a: 1 }); - expect(outputs).toEqual({ out_dict: { a: 1 } }); - }); - - it("single output uses a dict keyed by the declared title as-is", async () => { - const flow = buildClientToolFlow(numberProperty({ title: "x" }), [ - objectProperty({ title: "out_dict", properties: {} }), - ]); - const outputs = await runFlowAndResume(flow, { out_dict: 1 }); - expect(outputs).toEqual({ out_dict: 1 }); - }); - - it("scalar output passes through under the declared key", async () => { - const flow = buildClientToolFlow(numberProperty({ title: "x" }), [ - stringProperty({ title: "out_string" }), - ]); - const outputs = await runFlowAndResume(flow, "value"); - expect(outputs).toEqual({ out_string: "value" }); - }); - - it("multiple outputs filter the dict and defaults fill missing keys", async () => { - const flow = buildClientToolFlow(numberProperty({ title: "x" }), [ - numberProperty({ title: "a" }), - numberProperty({ title: "b", default: 0 }), - ]); - const outputs = await runFlowAndResume(flow, { a: 5 }); - expect(outputs).toEqual({ a: 5, b: 0 }); - }); - - it("list output maps to a single declared list output", async () => { - const flow = buildClientToolFlow(numberProperty({ title: "x" }), [ - listProperty({ title: "out", itemType: numberProperty({ title: "item" }) }), - ]); - const outputs = await runFlowAndResume(flow, [1, 2, 3]); - expect(outputs).toEqual({ out: [1, 2, 3] }); - }); - - it("scalar output maps to a single declared number output", async () => { - const flow = buildClientToolFlow(numberProperty({ title: "x" }), [ - numberProperty({ title: "out_number" }), - ]); - const outputs = await runFlowAndResume(flow, 42); - expect(outputs).toEqual({ out_number: 42 }); - }); - - it("array output onto a single declared string output is stringified", async () => { - // Python (tuple payload) stringifies via json.dumps -> "[1, 2]"; the TS - // cast mirrors json.dumps formatting (", " separator, not "[1,2]"). - const flow = buildClientToolFlow(numberProperty({ title: "x" }), [ - stringProperty({ title: "out" }), - ]); - const outputs = await runFlowAndResume(flow, [1, 2]); - expect(outputs).toEqual({ out: "[1, 2]" }); - }); - - it("array output shorter than the declared outputs raises like Python", async () => { - // Python raises IndexError instead of silently mapping undefined. - const flow = buildClientToolFlow(numberProperty({ title: "x" }), [ - numberProperty({ title: "a" }), - stringProperty({ title: "b" }), - ]); - await expect(runFlowAndResume(flow, [7])).rejects.toThrow( - "Tool node `tool` returned 1 value(s) but declares 2 outputs; " + - "no value for output `b`.", - ); - }); - - it("content-block list shorter than the declared outputs raises like Python", async () => { - const flow = buildClientToolFlow(numberProperty({ title: "x" }), [ - stringProperty({ title: "text_out" }), - stringProperty({ title: "image_out" }), - ]); - await expect( - runFlowAndResume(flow, [{ type: "text", text: "hello" }]), - ).rejects.toThrow( - "Tool node `tool` returned 1 content block(s) but declares 2 outputs; " + - "no value for output `image_out`.", - ); - }); - - it("array output maps positionally onto multiple outputs", async () => { - const flow = buildClientToolFlow(numberProperty({ title: "x" }), [ - numberProperty({ title: "a" }), - stringProperty({ title: "b" }), - ]); - const outputs = await runFlowAndResume(flow, [7, "ok"]); - expect(outputs).toEqual({ a: 7, b: "ok" }); - }); - - it("mixed array output maps positionally onto number/object/array outputs", async () => { - const flow = buildClientToolFlow(numberProperty({ title: "x" }), [ - numberProperty({ title: "num" }), - objectProperty({ title: "obj", properties: {} }), - listProperty({ title: "array", itemType: numberProperty({ title: "elem" }) }), - ]); - const outputs = await runFlowAndResume(flow, [7, { key: "val" }, [1]]); - expect(outputs).toEqual({ num: 7, obj: { key: "val" }, array: [1] }); - }); - - it("MCP content-block lists extract payloads positionally", async () => { - const flow = buildClientToolFlow(numberProperty({ title: "x" }), [ - stringProperty({ title: "text_out" }), - stringProperty({ title: "image_out" }), - ]); - const outputs = await runFlowAndResume(flow, [ - { type: "text", text: "hello" }, - { type: "image", base64: "imgdata" }, - ]); - expect(outputs).toEqual({ text_out: "hello", image_out: "imgdata" }); - }); -}); - -describe("BranchingNode", () => { - it("routes on the mapping, falls back to the default branch, and keeps defaults on untaken paths", async () => { - const customInput = stringProperty({ title: "custom_input" }); - const outputA = stringProperty({ title: "output_a", default: "no_value" }); - const outputB = stringProperty({ title: "output_b", default: "no_value" }); - const branchingNode = createBranchingNode({ - name: "branching", - mapping: { a: "branch_a", b: "branch_b" }, - inputs: [customInput], - }); - const start = ioStartNode("start", [customInput]); - const endA = ioEndNode("end_a", [outputA]); - const endB = ioEndNode("end_b", [outputB]); - const endDefault = ioEndNode("end_default"); - - const flow = createFlow({ - name: "flow", - startNode: start, - nodes: [start, branchingNode, endA, endB, endDefault], - controlFlowConnections: [ - ctrl(start, branchingNode), - ctrl(branchingNode, endA, "branch_a"), - ctrl(branchingNode, endB, "branch_b"), - ctrl(branchingNode, endDefault, "default"), - ], - dataFlowConnections: [ - dataEdge(start, branchingNode, "custom_input"), - dataEdge(start, endB, "custom_input", "output_b"), - dataEdge(start, endA, "custom_input", "output_a"), - ], - outputs: [outputA, outputB], - }); - - const graph = await loadFlow(flow); - - let result = await graph.invoke({ inputs: { custom_input: "a" } }); - expect(outputsOf(result)).toEqual({ output_a: "a", output_b: "no_value" }); - expect(result).toHaveProperty("messages"); - - result = await graph.invoke({ inputs: { custom_input: "b" } }); - expect(outputsOf(result)).toEqual({ output_a: "no_value", output_b: "b" }); - - result = await graph.invoke({ inputs: { custom_input: "no_match" } }); - expect(outputsOf(result)).toEqual({ - output_a: "no_value", - output_b: "no_value", - }); - }); - - it("raises the missing-input error when nothing feeds the branching input", async () => { - const customInput = stringProperty({ title: "custom_input" }); - const branchingNode = createBranchingNode({ - name: "branching", - mapping: { a: "branch_a" }, - inputs: [customInput], - }); - const start = ioStartNode("start"); - const endA = ioEndNode("end_a"); - const endDefault = ioEndNode("end_default"); - const flow = createFlow({ - name: "flow", - startNode: start, - nodes: [start, branchingNode, endA, endDefault], - controlFlowConnections: [ - ctrl(start, branchingNode), - ctrl(branchingNode, endA, "branch_a"), - ctrl(branchingNode, endDefault, "default"), - ], - dataFlowConnections: [], - }); - - const graph = await loadFlow(flow); - await expect(graph.invoke({ inputs: {} })).rejects.toThrow( - "Expected node `branching` to have a value for property `custom_input`, but none was found.", - ); - }); -}); - -/** Duck-typed chat-model fake for LlmNode tests. */ -function makeChatModelFake(opts: { - reply?: string; - structured?: Record; -}) { - const captured = { - prompts: [] as unknown[], - structuredSchemas: [] as Record[], - }; - const model = { - invoke: async (input: unknown) => { - captured.prompts.push(input); - return new AIMessage(opts.reply ?? ""); - }, - withStructuredOutput: (schema: Record) => { - captured.structuredSchemas.push(schema); - return { - invoke: async (input: unknown) => { - captured.prompts.push(input); - if (opts.structured === undefined) { - throw new Error("No structured response configured."); - } - return opts.structured; - }, - }; - }, - }; - return { model, captured }; -} - -describe("LlmNode", () => { - const nationality = stringProperty({ title: "nationality" }); - const car = stringProperty({ title: "car" }); - - function buildLlmFlow(outputs: Property[]): Flow { - const llmNode = createLlmNode({ - name: "llm_node", - llmConfig: makeLlmConfig(), - promptTemplate: - "Answer in one short sentence. What is the fastest {{nationality}} car?", - inputs: [nationality], - outputs, - }); - const start = ioStartNode("start", [nationality]); - const end = ioEndNode("end", outputs); - return createFlow({ - name: "flow", - startNode: start, - nodes: [start, llmNode, end], - controlFlowConnections: [ctrl(start, llmNode), ctrl(llmNode, end)], - dataFlowConnections: [ - dataEdge(start, llmNode, "nationality"), - ...outputs.map((prop) => dataEdge(llmNode, end, prop.title)), - ], - outputs, - }); - } - - it("unstructured: a single string output takes the message content of the rendered prompt call", async () => { - const { model, captured } = makeChatModelFake({ reply: "The Ferrari." }); - const { agent } = await loadWithFakeLlm(buildLlmFlow([car]), () => model); - - const result = await agent.invoke({ inputs: { nationality: "italian" } }); - expect(outputsOf(result)).toEqual({ car: "The Ferrari." }); - - // The prompt template was rendered against the node inputs. - expect(captured.structuredSchemas).toHaveLength(0); - expect(captured.prompts).toHaveLength(1); - const promptMessages = captured.prompts[0] as Array<{ - role: string; - content: string; - }>; - expect(promptMessages).toEqual([ - { - role: "user", - content: - "Answer in one short sentence. What is the fastest italian car?", - }, - ]); - }); - - it("structured: multiple outputs use withStructuredOutput with the built JSON schema", async () => { - const rating = integerProperty({ title: "rating" }); - const { model, captured } = makeChatModelFake({ - structured: { car: "Ferrari", rating: 9 }, - }); - const { agent } = await loadWithFakeLlm( - buildLlmFlow([car, rating]), - () => model, - ); - - const result = await agent.invoke({ inputs: { nationality: "italian" } }); - expect(outputsOf(result)).toEqual({ car: "Ferrari", rating: 9 }); - - expect(captured.structuredSchemas).toHaveLength(1); - expect(captured.structuredSchemas[0]).toEqual({ - title: "structured_output", - type: "object", - properties: { - car: car.jsonSchema, - rating: rating.jsonSchema, - }, - }); - }); - - it("structured: a flattened single-property result is rewrapped under the declared title", async () => { - const wrapped = objectProperty({ title: "wrapped", properties: {} }); - const { model } = makeChatModelFake({ structured: { inner: 1 } }); - const { agent } = await loadWithFakeLlm( - buildLlmFlow([wrapped]), - () => model, - ); - - const result = await agent.invoke({ inputs: { nationality: "italian" } }); - expect(outputsOf(result)).toEqual({ wrapped: { inner: 1 } }); - }); -}); - -describe("AgentNode in a flow", () => { - const nationality = stringProperty({ title: "nationality" }); - const car = stringProperty({ title: "car" }); - - function buildAgentFlow(): Flow { - const agentSpec = makeAgent({ - name: "agent", - systemPrompt: "What is the fastest {{nationality}} car?", - inputs: [nationality], - outputs: [car], - }); - const agentNode = createAgentNode({ name: "agent_node", agent: agentSpec }); - const start = ioStartNode("start", [nationality]); - const end = ioEndNode("end", [car]); - return createFlow({ - name: "flow", - startNode: start, - nodes: [start, agentNode, end], - controlFlowConnections: [ctrl(start, agentNode), ctrl(agentNode, end)], - dataFlowConnections: [ - dataEdge(start, agentNode, "nationality"), - dataEdge(agentNode, end, "car"), - ], - outputs: [car], - }); - } - - it("renders the system prompt from node inputs and extracts declared outputs", async () => { - const { agent, loader } = await loadWithFakeLlm(buildAgentFlow(), [ - toolCallMessage("AgentOutputModel", { car: "Ferrari 296" }), - ]); - - const result = await agent.invoke({ inputs: { nationality: "italian" } }); - expect(outputsOf(result)).toEqual({ car: "Ferrari 296" }); - - // The compiled react agent received the RENDERED system prompt (langchain - // v1 normalizes the prompt into a content-blocks array). - const fakeModel = loader.getFakeModel(); - const systemMessage = fakeModel.calls[0]![0]!; - expect(systemMessage.getType()).toBe("system"); - const systemText = JSON.stringify(systemMessage.content); - expect(systemText).toContain("What is the fastest italian car?"); - // No placeholder survives rendering. - expect(systemText).not.toContain("{{"); - }); - - it("emits the agent's answer as an assistant message when the node declares no outputs", async () => { - const chatAgent = makeAgent({ name: "chat_agent", systemPrompt: "Say hi." }); - const agentNode = createAgentNode({ name: "agent_node", agent: chatAgent }); - const start = ioStartNode("start"); - const end = ioEndNode("end"); - const flow = createFlow({ - name: "flow", - startNode: start, - nodes: [start, agentNode, end], - controlFlowConnections: [ctrl(start, agentNode), ctrl(agentNode, end)], - }); - - const { agent } = await loadWithFakeLlm(flow, [new AIMessage("Ciao!")]); - const result = await agent.invoke({ inputs: {} }); - - const messages = messagesOf(result); - expect(messages).toHaveLength(1); - expect(messages[0]!.getType()).toBe("ai"); - expect(messages[0]!.content).toBe("Ciao!"); - expect(outputsOf(result)).toEqual({}); - }); - - it("caches the compiled agent per rendered system prompt across invokes", async () => { - /** Converter that counts react-agent compilations and injects a fake LLM. */ - class CountingFakeConverter extends AgentSpecToLangGraphConverter { - compileCount = 0; - - constructor(private readonly model: unknown) { - super(); - } - - protected override async convertLlmConfig( - _llmConfig: LlmConfig, - ): Promise { - return this.model; - } - - protected override async createReactAgentWithGivenInfo( - info: unknown, - context: unknown, - ): Promise { - this.compileCount += 1; - return super.createReactAgentWithGivenInfo( - info as never, - context as never, - ); - } - } - - const fakeModel = new FakeToolCallingChatModel({ - responses: [toolCallMessage("AgentOutputModel", { car: "Ferrari" })], - }); - const converter = new CountingFakeConverter(fakeModel); - const graph = (await converter.convert(buildAgentFlow(), {})) as CompiledFlow; - - // Compilation is lazy: nothing is compiled at load time. - expect(converter.compileCount).toBe(0); - - let result = await graph.invoke({ inputs: { nationality: "italian" } }); - expect(outputsOf(result)["car"]).toBe("Ferrari"); - expect(converter.compileCount).toBe(1); - - // Same rendered prompt: the cached agent is reused. - result = await graph.invoke({ inputs: { nationality: "italian" } }); - expect(converter.compileCount).toBe(1); - - // A different rendered prompt compiles a new agent. - result = await graph.invoke({ inputs: { nationality: "french" } }); - expect(outputsOf(result)["car"]).toBe("Ferrari"); - expect(converter.compileCount).toBe(2); - }); -}); - -describe("FlowNode", () => { - it("executes the subflow and passes its outputs through", async () => { - const customProp = stringProperty({ title: "custom_prop" }); - const subStart = ioStartNode("start", [customProp]); - const subEnd = ioEndNode("end", [customProp]); - const subflow = createFlow({ - name: "subflow", - startNode: subStart, - nodes: [subStart, subEnd], - controlFlowConnections: [ctrl(subStart, subEnd)], - dataFlowConnections: [dataEdge(subStart, subEnd, "custom_prop")], - inputs: [customProp], - outputs: [customProp], - }); - - const flowNode = createFlowNode({ name: "flow_node", subflow }); - const start = ioStartNode("start", [customProp]); - const end = ioEndNode("end", [customProp]); - const flow = createFlow({ - name: "outer", - startNode: start, - nodes: [start, flowNode, end], - controlFlowConnections: [ctrl(start, flowNode), ctrl(flowNode, end)], - dataFlowConnections: [ - dataEdge(start, flowNode, "custom_prop"), - dataEdge(flowNode, end, "custom_prop"), - ], - inputs: [customProp], - outputs: [customProp], - }); - - const graph = await loadFlow(flow); - const result = await graph.invoke({ inputs: { custom_prop: "custom" } }); - expect(result).toHaveProperty("messages"); - expect(outputsOf(result)).toEqual({ custom_prop: "custom" }); - }); -}); - -describe("CatchExceptionNode", () => { - const inp = integerProperty({ title: "x" }); - const outp = stringProperty({ title: "y", default: "" }); - - function makeErrorInfoProperty(): Property { - return unionProperty({ - title: "error_info", - anyOf: [ - stringProperty({ title: "error_info" }), - nullProperty({ title: "error_info" }), - ], - default: null, - }); - } - - function buildToolSubflow(tool: ServerTool, endBranch?: string): Flow { - const subStart = ioStartNode("sub_start", [inp]); - const toolNode = createToolNode({ name: `${tool.name}_node`, tool }); - const subEnd = ioEndNode("sub_end", [outp], endBranch); - return createFlow({ - name: `${tool.name}_subflow`, - startNode: subStart, - nodes: [subStart, toolNode, subEnd], - controlFlowConnections: [ctrl(subStart, toolNode), ctrl(toolNode, subEnd)], - dataFlowConnections: [ - dataEdge(subStart, toolNode, "x"), - dataEdge(toolNode, subEnd, "y"), - ], - inputs: [inp], - outputs: [outp], - }); - } - - it("routes exceptions to the caught_exception_branch with default outputs and caught_exception_info", async () => { - const flakyTool = createServerTool({ - name: "flaky_tool", - description: "Raises for negative inputs", - inputs: [inp], - outputs: [outp], - }); - const subflow = buildToolSubflow(flakyTool); - const catchNode = createCatchExceptionNode({ name: "catch", subflow }); - const errorInfo = makeErrorInfoProperty(); - const start = ioStartNode("start", [inp]); - const end = ioEndNode("end", [outp]); - const errorEnd = ioEndNode("error_end", [errorInfo], "ERROR"); - const flow = createFlow({ - name: "outer", - startNode: start, - nodes: [start, catchNode, end, errorEnd], - controlFlowConnections: [ - ctrl(start, catchNode), - ctrl(catchNode, end), - ctrl(catchNode, errorEnd, "caught_exception_branch"), - ], - dataFlowConnections: [ - dataEdge(start, catchNode, "x"), - dataEdge(catchNode, end, "y"), - dataEdge(catchNode, errorEnd, "caught_exception_info", "error_info"), - ], - inputs: [inp], - outputs: [outp, errorInfo], - }); - - const graph = await loadFlow(flow, { - toolRegistry: { - flaky_tool: (input: unknown) => { - const { x } = input as { x: number }; - if (x < 0) { - throw new Error("x must be non-negative"); - } - return "ok"; - }, - }, - }); - - // Case 1: no exception -> subflow output passes through. - let result = await graph.invoke({ inputs: { x: 1 } }); - expect(outputsOf(result)["y"]).toBe("ok"); - expect(outputsOf(result)["error_info"]).toBeNull(); - - // Case 2: exception -> default output value, ERROR end branch, and the - // exception message routed through caught_exception_info. - result = await graph.invoke({ inputs: { x: -1 } }); - expect(outputsOf(result)["y"]).toBe(""); - expect(detailsOf(result)["branch"]).toBe("ERROR"); - const caught = outputsOf(result)["error_info"]; - expect(typeof caught).toBe("string"); - expect(String(caught)).toContain("x must be non-negative"); - }); - - it("propagates a custom subflow end branch on success with null exception info", async () => { - const okTool = createServerTool({ - name: "ok_tool", - description: "Always returns ok", - inputs: [inp], - outputs: [outp], - }); - const subflow = buildToolSubflow(okTool, "OK"); - const catchNode = createCatchExceptionNode({ name: "catch", subflow }); - const errorInfo = makeErrorInfoProperty(); - const start = ioStartNode("start", [inp]); - const okEnd = ioEndNode("ok_end", [outp, errorInfo]); - const otherEnd = ioEndNode("other_end"); - const flow = createFlow({ - name: "outer", - startNode: start, - nodes: [start, catchNode, okEnd, otherEnd], - controlFlowConnections: [ - ctrl(start, catchNode), - ctrl(catchNode, okEnd, "OK"), - ctrl(catchNode, otherEnd), - ], - dataFlowConnections: [ - dataEdge(start, catchNode, "x"), - dataEdge(catchNode, okEnd, "y"), - dataEdge(catchNode, okEnd, "caught_exception_info", "error_info"), - ], - inputs: [inp], - outputs: [outp, errorInfo], - }); - - const graph = await loadFlow(flow, { - toolRegistry: { ok_tool: () => "ok" }, - }); - const result = await graph.invoke({ inputs: { x: 7 } }); - expect(detailsOf(result)["branch"]).toBe("next"); - expect(outputsOf(result)["y"]).toBe("ok"); - expect(outputsOf(result)["error_info"]).toBeNull(); - }); - - it("uses the default next branch on success with null exception info", async () => { - const okTool = createServerTool({ - name: "ok_tool_default", - description: "Always returns ok", - inputs: [inp], - outputs: [outp], - }); - const subflow = buildToolSubflow(okTool); - const catchNode = createCatchExceptionNode({ name: "catch", subflow }); - const errorInfo = makeErrorInfoProperty(); - const start = ioStartNode("start", [inp]); - const nextEnd = ioEndNode("next_end", [outp, errorInfo]); - const flow = createFlow({ - name: "outer", - startNode: start, - nodes: [start, catchNode, nextEnd], - controlFlowConnections: [ctrl(start, catchNode), ctrl(catchNode, nextEnd)], - dataFlowConnections: [ - dataEdge(start, catchNode, "x"), - dataEdge(catchNode, nextEnd, "y"), - dataEdge(catchNode, nextEnd, "caught_exception_info", "error_info"), - ], - inputs: [inp], - outputs: [outp, errorInfo], - }); - - const graph = await loadFlow(flow, { - toolRegistry: { ok_tool_default: () => "ok" }, - }); - const result = await graph.invoke({ inputs: { x: 5 } }); - expect(detailsOf(result)["branch"]).toBe("next"); - expect(outputsOf(result)["y"]).toBe("ok"); - expect(outputsOf(result)["error_info"]).toBeNull(); - }); -}); - -describe("InputMessageNode", () => { - it("interrupts with an empty payload; the resume value becomes the output and a user message", async () => { - const customInput = stringProperty({ title: "custom_input" }); - const inputMessageNode = createInputMessageNode({ - name: "input_message", - outputs: [customInput], - }); - const start = ioStartNode("start"); - const end = ioEndNode("end", [customInput]); - const flow = createFlow({ - name: "flow", - startNode: start, - nodes: [start, inputMessageNode, end], - controlFlowConnections: [ - ctrl(start, inputMessageNode), - ctrl(inputMessageNode, end), - ], - dataFlowConnections: [dataEdge(inputMessageNode, end, "custom_input")], - outputs: [customInput], - }); - - const graph = await loadFlow(flow, { checkpointer: new MemorySaver() }); - const config = threadConfig("1"); - - const first = await graph.invoke({}, config); - const interrupts = getInterrupts(first); - expect(interrupts).toHaveLength(1); - expect(interrupts[0]!.value).toBe(""); - - const result = await graph.invoke(new Command({ resume: "3" }), config); - expect(outputsOf(result)).toEqual({ custom_input: "3" }); - - const messages = messagesOf(result); - expect(messages).toHaveLength(1); - expect(messages[0]!.getType()).toBe("human"); - expect(messages[0]!.content).toBe("3"); - }); -}); - -describe("OutputMessageNode", () => { - it("emits the rendered template as an assistant message", async () => { - const customInput = stringProperty({ title: "custom_input" }); - const outputMessageNode = createOutputMessageNode({ - name: "output_message", - message: "Hey {{custom_input}}", - inputs: [customInput], - }); - const start = ioStartNode("start", [customInput]); - const end = ioEndNode("end"); - const flow = createFlow({ - name: "flow", - startNode: start, - nodes: [start, outputMessageNode, end], - controlFlowConnections: [ - ctrl(start, outputMessageNode), - ctrl(outputMessageNode, end), - ], - dataFlowConnections: [dataEdge(start, outputMessageNode, "custom_input")], - inputs: [customInput], - }); - - const graph = await loadFlow(flow); - const result = await graph.invoke({ inputs: { custom_input: "custom" } }); - - expect(result).toHaveProperty("outputs"); - const messages = messagesOf(result); - expect(messages).toHaveLength(1); - expect(messages[0]!.getType()).toBe("ai"); - expect(messages[0]!.content).toBe("Hey custom"); - }); -}); - -describe("MapNode", () => { - function buildSquareSubflow(): Flow { - const xProp = numberProperty({ title: "input" }); - const xSquareProp = numberProperty({ title: "input_square" }); - const squareTool = createServerTool({ - name: "square_tool", - description: "Computes the square of a number", - inputs: [xProp], - outputs: [xSquareProp], - }); - const subStart = ioStartNode("subflow_start", [xProp]); - const toolNode = createToolNode({ name: "square_tool_node", tool: squareTool }); - const subEnd = ioEndNode("subflow_end", [xSquareProp]); - return createFlow({ - name: "Square number flow", - startNode: subStart, - nodes: [subStart, toolNode, subEnd], - controlFlowConnections: [ctrl(subStart, toolNode), ctrl(toolNode, subEnd)], - dataFlowConnections: [ - dataEdge(subStart, toolNode, "input"), - dataEdge(toolNode, subEnd, "input_square"), - ], - }); - } - - const iteratedInput = unionProperty({ - title: "iterated_input", - anyOf: [ - numberProperty({ title: "input" }), - listProperty({ title: "input", itemType: numberProperty({ title: "item" }) }), - ], - }); - const collectedSquare = listProperty({ - title: "collected_input_square", - itemType: numberProperty({ title: "item" }), - }); - const squareRegistry = { - square_tool: (input: unknown) => { - const { input: value } = input as { input: number }; - return value * value; - }, - }; - - it("iterates the subflow over the list input and collects the outputs", async () => { - const mapNode = createMapNode({ - name: "square_number_map_node", - subflow: buildSquareSubflow(), - inputs: [iteratedInput], - outputs: [collectedSquare], - }); - const inputList = listProperty({ - title: "input_list", - itemType: numberProperty({ title: "item" }), - }); - const start = ioStartNode("outer_start", [inputList]); - const end = ioEndNode("outer_end", [collectedSquare]); - const flow = createFlow({ - name: "flow to square all elements of a list", - startNode: start, - nodes: [start, mapNode, end], - controlFlowConnections: [ctrl(start, mapNode), ctrl(mapNode, end)], - dataFlowConnections: [ - dataEdge(start, mapNode, "input_list", "iterated_input"), - dataEdge(mapNode, end, "collected_input_square"), - ], - }); - - const graph = await loadFlow(flow, { toolRegistry: squareRegistry }); - const result = await graph.invoke({ inputs: { input_list: [1, 2, 3, 4] } }); - expect(outputsOf(result)).toEqual({ - collected_input_square: [1, 4, 9, 16], - }); - }); - - it("raises when iterated inputs have different lengths", async () => { - const aProp = numberProperty({ title: "a" }); - const bProp = numberProperty({ title: "b" }); - const totalProp = numberProperty({ title: "total" }); - const sumTool = createServerTool({ - name: "sum_tool", - description: "Adds two numbers", - inputs: [aProp, bProp], - outputs: [totalProp], - }); - const subStart = ioStartNode("sum_start", [aProp, bProp]); - const toolNode = createToolNode({ name: "sum_tool_node", tool: sumTool }); - const subEnd = ioEndNode("sum_end", [totalProp]); - const sumSubflow = createFlow({ - name: "sum_subflow", - startNode: subStart, - nodes: [subStart, toolNode, subEnd], - controlFlowConnections: [ctrl(subStart, toolNode), ctrl(toolNode, subEnd)], - dataFlowConnections: [ - dataEdge(subStart, toolNode, "a"), - dataEdge(subStart, toolNode, "b"), - dataEdge(toolNode, subEnd, "total"), - ], - inputs: [aProp, bProp], - outputs: [totalProp], - }); - - const iteratedA = unionProperty({ - title: "iterated_a", - anyOf: [ - numberProperty({ title: "a" }), - listProperty({ title: "a", itemType: numberProperty({ title: "item" }) }), - ], - }); - const iteratedB = unionProperty({ - title: "iterated_b", - anyOf: [ - numberProperty({ title: "b" }), - listProperty({ title: "b", itemType: numberProperty({ title: "item" }) }), - ], - }); - const collectedTotal = listProperty({ - title: "collected_total", - itemType: numberProperty({ title: "item" }), - }); - const mapNode = createMapNode({ - name: "sum_map_node", - subflow: sumSubflow, - inputs: [iteratedA, iteratedB], - outputs: [collectedTotal], - }); - - const listA = listProperty({ - title: "list_a", - itemType: numberProperty({ title: "item" }), - }); - const listB = listProperty({ - title: "list_b", - itemType: numberProperty({ title: "item" }), - }); - const start = ioStartNode("outer_start", [listA, listB]); - const end = ioEndNode("outer_end", [collectedTotal]); - const flow = createFlow({ - name: "sum_map_flow", - startNode: start, - nodes: [start, mapNode, end], - controlFlowConnections: [ctrl(start, mapNode), ctrl(mapNode, end)], - dataFlowConnections: [ - dataEdge(start, mapNode, "list_a", "iterated_a"), - dataEdge(start, mapNode, "list_b", "iterated_b"), - dataEdge(mapNode, end, "collected_total"), - ], - }); - - const graph = await loadFlow(flow, { - toolRegistry: { - sum_tool: (input: unknown) => { - const { a, b } = input as { a: number; b: number }; - return a + b; - }, - }, - }); - await expect( - graph.invoke({ inputs: { list_a: [1, 2], list_b: [10, 20, 30] } }), - ).rejects.toThrow("Found inputs to iterate with different sizes"); - }); - - it("raises naming the input when an iterated input has no length at runtime", async () => { - // The converter selects iterated_input statically (list-typed schema), - // but the runtime value is a scalar: the error names the node and the - // offending input instead of reusing the size-mismatch text. - const mapNode = createMapNode({ - name: "square_number_map_node", - subflow: buildSquareSubflow(), - inputs: [iteratedInput], - outputs: [collectedSquare], - }); - const inputList = listProperty({ - title: "input_list", - itemType: numberProperty({ title: "item" }), - }); - const start = ioStartNode("outer_start", [inputList]); - const end = ioEndNode("outer_end", [collectedSquare]); - const flow = createFlow({ - name: "flow to square all elements of a list", - startNode: start, - nodes: [start, mapNode, end], - controlFlowConnections: [ctrl(start, mapNode), ctrl(mapNode, end)], - dataFlowConnections: [ - dataEdge(start, mapNode, "input_list", "iterated_input"), - dataEdge(mapNode, end, "collected_input_square"), - ], - }); - - const graph = await loadFlow(flow, { toolRegistry: squareRegistry }); - await expect(graph.invoke({ inputs: { input_list: 7 } })).rejects.toThrow( - "MapNode `square_number_map_node` cannot iterate over input " + - "`iterated_input`: 7 has no length", - ); - }); - - it("raises when no data-flow edge selects an input to iterate", async () => { - const mapNode = createMapNode({ - name: "square_map_scalar", - subflow: buildSquareSubflow(), - inputs: [iteratedInput], - outputs: [collectedSquare], - }); - // The edge feeds a SCALAR into iterated_input, so the converter finds no - // list-typed source matching the subflow input and selects nothing. - const singleX = numberProperty({ title: "single_x" }); - const start = ioStartNode("outer_start", [singleX]); - const end = ioEndNode("outer_end", [collectedSquare]); - const flow = createFlow({ - name: "scalar_map_flow", - startNode: start, - nodes: [start, mapNode, end], - controlFlowConnections: [ctrl(start, mapNode), ctrl(mapNode, end)], - dataFlowConnections: [ - dataEdge(start, mapNode, "single_x", "iterated_input"), - dataEdge(mapNode, end, "collected_input_square"), - ], - }); - - const graph = await loadFlow(flow, { toolRegistry: squareRegistry }); - await expect(graph.invoke({ inputs: { single_x: 3 } })).rejects.toThrow( - "MapNode has no inputs to iterate", - ); - }); -}); - -describe("ApiNode", () => { - let mockFetch: MockFetchController | undefined; - - afterEach(() => { - mockFetch?.restore(); - mockFetch = undefined; - vi.restoreAllMocks(); - }); - - function buildApiFlow( - apiNode: Record, - inputProps: Property[], - outputProps: Property[], - ): Flow { - const start = ioStartNode("start", inputProps); - const end = ioEndNode("end", outputProps); - return createFlow({ - name: "api_flow", - startNode: start, - nodes: [start, apiNode, end], - controlFlowConnections: [ctrl(start, apiNode), ctrl(apiNode, end)], - dataFlowConnections: [ - ...inputProps.map((prop) => - dataEdge(start, apiNode as unknown as ComponentWithIO, prop.title), - ), - ...outputProps.map((prop) => - dataEdge(apiNode as unknown as ComponentWithIO, end, prop.title), - ), - ], - inputs: inputProps, - outputs: outputProps, - }); - } - - it("GET: templates the URL, query params and headers, and maps the JSON response", async () => { - // Templated URL destination without an allow list warns per Python rules. - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - const inputProps = [ - stringProperty({ title: "host" }), - stringProperty({ title: "order_id" }), - stringProperty({ title: "flag" }), - stringProperty({ title: "token" }), - ]; - const status = stringProperty({ title: "status" }); - const apiNode = createApiNode({ - name: "api", - url: "https://{{host}}/orders/{{order_id}}", - httpMethod: "GET", - queryParams: { verbose: "{{flag}}" }, - headers: { "X-Auth": "Bearer {{token}}" }, - inputs: inputProps, - outputs: [status], - }); - const flow = buildApiFlow(apiNode, inputProps, [status]); - const graph = await loadFlow(flow); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining("ApiNode `api` uses placeholders in the URL destination"), - ); - - mockFetch = installMockFetch(() => ({ status: "ok" })); - const result = await graph.invoke({ - inputs: { - host: "allowed.example.com", - order_id: "123", - flag: "yes", - token: "tok-1", - }, - }); - - expect(outputsOf(result)).toEqual({ status: "ok" }); - expect(mockFetch.calls).toHaveLength(1); - expect(mockFetch.calls[0]!.url).toBe( - "https://allowed.example.com/orders/123?verbose=yes", - ); - const init = mockFetch.calls[0]!.init!; - expect(init.method).toBe("GET"); - expect((init.headers as Record)["X-Auth"]).toBe( - "Bearer tok-1", - ); - expect(init.body).toBeUndefined(); - }); - - it("GET: warns when declared request data is dropped (fetch forbids GET bodies)", async () => { - // Python's httpx sends the body on GET; fetch cannot, so the adapter - // must at least warn instead of silently discarding the declared data. - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - const inputProps = [stringProperty({ title: "term" })]; - const echo = stringProperty({ title: "echo" }); - const apiNode = createApiNode({ - name: "api", - url: "https://api.example.com/search", - httpMethod: "GET", - data: { q: "{{term}}" }, - inputs: inputProps, - outputs: [echo], - }); - const flow = buildApiFlow(apiNode, inputProps, [echo]); - const graph = await loadFlow(flow); - - mockFetch = installMockFetch(() => ({ echo: "done" })); - await graph.invoke({ inputs: { term: "boots" } }); - - expect(mockFetch.calls[0]!.init!.body).toBeUndefined(); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining( - "ApiNode `api` declares request data for HTTP method GET", - ), - ); - }); - - it("GET: does not warn about a dropped body for the default empty data", async () => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - const echo = stringProperty({ title: "echo" }); - const apiNode = createApiNode({ - name: "api", - url: "https://api.example.com/plain", - httpMethod: "GET", - outputs: [echo], - }); - const flow = buildApiFlow(apiNode, [], [echo]); - const graph = await loadFlow(flow); - - mockFetch = installMockFetch(() => ({ echo: "done" })); - await graph.invoke({ inputs: {} }); - - expect(warnSpy).not.toHaveBeenCalledWith( - expect.stringContaining("declares request data"), - ); - }); - - it("POST: templated dict data is sent as a JSON body with a JSON content type", async () => { - const inputProps = [ - stringProperty({ title: "order_id" }), - stringProperty({ title: "tag" }), - ]; - const echo = stringProperty({ title: "echo" }); - const apiNode = createApiNode({ - name: "api", - url: "https://api.example.com/orders", - httpMethod: "POST", - data: { order: { id: "{{order_id}}" }, tags: ["{{tag}}", "static"] }, - inputs: inputProps, - outputs: [echo], - }); - const flow = buildApiFlow(apiNode, inputProps, [echo]); - const graph = await loadFlow(flow); - - mockFetch = installMockFetch(() => ({ echo: "done" })); - const result = await graph.invoke({ - inputs: { order_id: "777", tag: "blue" }, - }); - - expect(outputsOf(result)).toEqual({ echo: "done" }); - const init = mockFetch.calls[0]!.init!; - expect(init.method).toBe("POST"); - expect( - (init.headers as Record)["Content-Type"], - ).toBe("application/json"); - expect(JSON.parse(String(init.body))).toEqual({ - order: { id: "777" }, - tags: ["blue", "static"], - }); - }); - - it("POST: an urlencoded content type sends dict data as a form body and templates header keys", async () => { - const inputProps = [ - stringProperty({ title: "a" }), - stringProperty({ title: "key_name" }), - stringProperty({ title: "key_val" }), - ]; - const echo = stringProperty({ title: "echo" }); - const apiNode = createApiNode({ - name: "api", - url: "https://api.example.com/form", - httpMethod: "POST", - data: { a: "{{a}}", b: "static" }, - headers: { - "Content-Type": "application/x-www-form-urlencoded", - "X-{{key_name}}": "{{key_val}}", - }, - inputs: inputProps, - outputs: [echo], - }); - const flow = buildApiFlow(apiNode, inputProps, [echo]); - const graph = await loadFlow(flow); - - mockFetch = installMockFetch(() => ({ echo: "done" })); - const result = await graph.invoke({ - inputs: { a: "1", key_name: "Trace", key_val: "on" }, - }); - - expect(outputsOf(result)).toEqual({ echo: "done" }); - const init = mockFetch.calls[0]!.init!; - const headers = init.headers as Record; - expect(headers["Content-Type"]).toBe("application/x-www-form-urlencoded"); - expect(headers["X-Trace"]).toBe("on"); - expect(init.body).toBeInstanceOf(URLSearchParams); - expect(String(init.body)).toBe("a=1&b=static"); - }); - - it("POST: an empty-string Content-Type falls through to the lowercase header (Python `or` parity)", async () => { - // Python looks the content type up with `get("Content-Type") or - // get("content-type")`: an empty-string uppercase header is falsy, so - // the lowercase urlencoded header wins and dict data goes out as a form - // body (a `??` lookup would stop at the empty string and send JSON). - const echo = stringProperty({ title: "echo" }); - const apiNode = createApiNode({ - name: "api", - url: "https://api.example.com/form", - httpMethod: "POST", - data: { a: "1" }, - headers: { - "Content-Type": "", - "content-type": "application/x-www-form-urlencoded", - }, - outputs: [echo], - }); - const flow = buildApiFlow(apiNode, [], [echo]); - const graph = await loadFlow(flow); - - mockFetch = installMockFetch(() => ({ echo: "done" })); - const result = await graph.invoke({ inputs: {} }); - - expect(outputsOf(result)).toEqual({ echo: "done" }); - const init = mockFetch.calls[0]!.init!; - expect(init.body).toBeInstanceOf(URLSearchParams); - expect(String(init.body)).toBe("a=1"); - }); - - it("does not follow redirects: a 3xx response body maps to the node outputs like any status", async () => { - // Python's httpx does not follow redirects (follow_redirects defaults to - // False) and parses the returned 3xx body like any other status; the - // adapter uses redirect: "manual" so undici returns the 3xx response - // itself instead of requesting the Location target. - const echo = stringProperty({ title: "echo" }); - const apiNode = createApiNode({ - name: "api", - url: "https://api.example.com/redirecting", - httpMethod: "GET", - outputs: [echo], - }); - const flow = buildApiFlow(apiNode, [], [echo]); - const graph = await loadFlow(flow); - - mockFetch = installMockFetch( - () => - new Response('{"echo": "from-redirect-response"}', { - status: 302, - headers: { - "Content-Type": "application/json", - Location: "https://attacker.example/exfil", - }, - }), - ); - const result = await graph.invoke({ inputs: {} }); - - expect(outputsOf(result)).toEqual({ echo: "from-redirect-response" }); - expect(mockFetch.calls).toHaveLength(1); - expect(mockFetch.calls[0]!.init!.redirect).toBe("manual"); - }); - - it("attaches the default httpx-parity timeout and names the node on a timeout abort", async () => { - const echo = stringProperty({ title: "echo" }); - const apiNode = createApiNode({ - name: "api", - url: "https://api.example.com/slow", - httpMethod: "GET", - outputs: [echo], - }); - const flow = buildApiFlow(apiNode, [], [echo]); - const graph = await loadFlow(flow); - - mockFetch = installMockFetch(() => { - throw new DOMException( - "The operation was aborted due to timeout", - "TimeoutError", - ); - }); - - await expect(graph.invoke({ inputs: {} })).rejects.toThrow( - `ApiNode \`api\` HTTP request timed out after ${DEFAULT_HTTP_REQUEST_TIMEOUT_MS}ms.`, - ); - expect(mockFetch.calls).toHaveLength(1); - expect(mockFetch.calls[0]!.init!.signal).toBeInstanceOf(AbortSignal); - }); - - it("POST: string data is sent as a raw body without forcing a content type", async () => { - const inputProps = [stringProperty({ title: "val" })]; - const echo = stringProperty({ title: "echo" }); - const apiNode = createApiNode({ - name: "api", - url: "https://api.example.com/raw", - httpMethod: "POST", - data: "payload={{val}}", - inputs: inputProps, - outputs: [echo], - }); - const flow = buildApiFlow(apiNode, inputProps, [echo]); - const graph = await loadFlow(flow); - - mockFetch = installMockFetch(() => ({ echo: "done" })); - const result = await graph.invoke({ inputs: { val: "hello" } }); - - expect(outputsOf(result)).toEqual({ echo: "done" }); - const init = mockFetch.calls[0]!.init!; - expect(init.body).toBe("payload=hello"); - const headerKeys = Object.keys(init.headers as Record); - expect( - headerKeys.some((key) => key.toLowerCase() === "content-type"), - ).toBe(false); - }); -}); diff --git a/tsagentspec/tests/adapters/langgraph/flow-nodes/agent-node.test.ts b/tsagentspec/tests/adapters/langgraph/flow-nodes/agent-node.test.ts new file mode 100644 index 00000000..f9708375 --- /dev/null +++ b/tsagentspec/tests/adapters/langgraph/flow-nodes/agent-node.test.ts @@ -0,0 +1,150 @@ +/** + * AgentNode flow execution tests for the LangGraph adapter. + * + * Mirrors `pyagentspec/tests/adapters/langgraph/flows/test_agentnode.py` with + * fake chat models injected at the converter seam; all tests run offline. + */ +import { describe, expect, it } from "vitest"; +import { AIMessage } from "@langchain/core/messages"; +import { + createAgentNode, + createFlow, + stringProperty, + type Flow, + type LlmConfig, +} from "../../../../src/index.js"; +import { AgentSpecToLangGraphConverter } from "../../../../src/adapters/langgraph/langgraph-converter.js"; +import { + FakeToolCallingChatModel, + ctrl, + dataEdge, + ioEndNode, + ioStartNode, + loadWithFakeLlm, + makeAgent, + messagesOf, + outputsOf, + toolCallMessage, + type CompiledFlow, +} from "../test-helpers.js"; + +describe("AgentNode in a flow", () => { + const nationality = stringProperty({ title: "nationality" }); + const car = stringProperty({ title: "car" }); + + function buildAgentFlow(): Flow { + const agentSpec = makeAgent({ + name: "agent", + systemPrompt: "What is the fastest {{nationality}} car?", + inputs: [nationality], + outputs: [car], + }); + const agentNode = createAgentNode({ name: "agent_node", agent: agentSpec }); + const start = ioStartNode("start", [nationality]); + const end = ioEndNode("end", [car]); + return createFlow({ + name: "flow", + startNode: start, + nodes: [start, agentNode, end], + controlFlowConnections: [ctrl(start, agentNode), ctrl(agentNode, end)], + dataFlowConnections: [ + dataEdge(start, agentNode, "nationality"), + dataEdge(agentNode, end, "car"), + ], + outputs: [car], + }); + } + + it("renders the system prompt from node inputs and extracts declared outputs", async () => { + const { agent, loader } = await loadWithFakeLlm(buildAgentFlow(), [ + toolCallMessage("AgentOutputModel", { car: "Ferrari 296" }), + ]); + + const result = await agent.invoke({ inputs: { nationality: "italian" } }); + expect(outputsOf(result)).toEqual({ car: "Ferrari 296" }); + + // The compiled react agent received the RENDERED system prompt (langchain + // v1 normalizes the prompt into a content-blocks array). + const fakeModel = loader.getFakeModel(); + const systemMessage = fakeModel.calls[0]![0]!; + expect(systemMessage.getType()).toBe("system"); + const systemText = JSON.stringify(systemMessage.content); + expect(systemText).toContain("What is the fastest italian car?"); + // No placeholder survives rendering. + expect(systemText).not.toContain("{{"); + }); + + it("emits the agent's answer as an assistant message when the node declares no outputs", async () => { + const chatAgent = makeAgent({ name: "chat_agent", systemPrompt: "Say hi." }); + const agentNode = createAgentNode({ name: "agent_node", agent: chatAgent }); + const start = ioStartNode("start"); + const end = ioEndNode("end"); + const flow = createFlow({ + name: "flow", + startNode: start, + nodes: [start, agentNode, end], + controlFlowConnections: [ctrl(start, agentNode), ctrl(agentNode, end)], + }); + + const { agent } = await loadWithFakeLlm(flow, [new AIMessage("Ciao!")]); + const result = await agent.invoke({ inputs: {} }); + + const messages = messagesOf(result); + expect(messages).toHaveLength(1); + expect(messages[0]!.getType()).toBe("ai"); + expect(messages[0]!.content).toBe("Ciao!"); + expect(outputsOf(result)).toEqual({}); + }); + + it("caches the compiled agent per rendered system prompt across invokes", async () => { + /** Converter that counts react-agent compilations and injects a fake LLM. */ + class CountingFakeConverter extends AgentSpecToLangGraphConverter { + compileCount = 0; + + constructor(private readonly model: unknown) { + super(); + } + + protected override async convertLlmConfig( + _llmConfig: LlmConfig, + ): Promise { + return this.model; + } + + protected override async createReactAgent( + agent: unknown, + context: unknown, + overrides?: unknown, + ): Promise { + this.compileCount += 1; + return super.createReactAgent( + agent as never, + context as never, + overrides as never, + ); + } + } + + const fakeModel = new FakeToolCallingChatModel({ + responses: [toolCallMessage("AgentOutputModel", { car: "Ferrari" })], + }); + const converter = new CountingFakeConverter(fakeModel); + const graph = (await converter.convert(buildAgentFlow(), {})) as CompiledFlow; + + // Compilation is lazy: nothing is compiled at load time. + expect(converter.compileCount).toBe(0); + + let result = await graph.invoke({ inputs: { nationality: "italian" } }); + expect(outputsOf(result)["car"]).toBe("Ferrari"); + expect(converter.compileCount).toBe(1); + + // Same rendered prompt: the cached agent is reused. + result = await graph.invoke({ inputs: { nationality: "italian" } }); + expect(converter.compileCount).toBe(1); + + // A different rendered prompt compiles a new agent. + result = await graph.invoke({ inputs: { nationality: "french" } }); + expect(outputsOf(result)["car"]).toBe("Ferrari"); + expect(converter.compileCount).toBe(2); + }); +}); diff --git a/tsagentspec/tests/adapters/langgraph/flow-nodes/api-node.test.ts b/tsagentspec/tests/adapters/langgraph/flow-nodes/api-node.test.ts new file mode 100644 index 00000000..cf160dc6 --- /dev/null +++ b/tsagentspec/tests/adapters/langgraph/flow-nodes/api-node.test.ts @@ -0,0 +1,344 @@ +/** + * ApiNode flow execution tests for the LangGraph adapter. + * + * Mirrors `pyagentspec/tests/adapters/langgraph/flows/test_apinode.py` with a + * mocked fetch so every test runs offline. + * + * Documented divergence exercised here: the TS SDK ApiNode has no + * `urlAllowList` field yet, so the Python allow-list rejection test has no TS + * equivalent (the adapter always calls the validation helper with + * `undefined`). + */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createApiNode, + createFlow, + stringProperty, + type ComponentWithIO, + type Flow, + type Property, +} from "../../../../src/index.js"; +import { DEFAULT_HTTP_REQUEST_TIMEOUT_MS } from "../../../../src/adapters/common/tools-common.js"; +import { + ctrl, + dataEdge, + installMockFetch, + ioEndNode, + ioStartNode, + loadFlow, + outputsOf, + type MockFetchController, +} from "../test-helpers.js"; + +describe("ApiNode", () => { + let mockFetch: MockFetchController | undefined; + + afterEach(() => { + mockFetch?.restore(); + mockFetch = undefined; + vi.restoreAllMocks(); + }); + + function buildApiFlow( + apiNode: Record, + inputProps: Property[], + outputProps: Property[], + ): Flow { + const start = ioStartNode("start", inputProps); + const end = ioEndNode("end", outputProps); + return createFlow({ + name: "api_flow", + startNode: start, + nodes: [start, apiNode, end], + controlFlowConnections: [ctrl(start, apiNode), ctrl(apiNode, end)], + dataFlowConnections: [ + ...inputProps.map((prop) => + dataEdge(start, apiNode as unknown as ComponentWithIO, prop.title), + ), + ...outputProps.map((prop) => + dataEdge(apiNode as unknown as ComponentWithIO, end, prop.title), + ), + ], + inputs: inputProps, + outputs: outputProps, + }); + } + + it("GET: templates the URL, query params and headers, and maps the JSON response", async () => { + // Templated URL destination without an allow list warns per Python rules. + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const inputProps = [ + stringProperty({ title: "host" }), + stringProperty({ title: "order_id" }), + stringProperty({ title: "flag" }), + stringProperty({ title: "token" }), + ]; + const status = stringProperty({ title: "status" }); + const apiNode = createApiNode({ + name: "api", + url: "https://{{host}}/orders/{{order_id}}", + httpMethod: "GET", + queryParams: { verbose: "{{flag}}" }, + headers: { "X-Auth": "Bearer {{token}}" }, + inputs: inputProps, + outputs: [status], + }); + const flow = buildApiFlow(apiNode, inputProps, [status]); + const graph = await loadFlow(flow); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("ApiNode `api` uses placeholders in the URL destination"), + ); + + mockFetch = installMockFetch(() => ({ status: "ok" })); + const result = await graph.invoke({ + inputs: { + host: "allowed.example.com", + order_id: "123", + flag: "yes", + token: "tok-1", + }, + }); + + expect(outputsOf(result)).toEqual({ status: "ok" }); + expect(mockFetch.calls).toHaveLength(1); + expect(mockFetch.calls[0]!.url).toBe( + "https://allowed.example.com/orders/123?verbose=yes", + ); + const init = mockFetch.calls[0]!.init!; + expect(init.method).toBe("GET"); + expect((init.headers as Record)["X-Auth"]).toBe( + "Bearer tok-1", + ); + expect(init.body).toBeUndefined(); + }); + + it("GET: warns when declared request data is dropped (fetch forbids GET bodies)", async () => { + // Python's httpx sends the body on GET; fetch cannot, so the adapter + // must at least warn instead of silently discarding the declared data. + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const inputProps = [stringProperty({ title: "term" })]; + const echo = stringProperty({ title: "echo" }); + const apiNode = createApiNode({ + name: "api", + url: "https://api.example.com/search", + httpMethod: "GET", + data: { q: "{{term}}" }, + inputs: inputProps, + outputs: [echo], + }); + const flow = buildApiFlow(apiNode, inputProps, [echo]); + const graph = await loadFlow(flow); + + mockFetch = installMockFetch(() => ({ echo: "done" })); + await graph.invoke({ inputs: { term: "boots" } }); + + expect(mockFetch.calls[0]!.init!.body).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + "ApiNode `api` declares request data for HTTP method GET", + ), + ); + }); + + it("GET: does not warn about a dropped body for the default empty data", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const echo = stringProperty({ title: "echo" }); + const apiNode = createApiNode({ + name: "api", + url: "https://api.example.com/plain", + httpMethod: "GET", + outputs: [echo], + }); + const flow = buildApiFlow(apiNode, [], [echo]); + const graph = await loadFlow(flow); + + mockFetch = installMockFetch(() => ({ echo: "done" })); + await graph.invoke({ inputs: {} }); + + expect(warnSpy).not.toHaveBeenCalledWith( + expect.stringContaining("declares request data"), + ); + }); + + it("POST: templated dict data is sent as a JSON body with a JSON content type", async () => { + const inputProps = [ + stringProperty({ title: "order_id" }), + stringProperty({ title: "tag" }), + ]; + const echo = stringProperty({ title: "echo" }); + const apiNode = createApiNode({ + name: "api", + url: "https://api.example.com/orders", + httpMethod: "POST", + data: { order: { id: "{{order_id}}" }, tags: ["{{tag}}", "static"] }, + inputs: inputProps, + outputs: [echo], + }); + const flow = buildApiFlow(apiNode, inputProps, [echo]); + const graph = await loadFlow(flow); + + mockFetch = installMockFetch(() => ({ echo: "done" })); + const result = await graph.invoke({ + inputs: { order_id: "777", tag: "blue" }, + }); + + expect(outputsOf(result)).toEqual({ echo: "done" }); + const init = mockFetch.calls[0]!.init!; + expect(init.method).toBe("POST"); + expect( + (init.headers as Record)["Content-Type"], + ).toBe("application/json"); + expect(JSON.parse(String(init.body))).toEqual({ + order: { id: "777" }, + tags: ["blue", "static"], + }); + }); + + it("POST: an urlencoded content type sends dict data as a form body and templates header keys", async () => { + const inputProps = [ + stringProperty({ title: "a" }), + stringProperty({ title: "key_name" }), + stringProperty({ title: "key_val" }), + ]; + const echo = stringProperty({ title: "echo" }); + const apiNode = createApiNode({ + name: "api", + url: "https://api.example.com/form", + httpMethod: "POST", + data: { a: "{{a}}", b: "static" }, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-{{key_name}}": "{{key_val}}", + }, + inputs: inputProps, + outputs: [echo], + }); + const flow = buildApiFlow(apiNode, inputProps, [echo]); + const graph = await loadFlow(flow); + + mockFetch = installMockFetch(() => ({ echo: "done" })); + const result = await graph.invoke({ + inputs: { a: "1", key_name: "Trace", key_val: "on" }, + }); + + expect(outputsOf(result)).toEqual({ echo: "done" }); + const init = mockFetch.calls[0]!.init!; + const headers = init.headers as Record; + expect(headers["Content-Type"]).toBe("application/x-www-form-urlencoded"); + expect(headers["X-Trace"]).toBe("on"); + expect(init.body).toBeInstanceOf(URLSearchParams); + expect(String(init.body)).toBe("a=1&b=static"); + }); + + it("POST: an empty-string Content-Type falls through to the lowercase header (Python `or` parity)", async () => { + // Python looks the content type up with `get("Content-Type") or + // get("content-type")`: an empty-string uppercase header is falsy, so + // the lowercase urlencoded header wins and dict data goes out as a form + // body (a `??` lookup would stop at the empty string and send JSON). + const echo = stringProperty({ title: "echo" }); + const apiNode = createApiNode({ + name: "api", + url: "https://api.example.com/form", + httpMethod: "POST", + data: { a: "1" }, + headers: { + "Content-Type": "", + "content-type": "application/x-www-form-urlencoded", + }, + outputs: [echo], + }); + const flow = buildApiFlow(apiNode, [], [echo]); + const graph = await loadFlow(flow); + + mockFetch = installMockFetch(() => ({ echo: "done" })); + const result = await graph.invoke({ inputs: {} }); + + expect(outputsOf(result)).toEqual({ echo: "done" }); + const init = mockFetch.calls[0]!.init!; + expect(init.body).toBeInstanceOf(URLSearchParams); + expect(String(init.body)).toBe("a=1"); + }); + + it("does not follow redirects: a 3xx response body maps to the node outputs like any status", async () => { + // Python's httpx does not follow redirects (follow_redirects defaults to + // False) and parses the returned 3xx body like any other status; the + // adapter uses redirect: "manual" so undici returns the 3xx response + // itself instead of requesting the Location target. + const echo = stringProperty({ title: "echo" }); + const apiNode = createApiNode({ + name: "api", + url: "https://api.example.com/redirecting", + httpMethod: "GET", + outputs: [echo], + }); + const flow = buildApiFlow(apiNode, [], [echo]); + const graph = await loadFlow(flow); + + mockFetch = installMockFetch( + () => + new Response('{"echo": "from-redirect-response"}', { + status: 302, + headers: { + "Content-Type": "application/json", + Location: "https://attacker.example/exfil", + }, + }), + ); + const result = await graph.invoke({ inputs: {} }); + + expect(outputsOf(result)).toEqual({ echo: "from-redirect-response" }); + expect(mockFetch.calls).toHaveLength(1); + expect(mockFetch.calls[0]!.init!.redirect).toBe("manual"); + }); + + it("attaches the default httpx-parity timeout and names the node on a timeout abort", async () => { + const echo = stringProperty({ title: "echo" }); + const apiNode = createApiNode({ + name: "api", + url: "https://api.example.com/slow", + httpMethod: "GET", + outputs: [echo], + }); + const flow = buildApiFlow(apiNode, [], [echo]); + const graph = await loadFlow(flow); + + mockFetch = installMockFetch(() => { + throw new DOMException( + "The operation was aborted due to timeout", + "TimeoutError", + ); + }); + + await expect(graph.invoke({ inputs: {} })).rejects.toThrow( + `ApiNode \`api\` HTTP request timed out after ${DEFAULT_HTTP_REQUEST_TIMEOUT_MS}ms.`, + ); + expect(mockFetch.calls).toHaveLength(1); + expect(mockFetch.calls[0]!.init!.signal).toBeInstanceOf(AbortSignal); + }); + + it("POST: string data is sent as a raw body without forcing a content type", async () => { + const inputProps = [stringProperty({ title: "val" })]; + const echo = stringProperty({ title: "echo" }); + const apiNode = createApiNode({ + name: "api", + url: "https://api.example.com/raw", + httpMethod: "POST", + data: "payload={{val}}", + inputs: inputProps, + outputs: [echo], + }); + const flow = buildApiFlow(apiNode, inputProps, [echo]); + const graph = await loadFlow(flow); + + mockFetch = installMockFetch(() => ({ echo: "done" })); + const result = await graph.invoke({ inputs: { val: "hello" } }); + + expect(outputsOf(result)).toEqual({ echo: "done" }); + const init = mockFetch.calls[0]!.init!; + expect(init.body).toBe("payload=hello"); + const headerKeys = Object.keys(init.headers as Record); + expect( + headerKeys.some((key) => key.toLowerCase() === "content-type"), + ).toBe(false); + }); +}); diff --git a/tsagentspec/tests/adapters/langgraph/flow-nodes/branching-node.test.ts b/tsagentspec/tests/adapters/langgraph/flow-nodes/branching-node.test.ts new file mode 100644 index 00000000..cab6a100 --- /dev/null +++ b/tsagentspec/tests/adapters/langgraph/flow-nodes/branching-node.test.ts @@ -0,0 +1,98 @@ +/** + * BranchingNode flow execution tests for the LangGraph adapter. + * + * Mirrors `pyagentspec/tests/adapters/langgraph/flows/test_branchingnode.py`; + * all tests run offline. + */ +import { describe, expect, it } from "vitest"; +import { + createBranchingNode, + createFlow, + stringProperty, +} from "../../../../src/index.js"; +import { + ctrl, + dataEdge, + ioEndNode, + ioStartNode, + loadFlow, + outputsOf, +} from "../test-helpers.js"; + +describe("BranchingNode", () => { + it("routes on the mapping, falls back to the default branch, and keeps defaults on untaken paths", async () => { + const customInput = stringProperty({ title: "custom_input" }); + const outputA = stringProperty({ title: "output_a", default: "no_value" }); + const outputB = stringProperty({ title: "output_b", default: "no_value" }); + const branchingNode = createBranchingNode({ + name: "branching", + mapping: { a: "branch_a", b: "branch_b" }, + inputs: [customInput], + }); + const start = ioStartNode("start", [customInput]); + const endA = ioEndNode("end_a", [outputA]); + const endB = ioEndNode("end_b", [outputB]); + const endDefault = ioEndNode("end_default"); + + const flow = createFlow({ + name: "flow", + startNode: start, + nodes: [start, branchingNode, endA, endB, endDefault], + controlFlowConnections: [ + ctrl(start, branchingNode), + ctrl(branchingNode, endA, "branch_a"), + ctrl(branchingNode, endB, "branch_b"), + ctrl(branchingNode, endDefault, "default"), + ], + dataFlowConnections: [ + dataEdge(start, branchingNode, "custom_input"), + dataEdge(start, endB, "custom_input", "output_b"), + dataEdge(start, endA, "custom_input", "output_a"), + ], + outputs: [outputA, outputB], + }); + + const graph = await loadFlow(flow); + + let result = await graph.invoke({ inputs: { custom_input: "a" } }); + expect(outputsOf(result)).toEqual({ output_a: "a", output_b: "no_value" }); + expect(result).toHaveProperty("messages"); + + result = await graph.invoke({ inputs: { custom_input: "b" } }); + expect(outputsOf(result)).toEqual({ output_a: "no_value", output_b: "b" }); + + result = await graph.invoke({ inputs: { custom_input: "no_match" } }); + expect(outputsOf(result)).toEqual({ + output_a: "no_value", + output_b: "no_value", + }); + }); + + it("raises the missing-input error when nothing feeds the branching input", async () => { + const customInput = stringProperty({ title: "custom_input" }); + const branchingNode = createBranchingNode({ + name: "branching", + mapping: { a: "branch_a" }, + inputs: [customInput], + }); + const start = ioStartNode("start"); + const endA = ioEndNode("end_a"); + const endDefault = ioEndNode("end_default"); + const flow = createFlow({ + name: "flow", + startNode: start, + nodes: [start, branchingNode, endA, endDefault], + controlFlowConnections: [ + ctrl(start, branchingNode), + ctrl(branchingNode, endA, "branch_a"), + ctrl(branchingNode, endDefault, "default"), + ], + dataFlowConnections: [], + }); + + const graph = await loadFlow(flow); + await expect(graph.invoke({ inputs: {} })).rejects.toThrow( + "Expected node `branching` to have a value for property `custom_input`, but none was found.", + ); + }); +}); diff --git a/tsagentspec/tests/adapters/langgraph/flow-nodes/catch-exception-node.test.ts b/tsagentspec/tests/adapters/langgraph/flow-nodes/catch-exception-node.test.ts new file mode 100644 index 00000000..e99a5452 --- /dev/null +++ b/tsagentspec/tests/adapters/langgraph/flow-nodes/catch-exception-node.test.ts @@ -0,0 +1,196 @@ +/** + * CatchExceptionNode flow execution tests for the LangGraph adapter. + * + * Mirrors `pyagentspec/tests/adapters/langgraph/flows/test_catchexceptionode.py`; + * all tests run offline. + */ +import { describe, expect, it } from "vitest"; +import { + createCatchExceptionNode, + createFlow, + createServerTool, + createToolNode, + integerProperty, + nullProperty, + stringProperty, + unionProperty, + type Flow, + type Property, + type ServerTool, +} from "../../../../src/index.js"; +import { + ctrl, + dataEdge, + detailsOf, + ioEndNode, + ioStartNode, + loadFlow, + outputsOf, +} from "../test-helpers.js"; + +describe("CatchExceptionNode", () => { + const inp = integerProperty({ title: "x" }); + const outp = stringProperty({ title: "y", default: "" }); + + function makeErrorInfoProperty(): Property { + return unionProperty({ + title: "error_info", + anyOf: [ + stringProperty({ title: "error_info" }), + nullProperty({ title: "error_info" }), + ], + default: null, + }); + } + + function buildToolSubflow(tool: ServerTool, endBranch?: string): Flow { + const subStart = ioStartNode("sub_start", [inp]); + const toolNode = createToolNode({ name: `${tool.name}_node`, tool }); + const subEnd = ioEndNode("sub_end", [outp], endBranch); + return createFlow({ + name: `${tool.name}_subflow`, + startNode: subStart, + nodes: [subStart, toolNode, subEnd], + controlFlowConnections: [ctrl(subStart, toolNode), ctrl(toolNode, subEnd)], + dataFlowConnections: [ + dataEdge(subStart, toolNode, "x"), + dataEdge(toolNode, subEnd, "y"), + ], + inputs: [inp], + outputs: [outp], + }); + } + + it("routes exceptions to the caught_exception_branch with default outputs and caught_exception_info", async () => { + const flakyTool = createServerTool({ + name: "flaky_tool", + description: "Raises for negative inputs", + inputs: [inp], + outputs: [outp], + }); + const subflow = buildToolSubflow(flakyTool); + const catchNode = createCatchExceptionNode({ name: "catch", subflow }); + const errorInfo = makeErrorInfoProperty(); + const start = ioStartNode("start", [inp]); + const end = ioEndNode("end", [outp]); + const errorEnd = ioEndNode("error_end", [errorInfo], "ERROR"); + const flow = createFlow({ + name: "outer", + startNode: start, + nodes: [start, catchNode, end, errorEnd], + controlFlowConnections: [ + ctrl(start, catchNode), + ctrl(catchNode, end), + ctrl(catchNode, errorEnd, "caught_exception_branch"), + ], + dataFlowConnections: [ + dataEdge(start, catchNode, "x"), + dataEdge(catchNode, end, "y"), + dataEdge(catchNode, errorEnd, "caught_exception_info", "error_info"), + ], + inputs: [inp], + outputs: [outp, errorInfo], + }); + + const graph = await loadFlow(flow, { + toolRegistry: { + flaky_tool: (input: unknown) => { + const { x } = input as { x: number }; + if (x < 0) { + throw new Error("x must be non-negative"); + } + return "ok"; + }, + }, + }); + + // Case 1: no exception -> subflow output passes through. + let result = await graph.invoke({ inputs: { x: 1 } }); + expect(outputsOf(result)["y"]).toBe("ok"); + expect(outputsOf(result)["error_info"]).toBeNull(); + + // Case 2: exception -> default output value, ERROR end branch, and the + // exception message routed through caught_exception_info. + result = await graph.invoke({ inputs: { x: -1 } }); + expect(outputsOf(result)["y"]).toBe(""); + expect(detailsOf(result)["branch"]).toBe("ERROR"); + const caught = outputsOf(result)["error_info"]; + expect(typeof caught).toBe("string"); + expect(String(caught)).toContain("x must be non-negative"); + }); + + it("propagates a custom subflow end branch on success with null exception info", async () => { + const okTool = createServerTool({ + name: "ok_tool", + description: "Always returns ok", + inputs: [inp], + outputs: [outp], + }); + const subflow = buildToolSubflow(okTool, "OK"); + const catchNode = createCatchExceptionNode({ name: "catch", subflow }); + const errorInfo = makeErrorInfoProperty(); + const start = ioStartNode("start", [inp]); + const okEnd = ioEndNode("ok_end", [outp, errorInfo]); + const otherEnd = ioEndNode("other_end"); + const flow = createFlow({ + name: "outer", + startNode: start, + nodes: [start, catchNode, okEnd, otherEnd], + controlFlowConnections: [ + ctrl(start, catchNode), + ctrl(catchNode, okEnd, "OK"), + ctrl(catchNode, otherEnd), + ], + dataFlowConnections: [ + dataEdge(start, catchNode, "x"), + dataEdge(catchNode, okEnd, "y"), + dataEdge(catchNode, okEnd, "caught_exception_info", "error_info"), + ], + inputs: [inp], + outputs: [outp, errorInfo], + }); + + const graph = await loadFlow(flow, { + toolRegistry: { ok_tool: () => "ok" }, + }); + const result = await graph.invoke({ inputs: { x: 7 } }); + expect(detailsOf(result)["branch"]).toBe("next"); + expect(outputsOf(result)["y"]).toBe("ok"); + expect(outputsOf(result)["error_info"]).toBeNull(); + }); + + it("uses the default next branch on success with null exception info", async () => { + const okTool = createServerTool({ + name: "ok_tool_default", + description: "Always returns ok", + inputs: [inp], + outputs: [outp], + }); + const subflow = buildToolSubflow(okTool); + const catchNode = createCatchExceptionNode({ name: "catch", subflow }); + const errorInfo = makeErrorInfoProperty(); + const start = ioStartNode("start", [inp]); + const nextEnd = ioEndNode("next_end", [outp, errorInfo]); + const flow = createFlow({ + name: "outer", + startNode: start, + nodes: [start, catchNode, nextEnd], + controlFlowConnections: [ctrl(start, catchNode), ctrl(catchNode, nextEnd)], + dataFlowConnections: [ + dataEdge(start, catchNode, "x"), + dataEdge(catchNode, nextEnd, "y"), + dataEdge(catchNode, nextEnd, "caught_exception_info", "error_info"), + ], + inputs: [inp], + outputs: [outp, errorInfo], + }); + + const graph = await loadFlow(flow, { + toolRegistry: { ok_tool_default: () => "ok" }, + }); + const result = await graph.invoke({ inputs: { x: 5 } }); + expect(detailsOf(result)["branch"]).toBe("next"); + expect(outputsOf(result)["y"]).toBe("ok"); + expect(outputsOf(result)["error_info"]).toBeNull(); + }); +}); diff --git a/tsagentspec/tests/adapters/langgraph/flow-nodes/flow-node.test.ts b/tsagentspec/tests/adapters/langgraph/flow-nodes/flow-node.test.ts new file mode 100644 index 00000000..34b7d09e --- /dev/null +++ b/tsagentspec/tests/adapters/langgraph/flow-nodes/flow-node.test.ts @@ -0,0 +1,54 @@ +/** + * FlowNode (subflow) execution tests for the LangGraph adapter. + * + * Mirrors `pyagentspec/tests/adapters/langgraph/flows/test_flownode.py`; all + * tests run offline. + */ +import { describe, expect, it } from "vitest"; +import { createFlow, createFlowNode, stringProperty } from "../../../../src/index.js"; +import { + ctrl, + dataEdge, + ioEndNode, + ioStartNode, + loadFlow, + outputsOf, +} from "../test-helpers.js"; + +describe("FlowNode", () => { + it("executes the subflow and passes its outputs through", async () => { + const customProp = stringProperty({ title: "custom_prop" }); + const subStart = ioStartNode("start", [customProp]); + const subEnd = ioEndNode("end", [customProp]); + const subflow = createFlow({ + name: "subflow", + startNode: subStart, + nodes: [subStart, subEnd], + controlFlowConnections: [ctrl(subStart, subEnd)], + dataFlowConnections: [dataEdge(subStart, subEnd, "custom_prop")], + inputs: [customProp], + outputs: [customProp], + }); + + const flowNode = createFlowNode({ name: "flow_node", subflow }); + const start = ioStartNode("start", [customProp]); + const end = ioEndNode("end", [customProp]); + const flow = createFlow({ + name: "outer", + startNode: start, + nodes: [start, flowNode, end], + controlFlowConnections: [ctrl(start, flowNode), ctrl(flowNode, end)], + dataFlowConnections: [ + dataEdge(start, flowNode, "custom_prop"), + dataEdge(flowNode, end, "custom_prop"), + ], + inputs: [customProp], + outputs: [customProp], + }); + + const graph = await loadFlow(flow); + const result = await graph.invoke({ inputs: { custom_prop: "custom" } }); + expect(result).toHaveProperty("messages"); + expect(outputsOf(result)).toEqual({ custom_prop: "custom" }); + }); +}); diff --git a/tsagentspec/tests/adapters/langgraph/flow-nodes/llm-node.test.ts b/tsagentspec/tests/adapters/langgraph/flow-nodes/llm-node.test.ts new file mode 100644 index 00000000..d3f9aa11 --- /dev/null +++ b/tsagentspec/tests/adapters/langgraph/flow-nodes/llm-node.test.ts @@ -0,0 +1,145 @@ +/** + * LlmNode flow execution tests for the LangGraph adapter. + * + * Mirrors `pyagentspec/tests/adapters/langgraph/flows/test_llmnode.py` with a + * duck-typed chat-model fake injected at the converter seam; all tests run + * offline. + */ +import { describe, expect, it } from "vitest"; +import { AIMessage } from "@langchain/core/messages"; +import { + createFlow, + createLlmNode, + integerProperty, + objectProperty, + stringProperty, + type Flow, + type Property, +} from "../../../../src/index.js"; +import { + ctrl, + dataEdge, + ioEndNode, + ioStartNode, + loadWithFakeLlm, + makeLlmConfig, + outputsOf, +} from "../test-helpers.js"; + +/** Duck-typed chat-model fake for LlmNode tests. */ +function makeChatModelFake(opts: { + reply?: string; + structured?: Record; +}) { + const captured = { + prompts: [] as unknown[], + structuredSchemas: [] as Record[], + }; + const model = { + invoke: async (input: unknown) => { + captured.prompts.push(input); + return new AIMessage(opts.reply ?? ""); + }, + withStructuredOutput: (schema: Record) => { + captured.structuredSchemas.push(schema); + return { + invoke: async (input: unknown) => { + captured.prompts.push(input); + if (opts.structured === undefined) { + throw new Error("No structured response configured."); + } + return opts.structured; + }, + }; + }, + }; + return { model, captured }; +} + +describe("LlmNode", () => { + const nationality = stringProperty({ title: "nationality" }); + const car = stringProperty({ title: "car" }); + + function buildLlmFlow(outputs: Property[]): Flow { + const llmNode = createLlmNode({ + name: "llm_node", + llmConfig: makeLlmConfig(), + promptTemplate: + "Answer in one short sentence. What is the fastest {{nationality}} car?", + inputs: [nationality], + outputs, + }); + const start = ioStartNode("start", [nationality]); + const end = ioEndNode("end", outputs); + return createFlow({ + name: "flow", + startNode: start, + nodes: [start, llmNode, end], + controlFlowConnections: [ctrl(start, llmNode), ctrl(llmNode, end)], + dataFlowConnections: [ + dataEdge(start, llmNode, "nationality"), + ...outputs.map((prop) => dataEdge(llmNode, end, prop.title)), + ], + outputs, + }); + } + + it("unstructured: a single string output takes the message content of the rendered prompt call", async () => { + const { model, captured } = makeChatModelFake({ reply: "The Ferrari." }); + const { agent } = await loadWithFakeLlm(buildLlmFlow([car]), () => model); + + const result = await agent.invoke({ inputs: { nationality: "italian" } }); + expect(outputsOf(result)).toEqual({ car: "The Ferrari." }); + + // The prompt template was rendered against the node inputs. + expect(captured.structuredSchemas).toHaveLength(0); + expect(captured.prompts).toHaveLength(1); + const promptMessages = captured.prompts[0] as Array<{ + role: string; + content: string; + }>; + expect(promptMessages).toEqual([ + { + role: "user", + content: + "Answer in one short sentence. What is the fastest italian car?", + }, + ]); + }); + + it("structured: multiple outputs use withStructuredOutput with the built JSON schema", async () => { + const rating = integerProperty({ title: "rating" }); + const { model, captured } = makeChatModelFake({ + structured: { car: "Ferrari", rating: 9 }, + }); + const { agent } = await loadWithFakeLlm( + buildLlmFlow([car, rating]), + () => model, + ); + + const result = await agent.invoke({ inputs: { nationality: "italian" } }); + expect(outputsOf(result)).toEqual({ car: "Ferrari", rating: 9 }); + + expect(captured.structuredSchemas).toHaveLength(1); + expect(captured.structuredSchemas[0]).toEqual({ + title: "structured_output", + type: "object", + properties: { + car: car.jsonSchema, + rating: rating.jsonSchema, + }, + }); + }); + + it("structured: a flattened single-property result is rewrapped under the declared title", async () => { + const wrapped = objectProperty({ title: "wrapped", properties: {} }); + const { model } = makeChatModelFake({ structured: { inner: 1 } }); + const { agent } = await loadWithFakeLlm( + buildLlmFlow([wrapped]), + () => model, + ); + + const result = await agent.invoke({ inputs: { nationality: "italian" } }); + expect(outputsOf(result)).toEqual({ wrapped: { inner: 1 } }); + }); +}); diff --git a/tsagentspec/tests/adapters/langgraph/flow-nodes/map-node.test.ts b/tsagentspec/tests/adapters/langgraph/flow-nodes/map-node.test.ts new file mode 100644 index 00000000..d0916a7f --- /dev/null +++ b/tsagentspec/tests/adapters/langgraph/flow-nodes/map-node.test.ts @@ -0,0 +1,250 @@ +/** + * MapNode flow execution tests for the LangGraph adapter. + * + * Mirrors `pyagentspec/tests/adapters/langgraph/flows/test_mapnode.py`; all + * tests run offline. + */ +import { describe, expect, it } from "vitest"; +import { + createFlow, + createMapNode, + createServerTool, + createToolNode, + listProperty, + numberProperty, + unionProperty, + type Flow, +} from "../../../../src/index.js"; +import { + ctrl, + dataEdge, + ioEndNode, + ioStartNode, + loadFlow, + outputsOf, +} from "../test-helpers.js"; + +describe("MapNode", () => { + function buildSquareSubflow(): Flow { + const xProp = numberProperty({ title: "input" }); + const xSquareProp = numberProperty({ title: "input_square" }); + const squareTool = createServerTool({ + name: "square_tool", + description: "Computes the square of a number", + inputs: [xProp], + outputs: [xSquareProp], + }); + const subStart = ioStartNode("subflow_start", [xProp]); + const toolNode = createToolNode({ name: "square_tool_node", tool: squareTool }); + const subEnd = ioEndNode("subflow_end", [xSquareProp]); + return createFlow({ + name: "Square number flow", + startNode: subStart, + nodes: [subStart, toolNode, subEnd], + controlFlowConnections: [ctrl(subStart, toolNode), ctrl(toolNode, subEnd)], + dataFlowConnections: [ + dataEdge(subStart, toolNode, "input"), + dataEdge(toolNode, subEnd, "input_square"), + ], + }); + } + + const iteratedInput = unionProperty({ + title: "iterated_input", + anyOf: [ + numberProperty({ title: "input" }), + listProperty({ title: "input", itemType: numberProperty({ title: "item" }) }), + ], + }); + const collectedSquare = listProperty({ + title: "collected_input_square", + itemType: numberProperty({ title: "item" }), + }); + const squareRegistry = { + square_tool: (input: unknown) => { + const { input: value } = input as { input: number }; + return value * value; + }, + }; + + it("iterates the subflow over the list input and collects the outputs", async () => { + const mapNode = createMapNode({ + name: "square_number_map_node", + subflow: buildSquareSubflow(), + inputs: [iteratedInput], + outputs: [collectedSquare], + }); + const inputList = listProperty({ + title: "input_list", + itemType: numberProperty({ title: "item" }), + }); + const start = ioStartNode("outer_start", [inputList]); + const end = ioEndNode("outer_end", [collectedSquare]); + const flow = createFlow({ + name: "flow to square all elements of a list", + startNode: start, + nodes: [start, mapNode, end], + controlFlowConnections: [ctrl(start, mapNode), ctrl(mapNode, end)], + dataFlowConnections: [ + dataEdge(start, mapNode, "input_list", "iterated_input"), + dataEdge(mapNode, end, "collected_input_square"), + ], + }); + + const graph = await loadFlow(flow, { toolRegistry: squareRegistry }); + const result = await graph.invoke({ inputs: { input_list: [1, 2, 3, 4] } }); + expect(outputsOf(result)).toEqual({ + collected_input_square: [1, 4, 9, 16], + }); + }); + + it("raises when iterated inputs have different lengths", async () => { + const aProp = numberProperty({ title: "a" }); + const bProp = numberProperty({ title: "b" }); + const totalProp = numberProperty({ title: "total" }); + const sumTool = createServerTool({ + name: "sum_tool", + description: "Adds two numbers", + inputs: [aProp, bProp], + outputs: [totalProp], + }); + const subStart = ioStartNode("sum_start", [aProp, bProp]); + const toolNode = createToolNode({ name: "sum_tool_node", tool: sumTool }); + const subEnd = ioEndNode("sum_end", [totalProp]); + const sumSubflow = createFlow({ + name: "sum_subflow", + startNode: subStart, + nodes: [subStart, toolNode, subEnd], + controlFlowConnections: [ctrl(subStart, toolNode), ctrl(toolNode, subEnd)], + dataFlowConnections: [ + dataEdge(subStart, toolNode, "a"), + dataEdge(subStart, toolNode, "b"), + dataEdge(toolNode, subEnd, "total"), + ], + inputs: [aProp, bProp], + outputs: [totalProp], + }); + + const iteratedA = unionProperty({ + title: "iterated_a", + anyOf: [ + numberProperty({ title: "a" }), + listProperty({ title: "a", itemType: numberProperty({ title: "item" }) }), + ], + }); + const iteratedB = unionProperty({ + title: "iterated_b", + anyOf: [ + numberProperty({ title: "b" }), + listProperty({ title: "b", itemType: numberProperty({ title: "item" }) }), + ], + }); + const collectedTotal = listProperty({ + title: "collected_total", + itemType: numberProperty({ title: "item" }), + }); + const mapNode = createMapNode({ + name: "sum_map_node", + subflow: sumSubflow, + inputs: [iteratedA, iteratedB], + outputs: [collectedTotal], + }); + + const listA = listProperty({ + title: "list_a", + itemType: numberProperty({ title: "item" }), + }); + const listB = listProperty({ + title: "list_b", + itemType: numberProperty({ title: "item" }), + }); + const start = ioStartNode("outer_start", [listA, listB]); + const end = ioEndNode("outer_end", [collectedTotal]); + const flow = createFlow({ + name: "sum_map_flow", + startNode: start, + nodes: [start, mapNode, end], + controlFlowConnections: [ctrl(start, mapNode), ctrl(mapNode, end)], + dataFlowConnections: [ + dataEdge(start, mapNode, "list_a", "iterated_a"), + dataEdge(start, mapNode, "list_b", "iterated_b"), + dataEdge(mapNode, end, "collected_total"), + ], + }); + + const graph = await loadFlow(flow, { + toolRegistry: { + sum_tool: (input: unknown) => { + const { a, b } = input as { a: number; b: number }; + return a + b; + }, + }, + }); + await expect( + graph.invoke({ inputs: { list_a: [1, 2], list_b: [10, 20, 30] } }), + ).rejects.toThrow("Found inputs to iterate with different sizes"); + }); + + it("raises naming the input when an iterated input has no length at runtime", async () => { + // The converter selects iterated_input statically (list-typed schema), + // but the runtime value is a scalar: the error names the node and the + // offending input instead of reusing the size-mismatch text. + const mapNode = createMapNode({ + name: "square_number_map_node", + subflow: buildSquareSubflow(), + inputs: [iteratedInput], + outputs: [collectedSquare], + }); + const inputList = listProperty({ + title: "input_list", + itemType: numberProperty({ title: "item" }), + }); + const start = ioStartNode("outer_start", [inputList]); + const end = ioEndNode("outer_end", [collectedSquare]); + const flow = createFlow({ + name: "flow to square all elements of a list", + startNode: start, + nodes: [start, mapNode, end], + controlFlowConnections: [ctrl(start, mapNode), ctrl(mapNode, end)], + dataFlowConnections: [ + dataEdge(start, mapNode, "input_list", "iterated_input"), + dataEdge(mapNode, end, "collected_input_square"), + ], + }); + + const graph = await loadFlow(flow, { toolRegistry: squareRegistry }); + await expect(graph.invoke({ inputs: { input_list: 7 } })).rejects.toThrow( + "MapNode `square_number_map_node` cannot iterate over input " + + "`iterated_input`: 7 has no length", + ); + }); + + it("raises when no data-flow edge selects an input to iterate", async () => { + const mapNode = createMapNode({ + name: "square_map_scalar", + subflow: buildSquareSubflow(), + inputs: [iteratedInput], + outputs: [collectedSquare], + }); + // The edge feeds a SCALAR into iterated_input, so the converter finds no + // list-typed source matching the subflow input and selects nothing. + const singleX = numberProperty({ title: "single_x" }); + const start = ioStartNode("outer_start", [singleX]); + const end = ioEndNode("outer_end", [collectedSquare]); + const flow = createFlow({ + name: "scalar_map_flow", + startNode: start, + nodes: [start, mapNode, end], + controlFlowConnections: [ctrl(start, mapNode), ctrl(mapNode, end)], + dataFlowConnections: [ + dataEdge(start, mapNode, "single_x", "iterated_input"), + dataEdge(mapNode, end, "collected_input_square"), + ], + }); + + const graph = await loadFlow(flow, { toolRegistry: squareRegistry }); + await expect(graph.invoke({ inputs: { single_x: 3 } })).rejects.toThrow( + "MapNode has no inputs to iterate", + ); + }); +}); diff --git a/tsagentspec/tests/adapters/langgraph/flow-nodes/message-node.test.ts b/tsagentspec/tests/adapters/langgraph/flow-nodes/message-node.test.ts new file mode 100644 index 00000000..25f54635 --- /dev/null +++ b/tsagentspec/tests/adapters/langgraph/flow-nodes/message-node.test.ts @@ -0,0 +1,98 @@ +/** + * Input/OutputMessageNode flow execution tests for the LangGraph adapter. + * + * Mirrors `pyagentspec/tests/adapters/langgraph/flows/test_inputmessagenode.py` + * and `test_outputmessagenode.py` (merged: each suite is a single small + * scenario); all tests run offline. + */ +import { describe, expect, it } from "vitest"; +import { Command, MemorySaver } from "@langchain/langgraph"; +import { + createFlow, + createInputMessageNode, + createOutputMessageNode, + stringProperty, +} from "../../../../src/index.js"; +import { + ctrl, + dataEdge, + getInterrupts, + ioEndNode, + ioStartNode, + loadFlow, + messagesOf, + outputsOf, + threadConfig, +} from "../test-helpers.js"; + +describe("InputMessageNode", () => { + it("interrupts with an empty payload; the resume value becomes the output and a user message", async () => { + const customInput = stringProperty({ title: "custom_input" }); + const inputMessageNode = createInputMessageNode({ + name: "input_message", + outputs: [customInput], + }); + const start = ioStartNode("start"); + const end = ioEndNode("end", [customInput]); + const flow = createFlow({ + name: "flow", + startNode: start, + nodes: [start, inputMessageNode, end], + controlFlowConnections: [ + ctrl(start, inputMessageNode), + ctrl(inputMessageNode, end), + ], + dataFlowConnections: [dataEdge(inputMessageNode, end, "custom_input")], + outputs: [customInput], + }); + + const graph = await loadFlow(flow, { checkpointer: new MemorySaver() }); + const config = threadConfig("1"); + + const first = await graph.invoke({}, config); + const interrupts = getInterrupts(first); + expect(interrupts).toHaveLength(1); + expect(interrupts[0]!.value).toBe(""); + + const result = await graph.invoke(new Command({ resume: "3" }), config); + expect(outputsOf(result)).toEqual({ custom_input: "3" }); + + const messages = messagesOf(result); + expect(messages).toHaveLength(1); + expect(messages[0]!.getType()).toBe("human"); + expect(messages[0]!.content).toBe("3"); + }); +}); + +describe("OutputMessageNode", () => { + it("emits the rendered template as an assistant message", async () => { + const customInput = stringProperty({ title: "custom_input" }); + const outputMessageNode = createOutputMessageNode({ + name: "output_message", + message: "Hey {{custom_input}}", + inputs: [customInput], + }); + const start = ioStartNode("start", [customInput]); + const end = ioEndNode("end"); + const flow = createFlow({ + name: "flow", + startNode: start, + nodes: [start, outputMessageNode, end], + controlFlowConnections: [ + ctrl(start, outputMessageNode), + ctrl(outputMessageNode, end), + ], + dataFlowConnections: [dataEdge(start, outputMessageNode, "custom_input")], + inputs: [customInput], + }); + + const graph = await loadFlow(flow); + const result = await graph.invoke({ inputs: { custom_input: "custom" } }); + + expect(result).toHaveProperty("outputs"); + const messages = messagesOf(result); + expect(messages).toHaveLength(1); + expect(messages[0]!.getType()).toBe("ai"); + expect(messages[0]!.content).toBe("Hey custom"); + }); +}); diff --git a/tsagentspec/tests/adapters/langgraph/flow-nodes/tool-node.test.ts b/tsagentspec/tests/adapters/langgraph/flow-nodes/tool-node.test.ts new file mode 100644 index 00000000..9dfb209e --- /dev/null +++ b/tsagentspec/tests/adapters/langgraph/flow-nodes/tool-node.test.ts @@ -0,0 +1,248 @@ +/** + * ToolNode flow execution tests for the LangGraph adapter. + * + * Mirrors `pyagentspec/tests/adapters/langgraph/flows/test_toolnode.py` with + * a checkpointer-driven interrupt/resume loop so every test runs offline. + * + * Documented divergence exercised here: tuples do not exist in JS, so arrays + * map positionally onto multiple declared tool-node outputs (Python restricts + * positional mapping to tuples). + * + * Note on node construction: the Python SDK infers the missing IO side of + * Start/End nodes, so Python specs always carry both sides on the wire; the + * TS factories default the missing side to `[]`, so these tests pass both + * sides explicitly (via `ioStartNode`/`ioEndNode`), matching the serialized + * wire format. + */ +import { describe, expect, it } from "vitest"; +import { Command, MemorySaver } from "@langchain/langgraph"; +import { + createClientTool, + createFlow, + createToolNode, + listProperty, + numberProperty, + objectProperty, + stringProperty, + type Flow, + type Property, +} from "../../../../src/index.js"; +import { + ctrl, + dataEdge, + getInterrupts, + ioEndNode, + ioStartNode, + loadFlow, + outputsOf, + threadConfig, +} from "../test-helpers.js"; + +describe("ToolNode output-mapping matrix", () => { + /** Python's `_build_flow_with_client_tool`: start -> ClientTool -> end. */ + function buildClientToolFlow( + inputProp: Property, + outputProps: Property[], + ): Flow { + const start = ioStartNode("start", [inputProp]); + const clientTool = createClientTool({ + name: "echo_tool", + description: "Client-side tool used for testing", + inputs: [inputProp], + outputs: outputProps, + }); + const toolNode = createToolNode({ name: "tool", tool: clientTool }); + const end = ioEndNode("end", outputProps); + return createFlow({ + name: "tool_output_flow", + startNode: start, + nodes: [start, toolNode, end], + controlFlowConnections: [ctrl(start, toolNode), ctrl(toolNode, end)], + dataFlowConnections: [ + dataEdge(start, toolNode, inputProp.title), + ...outputProps.map((prop) => dataEdge(toolNode, end, prop.title)), + ], + }); + } + + /** Interrupt at the client tool, then resume with the given payload. */ + async function runFlowAndResume( + flow: Flow, + resumePayload: unknown, + ): Promise> { + const graph = await loadFlow(flow, { checkpointer: new MemorySaver() }); + const config = threadConfig("t"); + const first = await graph.invoke( + { inputs: { [flow.inputs![0]!.title]: 123 } }, + config, + ); + expect(getInterrupts(first)).toHaveLength(1); + const resumed = await graph.invoke( + new Command({ resume: resumePayload }), + config, + ); + return outputsOf(resumed); + } + + it("interrupts with the client_tool_request payload and resumes with the value", async () => { + const inputProp = numberProperty({ title: "input" }); + const outputProp = numberProperty({ title: "input_square" }); + const squareTool = createClientTool({ + name: "square_tool", + description: "Computes the square of a number", + inputs: [inputProp], + outputs: [outputProp], + }); + const start = ioStartNode("subflow_start", [inputProp]); + const toolNode = createToolNode({ name: "square_tool_node", tool: squareTool }); + const end = ioEndNode("subflow_end", [outputProp]); + const flow = createFlow({ + name: "Square number flow", + startNode: start, + nodes: [start, toolNode, end], + controlFlowConnections: [ctrl(start, toolNode), ctrl(toolNode, end)], + dataFlowConnections: [ + dataEdge(start, toolNode, "input"), + dataEdge(toolNode, end, "input_square"), + ], + }); + + const graph = await loadFlow(flow, { checkpointer: new MemorySaver() }); + const config = threadConfig("1"); + const first = await graph.invoke({ inputs: { input: 4 } }, config); + const interrupts = getInterrupts(first); + expect(interrupts).toHaveLength(1); + expect(interrupts[0]!.value).toEqual({ + type: "client_tool_request", + name: "square_tool", + description: "Computes the square of a number", + inputs: { args: [], kwargs: { input: 4 } }, + }); + + const resumed = await graph.invoke(new Command({ resume: 16 }), config); + expect(outputsOf(resumed)["input_square"]).toBe(16); + }); + + it("single ObjectProperty output wraps a multi-key dict under the declared key", async () => { + const flow = buildClientToolFlow(numberProperty({ title: "x" }), [ + objectProperty({ title: "out_dict", properties: {} }), + ]); + const outputs = await runFlowAndResume(flow, { a: 1, b: 2 }); + expect(outputs).toEqual({ out_dict: { a: 1, b: 2 } }); + }); + + it("single ObjectProperty output wraps a single-key dict under the declared key", async () => { + const flow = buildClientToolFlow(numberProperty({ title: "x" }), [ + objectProperty({ title: "out_dict", properties: {} }), + ]); + const outputs = await runFlowAndResume(flow, { a: 1 }); + expect(outputs).toEqual({ out_dict: { a: 1 } }); + }); + + it("single output uses a dict keyed by the declared title as-is", async () => { + const flow = buildClientToolFlow(numberProperty({ title: "x" }), [ + objectProperty({ title: "out_dict", properties: {} }), + ]); + const outputs = await runFlowAndResume(flow, { out_dict: 1 }); + expect(outputs).toEqual({ out_dict: 1 }); + }); + + it("scalar output passes through under the declared key", async () => { + const flow = buildClientToolFlow(numberProperty({ title: "x" }), [ + stringProperty({ title: "out_string" }), + ]); + const outputs = await runFlowAndResume(flow, "value"); + expect(outputs).toEqual({ out_string: "value" }); + }); + + it("multiple outputs filter the dict and defaults fill missing keys", async () => { + const flow = buildClientToolFlow(numberProperty({ title: "x" }), [ + numberProperty({ title: "a" }), + numberProperty({ title: "b", default: 0 }), + ]); + const outputs = await runFlowAndResume(flow, { a: 5 }); + expect(outputs).toEqual({ a: 5, b: 0 }); + }); + + it("list output maps to a single declared list output", async () => { + const flow = buildClientToolFlow(numberProperty({ title: "x" }), [ + listProperty({ title: "out", itemType: numberProperty({ title: "item" }) }), + ]); + const outputs = await runFlowAndResume(flow, [1, 2, 3]); + expect(outputs).toEqual({ out: [1, 2, 3] }); + }); + + it("scalar output maps to a single declared number output", async () => { + const flow = buildClientToolFlow(numberProperty({ title: "x" }), [ + numberProperty({ title: "out_number" }), + ]); + const outputs = await runFlowAndResume(flow, 42); + expect(outputs).toEqual({ out_number: 42 }); + }); + + it("array output onto a single declared string output is stringified", async () => { + // Python (tuple payload) stringifies via json.dumps -> "[1, 2]"; the TS + // cast mirrors json.dumps formatting (", " separator, not "[1,2]"). + const flow = buildClientToolFlow(numberProperty({ title: "x" }), [ + stringProperty({ title: "out" }), + ]); + const outputs = await runFlowAndResume(flow, [1, 2]); + expect(outputs).toEqual({ out: "[1, 2]" }); + }); + + it("array output shorter than the declared outputs raises like Python", async () => { + // Python raises IndexError instead of silently mapping undefined. + const flow = buildClientToolFlow(numberProperty({ title: "x" }), [ + numberProperty({ title: "a" }), + stringProperty({ title: "b" }), + ]); + await expect(runFlowAndResume(flow, [7])).rejects.toThrow( + "Tool node `tool` returned 1 value(s) but declares 2 outputs; " + + "no value for output `b`.", + ); + }); + + it("content-block list shorter than the declared outputs raises like Python", async () => { + const flow = buildClientToolFlow(numberProperty({ title: "x" }), [ + stringProperty({ title: "text_out" }), + stringProperty({ title: "image_out" }), + ]); + await expect( + runFlowAndResume(flow, [{ type: "text", text: "hello" }]), + ).rejects.toThrow( + "Tool node `tool` returned 1 content block(s) but declares 2 outputs; " + + "no value for output `image_out`.", + ); + }); + + it("array output maps positionally onto multiple outputs", async () => { + const flow = buildClientToolFlow(numberProperty({ title: "x" }), [ + numberProperty({ title: "a" }), + stringProperty({ title: "b" }), + ]); + const outputs = await runFlowAndResume(flow, [7, "ok"]); + expect(outputs).toEqual({ a: 7, b: "ok" }); + }); + + it("mixed array output maps positionally onto number/object/array outputs", async () => { + const flow = buildClientToolFlow(numberProperty({ title: "x" }), [ + numberProperty({ title: "num" }), + objectProperty({ title: "obj", properties: {} }), + listProperty({ title: "array", itemType: numberProperty({ title: "elem" }) }), + ]); + const outputs = await runFlowAndResume(flow, [7, { key: "val" }, [1]]); + expect(outputs).toEqual({ num: 7, obj: { key: "val" }, array: [1] }); + }); + + it("MCP content-block lists extract payloads positionally", async () => { + const flow = buildClientToolFlow(numberProperty({ title: "x" }), [ + stringProperty({ title: "text_out" }), + stringProperty({ title: "image_out" }), + ]); + const outputs = await runFlowAndResume(flow, [ + { type: "text", text: "hello" }, + { type: "image", base64: "imgdata" }, + ]); + expect(outputs).toEqual({ text_out: "hello", image_out: "imgdata" }); + }); +}); diff --git a/tsagentspec/tests/adapters/langgraph/flow-state.test.ts b/tsagentspec/tests/adapters/langgraph/flow-state.test.ts index e7db93fb..68cd2f79 100644 --- a/tsagentspec/tests/adapters/langgraph/flow-state.test.ts +++ b/tsagentspec/tests/adapters/langgraph/flow-state.test.ts @@ -18,87 +18,22 @@ import { describe, expect, it } from "vitest"; import type { BaseMessage } from "@langchain/core/messages"; import { - createControlFlowEdge, - createDataFlowEdge, - createEndNode, createFlow, createServerTool, - createStartNode, createToolNode, integerProperty, numberProperty, stringProperty, - type ComponentWithIO, - type EndNode, type Flow, type Property, - type StartNode, } from "../../../src/index.js"; -import { AgentSpecLoader } from "../../../src/adapters/langgraph/agentspec-loader.js"; - -/** The invocable surface of a compiled flow graph. */ -interface CompiledFlow { - invoke( - input: unknown, - config?: unknown, - ): Promise>; -} - -/** A StartNode declaring the same properties as inputs and outputs. */ -function ioStartNode(name: string, props: Property[] = []): StartNode { - return createStartNode({ name, inputs: props, outputs: props }); -} - -/** An EndNode declaring the same properties as inputs and outputs. */ -function ioEndNode( - name: string, - props: Property[] = [], - branchName?: string, -): EndNode { - return createEndNode({ - name, - inputs: props, - outputs: props, - ...(branchName !== undefined ? { branchName } : {}), - }); -} - -function ctrl( - fromNode: Record, - toNode: Record, - fromBranch?: string, -) { - return createControlFlowEdge({ - name: `${String(fromNode["name"])}_to_${String(toNode["name"])}${ - fromBranch !== undefined ? `_${fromBranch}` : "" - }`, - fromNode, - toNode, - ...(fromBranch !== undefined ? { fromBranch } : {}), - }); -} - -function dataEdge( - sourceNode: ComponentWithIO, - destinationNode: ComponentWithIO, - sourceOutput: string, - destinationInput: string = sourceOutput, -) { - return createDataFlowEdge({ - name: `${sourceNode.name}.${sourceOutput}_to_${destinationNode.name}.${destinationInput}`, - sourceNode, - sourceOutput, - destinationNode, - destinationInput, - }); -} - -async function loadFlow(flow: Flow, toolRegistry?: Record) { - const loader = new AgentSpecLoader( - toolRegistry !== undefined ? { toolRegistry } : undefined, - ); - return (await loader.loadComponent(flow)) as CompiledFlow; -} +import { + ctrl, + dataEdge, + ioEndNode, + ioStartNode, + loadFlow, +} from "./test-helpers.js"; /** A start -> end pass-through flow over the given properties. */ function passThroughFlow(props: Property[], endBranchName?: string): Flow { @@ -296,7 +231,9 @@ describe("data-flow edges", () => { }); expect(flow.dataFlowConnections).toBeUndefined(); - const graph = await loadFlow(flow, { double_tool: double }); + const graph = await loadFlow(flow, { + toolRegistry: { double_tool: double }, + }); const result = await graph.invoke({ inputs: { x: 3 } }); expect(result["outputs"]).toEqual({ y: 6 }); }); @@ -323,7 +260,9 @@ describe("data-flow edges", () => { }); const graph = await loadFlow(flow, { - double_tool: (input: unknown) => (input as { value: number }).value * 2, + toolRegistry: { + double_tool: (input: unknown) => (input as { value: number }).value * 2, + }, }); const result = await graph.invoke({ inputs: { x: 4 } }); expect(result["outputs"]).toEqual({ y: 8 }); @@ -362,10 +301,12 @@ describe("data-flow edges", () => { }); const graph = await loadFlow(flow, { - double_tool: double, - add_tool: (input: unknown) => { - const { x, y } = input as { x: number; y: number }; - return x + y; + toolRegistry: { + double_tool: double, + add_tool: (input: unknown) => { + const { x, y } = input as { x: number; y: number }; + return x + y; + }, }, }); const result = await graph.invoke({ inputs: { x: 3 } }); diff --git a/tsagentspec/tests/adapters/langgraph/llm.test.ts b/tsagentspec/tests/adapters/langgraph/llm.test.ts index 54f2c6c7..b8343697 100644 --- a/tsagentspec/tests/adapters/langgraph/llm.test.ts +++ b/tsagentspec/tests/adapters/langgraph/llm.test.ts @@ -28,7 +28,6 @@ import { } from "../../../src/index.js"; import { convertLlmConfig, - generationConfigFromAgentSpec, prepareOpenAiCompatibleUrl, } from "../../../src/adapters/langgraph/llm.js"; @@ -77,30 +76,6 @@ describe("prepareOpenAiCompatibleUrl", () => { }); }); -describe("generationConfigFromAgentSpec", () => { - it("returns an empty config when no parameters are given", () => { - expect(generationConfigFromAgentSpec(undefined)).toEqual({}); - }); - - it("copies only the parameters that are set", () => { - expect(generationConfigFromAgentSpec({ temperature: 0.5 })).toEqual({ - temperature: 0.5, - }); - expect( - generationConfigFromAgentSpec(DEFAULT_GENERATION_PARAMETERS), - ).toEqual({ temperature: 0.2, maxTokens: 128, topP: 0.8 }); - }); - - it("ignores unsupported extra parameters", () => { - expect( - generationConfigFromAgentSpec({ - temperature: 0.2, - presencePenalty: 1.0, - } as LlmGenerationConfig), - ).toEqual({ temperature: 0.2 }); - }); -}); - describe("convertLlmConfig for OpenAI-compatible configs", () => { afterEach(() => { vi.unstubAllEnvs(); diff --git a/tsagentspec/tests/adapters/langgraph/loader-agent.test.ts b/tsagentspec/tests/adapters/langgraph/loader-agent.test.ts index 10578640..53fe74e7 100644 --- a/tsagentspec/tests/adapters/langgraph/loader-agent.test.ts +++ b/tsagentspec/tests/adapters/langgraph/loader-agent.test.ts @@ -9,7 +9,7 @@ * the component load policy. All tests run offline. */ import { describe, expect, it, vi } from "vitest"; -import { AIMessage, type BaseMessage } from "@langchain/core/messages"; +import { AIMessage } from "@langchain/core/messages"; import { tool } from "@langchain/core/tools"; import type { StructuredToolInterface } from "@langchain/core/tools"; import { Command, MemorySaver } from "@langchain/langgraph"; @@ -34,6 +34,7 @@ import { loadWithFakeLlm, makeAgent, makeLlmConfig, + messagesOf, rejectCommand, threadConfig, toolCallMessage, @@ -62,10 +63,6 @@ function getWeather(input: unknown): string { return `The weather in ${city} is sunny.`; } -function messagesOf(result: Record): BaseMessage[] { - return result["messages"] as BaseMessage[]; -} - describe("AgentSpecLoader load entry points", () => { const agentSpec = makeAgent({ name: "weather_agent" }); const serializer = new AgentSpecSerializer(); diff --git a/tsagentspec/tests/adapters/langgraph/manager-workers.test.ts b/tsagentspec/tests/adapters/langgraph/manager-workers.test.ts index cb49437b..5df295d6 100644 --- a/tsagentspec/tests/adapters/langgraph/manager-workers.test.ts +++ b/tsagentspec/tests/adapters/langgraph/manager-workers.test.ts @@ -44,6 +44,7 @@ import { import { FakeLlmAgentSpecLoader, makeLlmConfig, + messagesOf, threadConfig, type FakeLlmResponses, } from "./test-helpers.js"; @@ -128,11 +129,6 @@ function managerRouter( return branch!.path.func; } -/** The messages of an invoke result. */ -function messagesOf(result: Record): BaseMessage[] { - return result["messages"] as BaseMessage[]; -} - /** * The plain text of a message: langchain JS may deliver the system prompt as * a `[{type: "text", text}]` content-blocks array instead of a plain string. diff --git a/tsagentspec/tests/adapters/langgraph/test-helpers.ts b/tsagentspec/tests/adapters/langgraph/test-helpers.ts index 69c43868..5275de93 100644 --- a/tsagentspec/tests/adapters/langgraph/test-helpers.ts +++ b/tsagentspec/tests/adapters/langgraph/test-helpers.ts @@ -16,6 +16,9 @@ * of patching `httpx.request`). * - Spec builder helpers (`makeLlmConfig`, `makeAgent`) and interrupt/resume * helpers matching the Python test command shapes. + * - Flow builder/runner helpers shared by the flow suites (`ioStartNode`, + * `ioEndNode`, `ctrl`, `dataEdge`, `loadFlow`) and result accessors + * (`outputsOf`, `messagesOf`, `detailsOf`). */ import type { CallbackManagerForLLMRun } from "@langchain/core/callbacks/manager"; import { @@ -25,16 +28,24 @@ import { } from "@langchain/core/language_models/chat_models"; import { AIMessage, type BaseMessage } from "@langchain/core/messages"; import type { ChatResult } from "@langchain/core/outputs"; -import { Command } from "@langchain/langgraph"; +import { Command, type MemorySaver } from "@langchain/langgraph"; import { createAgent as createAgentSpecAgent, + createControlFlowEdge, + createDataFlowEdge, + createEndNode, + createStartNode, createVllmConfig, } from "../../../src/index.js"; import type { Agent, ComponentBase, + ComponentWithIO, + EndNode, + Flow, LlmConfig, Property, + StartNode, Tool, ToolBox, VllmConfig, @@ -356,3 +367,100 @@ export function rejectCommand(reason?: string): Command { }, }); } + +/** The invocable surface of a compiled flow graph. */ +export interface CompiledFlow { + invoke( + input: unknown, + config?: unknown, + ): Promise>; +} + +/** A StartNode declaring the same properties as inputs and outputs. */ +export function ioStartNode(name: string, props: Property[] = []): StartNode { + return createStartNode({ name, inputs: props, outputs: props }); +} + +/** An EndNode declaring the same properties as inputs and outputs. */ +export function ioEndNode( + name: string, + props: Property[] = [], + branchName?: string, +): EndNode { + return createEndNode({ + name, + inputs: props, + outputs: props, + ...(branchName !== undefined ? { branchName } : {}), + }); +} + +/** A control-flow edge named after its endpoints (and optional branch). */ +export function ctrl( + fromNode: Record, + toNode: Record, + fromBranch?: string, +) { + return createControlFlowEdge({ + name: `${String(fromNode["name"])}_to_${String(toNode["name"])}${ + fromBranch !== undefined ? `_${fromBranch}` : "" + }`, + fromNode, + toNode, + ...(fromBranch !== undefined ? { fromBranch } : {}), + }); +} + +/** A data-flow edge named after its endpoints and routed properties. */ +export function dataEdge( + sourceNode: ComponentWithIO, + destinationNode: ComponentWithIO, + sourceOutput: string, + destinationInput: string = sourceOutput, +) { + return createDataFlowEdge({ + name: `${sourceNode.name}.${sourceOutput}_to_${destinationNode.name}.${destinationInput}`, + sourceNode, + sourceOutput, + destinationNode, + destinationInput, + }); +} + +/** The `outputs` record of a flow invoke result. */ +export function outputsOf( + result: Record, +): Record { + return result["outputs"] as Record; +} + +/** The messages of an invoke result. */ +export function messagesOf(result: Record): BaseMessage[] { + return result["messages"] as BaseMessage[]; +} + +/** The `node_execution_details` record of a flow invoke result. */ +export function detailsOf( + result: Record, +): Record { + return result["node_execution_details"] as Record; +} + +/** Load a Flow spec into an invocable compiled graph. */ +export async function loadFlow( + flow: Flow, + options?: { + toolRegistry?: Record; + checkpointer?: MemorySaver; + }, +): Promise { + const loader = new AgentSpecLoader({ + ...(options?.toolRegistry !== undefined + ? { toolRegistry: options.toolRegistry } + : {}), + ...(options?.checkpointer !== undefined + ? { checkpointer: options.checkpointer } + : {}), + }); + return (await loader.loadComponent(flow)) as CompiledFlow; +} From 809053208ed617b0a24393a2ac2876814445b87e Mon Sep 17 00:00:00 2001 From: Salah Date: Thu, 3 Sep 2026 23:40:48 +0400 Subject: [PATCH 05/14] test(tsagentspec/adapters): restructure LangGraph suites and hoist shared 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. --- .../adapters/langgraph/exporter-flow.test.ts | 406 +++++++++++++++++ .../tests/adapters/langgraph/exporter.test.ts | 407 +----------------- 2 files changed, 410 insertions(+), 403 deletions(-) create mode 100644 tsagentspec/tests/adapters/langgraph/exporter-flow.test.ts diff --git a/tsagentspec/tests/adapters/langgraph/exporter-flow.test.ts b/tsagentspec/tests/adapters/langgraph/exporter-flow.test.ts new file mode 100644 index 00000000..3d0d5613 --- /dev/null +++ b/tsagentspec/tests/adapters/langgraph/exporter-flow.test.ts @@ -0,0 +1,406 @@ +/** + * Exporter tests for generic state graphs (LangGraph StateGraph -> Flow). + * + * Mirrors the state-graph portion of the Python suite + * (`pyagentspec/tests/adapters/langgraph/test_langgraph_to_agentspec.py`): + * plain edges, sink-node END synthesis, distinct input/output/node schemas, + * conditional edges (including the "condition" name collision), subgraph + * recursion and the documented conditional-edge rejections. Exercises the + * `agentspec-converter-flow.ts` pipeline behind `AgentSpecExporter`. All + * tests run offline: node functions are plain closures, never chat models. + */ +import { describe, expect, it } from "vitest"; +import { Annotation, END, START, StateGraph } from "@langchain/langgraph"; +import { DEFAULT_BRANCH, DEFAULT_INPUT } from "../../../src/index.js"; +import type { Flow, Property, ServerTool } from "../../../src/index.js"; +import { AgentSpecExporter } from "../../../src/adapters/langgraph/agentspec-exporter.js"; + +/** Structural view of an exported flow node used by the assertions. */ +interface ExportedNodeView { + id: string; + componentType: string; + name: string; + inputs?: Property[]; + outputs?: Property[]; + tool?: ServerTool; + mapping?: Record; + branches?: string[]; + subflow?: Flow; +} + +function nodesOf(flow: Flow): ExportedNodeView[] { + return flow.nodes as unknown as ExportedNodeView[]; +} + +function nodeNamed(flow: Flow, name: string): ExportedNodeView { + const node = nodesOf(flow).find((candidate) => candidate.name === name); + if (node === undefined) { + throw new Error(`Flow has no node named '${name}'.`); + } + return node; +} + +function controlFlowNames(flow: Flow): string[] { + return flow.controlFlowConnections.map((edge) => edge.name ?? ""); +} + +function dataFlowNames(flow: Flow): string[] { + return (flow.dataFlowConnections ?? []).map((edge) => edge.name ?? ""); +} + +/** Keys listed by a synthetic `state` property. */ +function statePropertyKeys(property: Property): string[] { + return Object.keys( + (property.jsonSchema["properties"] as Record) ?? {}, + ); +} + +describe("AgentSpecExporter: state graph flows", () => { + const CodeGenState = Annotation.Root({ + language: Annotation, + request: Annotation, + output: Annotation, + }); + + it("converts a linear compiled graph into a Flow", () => { + const exporter = new AgentSpecExporter(); + const graph = new StateGraph(CodeGenState) + .addNode("llm_code_gen", () => ({ output: "generated" })) + .addEdge(START, "llm_code_gen") + .addEdge("llm_code_gen", END); + const compiled = graph.compile({ name: "CodeGen Assistant" }); + + const flow = exporter.toComponent(compiled) as Flow; + + expect(flow.componentType).toBe("Flow"); + expect(flow.name).toBe("CodeGen Assistant"); + // llm_code_gen + synthesized __start__ + __end__ + expect(flow.nodes).toHaveLength(3); + expect( + nodesOf(flow).map((node) => [node.componentType, node.name]), + ).toEqual([ + ["ToolNode", "llm_code_gen"], + ["StartNode", "__start__"], + ["EndNode", "__end__"], + ]); + // One ctrl+data pair per LangGraph edge, with the Python edge names. + expect(controlFlowNames(flow)).toEqual([ + "__start___to_llm_code_gen", + "llm_code_gen_to___end__", + ]); + expect(dataFlowNames(flow)).toEqual([ + "__start___to_llm_code_gen_data_edge", + "llm_code_gen_to___end___data_edge", + ]); + + // The synthetic tool mirrors the node, over a single `state` property + // listing the channel keys. + const toolNode = nodeNamed(flow, "llm_code_gen"); + expect(toolNode.tool?.componentType).toBe("ServerTool"); + expect(toolNode.tool?.name).toBe("llm_code_gen_tool"); + const toolInput = toolNode.tool?.inputs[0] as Property; + expect(toolInput.title).toBe("state"); + expect(toolInput.type).toBe("object"); + expect(statePropertyKeys(toolInput)).toEqual([ + "language", + "request", + "output", + ]); + + // Flow inputs/outputs are inferred from the synthesized start/end nodes. + expect(flow.inputs?.map((input) => input.title)).toEqual(["state"]); + expect(flow.outputs?.map((output) => output.title)).toEqual(["state"]); + expect(statePropertyKeys(flow.inputs?.[0] as Property)).toEqual([ + "language", + "request", + "output", + ]); + }); + + it("converts an uncompiled builder into a Flow with the default name", () => { + const exporter = new AgentSpecExporter(); + const graph = new StateGraph(CodeGenState) + .addNode("llm_code_gen", () => ({ output: "generated" })) + .addEdge(START, "llm_code_gen") + .addEdge("llm_code_gen", END); + + const flow = exporter.toComponent(graph) as Flow; + + expect(flow.componentType).toBe("Flow"); + expect(flow.name).toBe("LangGraph Flow"); + }); + + it("synthesizes END edges for sink nodes without outgoing edges", () => { + const exporter = new AgentSpecExporter(); + const graph = new StateGraph(CodeGenState) + .addNode("sink", () => ({})) + .addEdge(START, "sink"); + + const flow = exporter.toComponent(graph.compile()) as Flow; + + expect(flow.nodes).toHaveLength(3); + expect(controlFlowNames(flow)).toEqual([ + "__start___to_sink", + "sink_to___end__", + ]); + expect(dataFlowNames(flow)).toEqual([ + "__start___to_sink_data_edge", + "sink_to___end___data_edge", + ]); + }); + + it("converts a graph with distinct input/output/node schemas", () => { + // Per-node `input` options are the JS equivalent of the Python function + // annotations the Python adapter introspects. + const exporter = new AgentSpecExporter(); + const InputSchema = Annotation.Root({ city: Annotation }); + const OutputSchema = Annotation.Root({ response: Annotation }); + const WeatherSchema = Annotation.Root({ + weather_data: Annotation, + }); + const InternalState = Annotation.Root({ + city: Annotation, + weather_data: Annotation, + response: Annotation, + }); + const graph = new StateGraph({ + state: InternalState, + input: InputSchema, + output: OutputSchema, + }) + .addNode("get_weather", () => ({ weather_data: "sunny" }), { + input: InputSchema, + }) + .addNode("llm_node", () => ({ response: "reformulated" }), { + input: WeatherSchema, + }) + .addEdge(START, "get_weather") + .addEdge("get_weather", "llm_node") + .addEdge("llm_node", END); + + const flow = exporter.toComponent(graph.compile({ name: "Weather Flow" })) as Flow; + + expect(flow.name).toBe("Weather Flow"); + // get_weather + llm_node + __start__ + __end__ + expect(flow.nodes).toHaveLength(4); + expect(flow.controlFlowConnections).toHaveLength(3); + expect(flow.dataFlowConnections).toHaveLength(3); + const startNode = nodeNamed(flow, "__start__"); + const endNode = nodeNamed(flow, "__end__"); + expect(statePropertyKeys(startNode.outputs?.[0] as Property)).toEqual([ + "city", + ]); + expect(statePropertyKeys(endNode.outputs?.[0] as Property)).toEqual([ + "response", + ]); + const getWeatherNode = nodeNamed(flow, "get_weather"); + expect(statePropertyKeys(getWeatherNode.inputs?.[0] as Property)).toEqual([ + "city", + ]); + expect(statePropertyKeys(getWeatherNode.outputs?.[0] as Property)).toEqual([ + "weather_data", + ]); + }); + + it("expands a conditional edge into a conditional ToolNode plus a BranchingNode", () => { + const exporter = new AgentSpecExporter(); + const CaseState = Annotation.Root({ sentence: Annotation }); + const graph = new StateGraph(CaseState) + .addNode("lowercase", () => ({})) + .addNode("uppercase", () => ({})) + .addNode("messycase", () => ({})) + .addConditionalEdges(START, () => "lowercase", { + lowercase: "lowercase", + uppercase: "uppercase", + messycase: "messycase", + }); + + const flow = exporter.toComponent( + graph.compile({ name: "Casecheck Flow" }), + ) as Flow; + + expect(flow.name).toBe("Casecheck Flow"); + // 3 case nodes + __start__ + __end__ + conditional node + branching node + expect(flow.nodes).toHaveLength(7); + + // The conditional ToolNode computes the branch name (LangGraph JS names + // every conditional branch "condition"). + const conditionalNode = nodeNamed(flow, "condition"); + expect(conditionalNode.componentType).toBe("ToolNode"); + expect(conditionalNode.tool?.name).toBe("condition_tool"); + expect(conditionalNode.tool?.outputs.map((output) => output.title)).toEqual( + [DEFAULT_INPUT], + ); + + const branchingNode = nodeNamed(flow, "condition_branching_node"); + expect(branchingNode.componentType).toBe("BranchingNode"); + expect(branchingNode.mapping).toEqual({ + lowercase: "lowercase", + uppercase: "uppercase", + messycase: "messycase", + }); + expect(new Set(branchingNode.branches)).toEqual( + new Set([DEFAULT_BRANCH, "lowercase", "uppercase", "messycase"]), + ); + + // Control edges: source -> conditional -> branching -> per-branch targets + // plus the default fall-through to END and auto-END edges for the sinks. + const edgesWithBranch = flow.controlFlowConnections.map((edge) => [ + edge.name, + edge.fromBranch, + ]); + expect(edgesWithBranch).toEqual([ + ["__start___to_condition", undefined], + ["condition_to_condition_branching_node", undefined], + ["condition_branching_node_to_lowercase", "lowercase"], + ["condition_branching_node_to_uppercase", "uppercase"], + ["condition_branching_node_to_messycase", "messycase"], + ["condition_branching_node_to___end__", DEFAULT_BRANCH], + ["lowercase_to___end__", undefined], + ["uppercase_to___end__", undefined], + ["messycase_to___end__", undefined], + ]); + + const dataNames = dataFlowNames(flow); + expect(dataNames).toContain("__start___to_condition_data_edge"); + expect(dataNames).toContain( + "condition_to_condition_branching_node_data_edge", + ); + expect(dataNames).toContain("data___start___to_lowercase"); + const branchingDataEdge = (flow.dataFlowConnections ?? []).find( + (edge) => edge.name === "condition_to_condition_branching_node_data_edge", + ); + expect(branchingDataEdge?.sourceOutput).toBe(DEFAULT_INPUT); + expect(branchingDataEdge?.destinationInput).toBe(DEFAULT_INPUT); + }); + + it("keeps a real node named 'condition' distinct from the synthetic conditional node", () => { + // LangGraph JS stores every conditional edge's branch under the fixed key + // "condition"; a user node with that literal name must not be overwritten + // by the synthetic conditional ToolNode. The synthetic names are suffixed + // instead (only in the colliding case). + const exporter = new AgentSpecExporter(); + const CaseState = Annotation.Root({ sentence: Annotation }); + const graph = new StateGraph(CaseState) + .addNode("condition", () => ({})) + .addNode("other", () => ({})) + .addConditionalEdges(START, () => "condition", { + condition: "condition", + other: "other", + }); + + const flow = exporter.toComponent( + graph.compile({ name: "Collision Flow" }), + ) as Flow; + + // 2 real nodes + __start__ + __end__ + conditional node + branching node. + expect(flow.nodes).toHaveLength(6); + + const realNode = nodeNamed(flow, "condition"); + expect(realNode.componentType).toBe("ToolNode"); + expect(realNode.tool?.name).toBe("condition_tool"); + + const conditionalNode = nodeNamed(flow, "condition_1"); + expect(conditionalNode.componentType).toBe("ToolNode"); + expect(conditionalNode.tool?.name).toBe("condition_1_tool"); + + const branchingNode = nodeNamed(flow, "condition_1_branching_node"); + expect(branchingNode.componentType).toBe("BranchingNode"); + expect(branchingNode.mapping).toEqual({ + condition: "condition", + other: "other", + }); + + // The branch-target edge is wired to the REAL node, not the synthetic one. + const branchTargetEdge = flow.controlFlowConnections.find( + (edge) => edge.name === "condition_1_branching_node_to_condition", + ); + expect(branchTargetEdge?.fromBranch).toBe("condition"); + expect((branchTargetEdge?.toNode as unknown as ExportedNodeView).id).toBe( + realNode.id, + ); + + // And the real node stays connected downstream (auto edge to END). + expect(controlFlowNames(flow)).toContain("condition_to___end__"); + }); + + it("rejects a conditional edge without a path map", () => { + const exporter = new AgentSpecExporter(); + const CaseState = Annotation.Root({ sentence: Annotation }); + const graph = new StateGraph(CaseState) + .addNode("lowercase", () => ({})) + .addConditionalEdges(START, () => "lowercase"); + + expect(() => exporter.toComponent(graph.compile())).toThrow( + "Mapping for condition not found.\n" + + " Make sure to add proper return type hints to the branching function.", + ); + }); + + it("rejects multiple conditional edges with the same source node", () => { + const exporter = new AgentSpecExporter(); + const CaseState = Annotation.Root({ sentence: Annotation }); + const graph = new StateGraph(CaseState) + .addNode("node_a", () => ({})) + .addNode("node_b", () => ({})) + .addConditionalEdges(START, () => "node_a", { go: "node_a" }); + // LangGraph JS names every conditional branch "condition" and refuses a + // second one on the same source, so the runtime shape the exporter guards + // against is reproduced on the builder directly. + const branches = ( + graph as unknown as { + branches: Record>; + } + ).branches; + branches[START]!["condition2"] = branches[START]!["condition"]!; + + expect(() => exporter.toComponent(graph)).toThrow( + "Conversion of multiple conditional edges with the same source node is not yet supported", + ); + }); + + it("converts subgraph nodes into FlowNodes recursively", () => { + const exporter = new AgentSpecExporter(); + const SubState = Annotation.Root({ foo: Annotation }); + const subgraph = new StateGraph(SubState) + .addNode("subgraph_node_1", (state) => ({ foo: `hi! ${state.foo}` })) + .addEdge(START, "subgraph_node_1") + .compile(); + const parent = new StateGraph(SubState) + .addNode("node_1", subgraph) + .addEdge(START, "node_1"); + const compiled = parent.compile({ name: "GraphWithSubgraph" }); + + const flow = exporter.toComponent(compiled) as Flow; + + expect(flow.componentType).toBe("Flow"); + expect(flow.name).toBe("GraphWithSubgraph"); + const flowNodes = nodesOf(flow).filter( + (node) => node.componentType === "FlowNode", + ); + expect(flowNodes).toHaveLength(1); + expect(flowNodes[0]!.name).toBe("node_1"); + + const subflow = flowNodes[0]!.subflow as Flow; + expect(subflow.componentType).toBe("Flow"); + // Both levels synthesize __start__/__end__ around their single node. + expect(flow.nodes).toHaveLength(3); + expect(subflow.nodes).toHaveLength(3); + expect( + nodesOf(subflow).map((node) => [node.componentType, node.name]), + ).toEqual([ + ["ToolNode", "subgraph_node_1"], + ["StartNode", "__start__"], + ["EndNode", "__end__"], + ]); + // Explicit edge + implicit edge to END at each level. + expect(controlFlowNames(flow)).toEqual([ + "__start___to_node_1", + "node_1_to___end__", + ]); + expect(controlFlowNames(subflow as Flow)).toEqual([ + "__start___to_subgraph_node_1", + "subgraph_node_1_to___end__", + ]); + }); +}); diff --git a/tsagentspec/tests/adapters/langgraph/exporter.test.ts b/tsagentspec/tests/adapters/langgraph/exporter.test.ts index e06a90d5..9cba2df0 100644 --- a/tsagentspec/tests/adapters/langgraph/exporter.test.ts +++ b/tsagentspec/tests/adapters/langgraph/exporter.test.ts @@ -4,30 +4,22 @@ * Mirrors the offline-able behaviors of the Python suite * (`pyagentspec/tests/adapters/langgraph/test_langgraph_to_agentspec.py` and * `test_disaggregated_config.py`): structured tools to ServerTools, chat - * models to LLM configs, langchain react agents to Agents, generic state - * graphs to Flows (plain edges, conditional edges, subgraphs), shared-object + * models to LLM configs, langchain react agents to Agents, shared-object * memoization, disaggregated exports and the documented TS-only rejections - * (swarm graphs and bare compiled agent graphs). All tests run offline: chat + * (swarm graphs and bare compiled agent graphs). State-graph-to-Flow + * conversion lives in `exporter-flow.test.ts`. All tests run offline: chat * models are only constructed, never invoked. */ import { describe, expect, it } from "vitest"; import { z } from "zod"; import { SystemMessage } from "@langchain/core/messages"; import { tool } from "@langchain/core/tools"; -import { - Annotation, - END, - MemorySaver, - START, - StateGraph, -} from "@langchain/langgraph"; +import { MemorySaver } from "@langchain/langgraph"; import { createSwarm } from "@langchain/langgraph-swarm"; import { ChatOllama } from "@langchain/ollama"; import { ChatOpenAI } from "@langchain/openai"; import { createAgent } from "langchain"; import { - DEFAULT_BRANCH, - DEFAULT_INPUT, OpenAIAPIType, createOpenAiCompatibleConfig, createServerTool, @@ -35,7 +27,6 @@ import { } from "../../../src/index.js"; import type { Agent, - Flow, OllamaConfig, OpenAiCompatibleConfig, OpenAiConfig, @@ -78,46 +69,6 @@ function makeWeatherLangChainTool() { }); } -/** Structural view of an exported flow node used by the assertions. */ -interface ExportedNodeView { - id: string; - componentType: string; - name: string; - inputs?: Property[]; - outputs?: Property[]; - tool?: ServerTool; - mapping?: Record; - branches?: string[]; - subflow?: Flow; -} - -function nodesOf(flow: Flow): ExportedNodeView[] { - return flow.nodes as unknown as ExportedNodeView[]; -} - -function nodeNamed(flow: Flow, name: string): ExportedNodeView { - const node = nodesOf(flow).find((candidate) => candidate.name === name); - if (node === undefined) { - throw new Error(`Flow has no node named '${name}'.`); - } - return node; -} - -function controlFlowNames(flow: Flow): string[] { - return flow.controlFlowConnections.map((edge) => edge.name ?? ""); -} - -function dataFlowNames(flow: Flow): string[] { - return (flow.dataFlowConnections ?? []).map((edge) => edge.name ?? ""); -} - -/** Keys listed by a synthetic `state` property. */ -function statePropertyKeys(property: Property): string[] { - return Object.keys( - (property.jsonSchema["properties"] as Record) ?? {}, - ); -} - describe("AgentSpecExporter: structured tools", () => { it("converts a zod structured tool into a ServerTool with typed inputs", () => { const exporter = new AgentSpecExporter(); @@ -450,356 +401,6 @@ describe("AgentSpecExporter: swarm graphs", () => { }); }); -describe("AgentSpecExporter: state graph flows", () => { - const CodeGenState = Annotation.Root({ - language: Annotation, - request: Annotation, - output: Annotation, - }); - - it("converts a linear compiled graph into a Flow", () => { - const exporter = new AgentSpecExporter(); - const graph = new StateGraph(CodeGenState) - .addNode("llm_code_gen", () => ({ output: "generated" })) - .addEdge(START, "llm_code_gen") - .addEdge("llm_code_gen", END); - const compiled = graph.compile({ name: "CodeGen Assistant" }); - - const flow = exporter.toComponent(compiled) as Flow; - - expect(flow.componentType).toBe("Flow"); - expect(flow.name).toBe("CodeGen Assistant"); - // llm_code_gen + synthesized __start__ + __end__ - expect(flow.nodes).toHaveLength(3); - expect( - nodesOf(flow).map((node) => [node.componentType, node.name]), - ).toEqual([ - ["ToolNode", "llm_code_gen"], - ["StartNode", "__start__"], - ["EndNode", "__end__"], - ]); - // One ctrl+data pair per LangGraph edge, with the Python edge names. - expect(controlFlowNames(flow)).toEqual([ - "__start___to_llm_code_gen", - "llm_code_gen_to___end__", - ]); - expect(dataFlowNames(flow)).toEqual([ - "__start___to_llm_code_gen_data_edge", - "llm_code_gen_to___end___data_edge", - ]); - - // The synthetic tool mirrors the node, over a single `state` property - // listing the channel keys. - const toolNode = nodeNamed(flow, "llm_code_gen"); - expect(toolNode.tool?.componentType).toBe("ServerTool"); - expect(toolNode.tool?.name).toBe("llm_code_gen_tool"); - const toolInput = toolNode.tool?.inputs[0] as Property; - expect(toolInput.title).toBe("state"); - expect(toolInput.type).toBe("object"); - expect(statePropertyKeys(toolInput)).toEqual([ - "language", - "request", - "output", - ]); - - // Flow inputs/outputs are inferred from the synthesized start/end nodes. - expect(flow.inputs?.map((input) => input.title)).toEqual(["state"]); - expect(flow.outputs?.map((output) => output.title)).toEqual(["state"]); - expect(statePropertyKeys(flow.inputs?.[0] as Property)).toEqual([ - "language", - "request", - "output", - ]); - }); - - it("converts an uncompiled builder into a Flow with the default name", () => { - const exporter = new AgentSpecExporter(); - const graph = new StateGraph(CodeGenState) - .addNode("llm_code_gen", () => ({ output: "generated" })) - .addEdge(START, "llm_code_gen") - .addEdge("llm_code_gen", END); - - const flow = exporter.toComponent(graph) as Flow; - - expect(flow.componentType).toBe("Flow"); - expect(flow.name).toBe("LangGraph Flow"); - }); - - it("synthesizes END edges for sink nodes without outgoing edges", () => { - const exporter = new AgentSpecExporter(); - const graph = new StateGraph(CodeGenState) - .addNode("sink", () => ({})) - .addEdge(START, "sink"); - - const flow = exporter.toComponent(graph.compile()) as Flow; - - expect(flow.nodes).toHaveLength(3); - expect(controlFlowNames(flow)).toEqual([ - "__start___to_sink", - "sink_to___end__", - ]); - expect(dataFlowNames(flow)).toEqual([ - "__start___to_sink_data_edge", - "sink_to___end___data_edge", - ]); - }); - - it("converts a graph with distinct input/output/node schemas", () => { - // Per-node `input` options are the JS equivalent of the Python function - // annotations the Python adapter introspects. - const exporter = new AgentSpecExporter(); - const InputSchema = Annotation.Root({ city: Annotation }); - const OutputSchema = Annotation.Root({ response: Annotation }); - const WeatherSchema = Annotation.Root({ - weather_data: Annotation, - }); - const InternalState = Annotation.Root({ - city: Annotation, - weather_data: Annotation, - response: Annotation, - }); - const graph = new StateGraph({ - state: InternalState, - input: InputSchema, - output: OutputSchema, - }) - .addNode("get_weather", () => ({ weather_data: "sunny" }), { - input: InputSchema, - }) - .addNode("llm_node", () => ({ response: "reformulated" }), { - input: WeatherSchema, - }) - .addEdge(START, "get_weather") - .addEdge("get_weather", "llm_node") - .addEdge("llm_node", END); - - const flow = exporter.toComponent(graph.compile({ name: "Weather Flow" })) as Flow; - - expect(flow.name).toBe("Weather Flow"); - // get_weather + llm_node + __start__ + __end__ - expect(flow.nodes).toHaveLength(4); - expect(flow.controlFlowConnections).toHaveLength(3); - expect(flow.dataFlowConnections).toHaveLength(3); - const startNode = nodeNamed(flow, "__start__"); - const endNode = nodeNamed(flow, "__end__"); - expect(statePropertyKeys(startNode.outputs?.[0] as Property)).toEqual([ - "city", - ]); - expect(statePropertyKeys(endNode.outputs?.[0] as Property)).toEqual([ - "response", - ]); - const getWeatherNode = nodeNamed(flow, "get_weather"); - expect(statePropertyKeys(getWeatherNode.inputs?.[0] as Property)).toEqual([ - "city", - ]); - expect(statePropertyKeys(getWeatherNode.outputs?.[0] as Property)).toEqual([ - "weather_data", - ]); - }); - - it("expands a conditional edge into a conditional ToolNode plus a BranchingNode", () => { - const exporter = new AgentSpecExporter(); - const CaseState = Annotation.Root({ sentence: Annotation }); - const graph = new StateGraph(CaseState) - .addNode("lowercase", () => ({})) - .addNode("uppercase", () => ({})) - .addNode("messycase", () => ({})) - .addConditionalEdges(START, () => "lowercase", { - lowercase: "lowercase", - uppercase: "uppercase", - messycase: "messycase", - }); - - const flow = exporter.toComponent( - graph.compile({ name: "Casecheck Flow" }), - ) as Flow; - - expect(flow.name).toBe("Casecheck Flow"); - // 3 case nodes + __start__ + __end__ + conditional node + branching node - expect(flow.nodes).toHaveLength(7); - - // The conditional ToolNode computes the branch name (LangGraph JS names - // every conditional branch "condition"). - const conditionalNode = nodeNamed(flow, "condition"); - expect(conditionalNode.componentType).toBe("ToolNode"); - expect(conditionalNode.tool?.name).toBe("condition_tool"); - expect(conditionalNode.tool?.outputs.map((output) => output.title)).toEqual( - [DEFAULT_INPUT], - ); - - const branchingNode = nodeNamed(flow, "condition_branching_node"); - expect(branchingNode.componentType).toBe("BranchingNode"); - expect(branchingNode.mapping).toEqual({ - lowercase: "lowercase", - uppercase: "uppercase", - messycase: "messycase", - }); - expect(new Set(branchingNode.branches)).toEqual( - new Set([DEFAULT_BRANCH, "lowercase", "uppercase", "messycase"]), - ); - - // Control edges: source -> conditional -> branching -> per-branch targets - // plus the default fall-through to END and auto-END edges for the sinks. - const edgesWithBranch = flow.controlFlowConnections.map((edge) => [ - edge.name, - edge.fromBranch, - ]); - expect(edgesWithBranch).toEqual([ - ["__start___to_condition", undefined], - ["condition_to_condition_branching_node", undefined], - ["condition_branching_node_to_lowercase", "lowercase"], - ["condition_branching_node_to_uppercase", "uppercase"], - ["condition_branching_node_to_messycase", "messycase"], - ["condition_branching_node_to___end__", DEFAULT_BRANCH], - ["lowercase_to___end__", undefined], - ["uppercase_to___end__", undefined], - ["messycase_to___end__", undefined], - ]); - - const dataNames = dataFlowNames(flow); - expect(dataNames).toContain("__start___to_condition_data_edge"); - expect(dataNames).toContain( - "condition_to_condition_branching_node_data_edge", - ); - expect(dataNames).toContain("data___start___to_lowercase"); - const branchingDataEdge = (flow.dataFlowConnections ?? []).find( - (edge) => edge.name === "condition_to_condition_branching_node_data_edge", - ); - expect(branchingDataEdge?.sourceOutput).toBe(DEFAULT_INPUT); - expect(branchingDataEdge?.destinationInput).toBe(DEFAULT_INPUT); - }); - - it("keeps a real node named 'condition' distinct from the synthetic conditional node", () => { - // LangGraph JS stores every conditional edge's branch under the fixed key - // "condition"; a user node with that literal name must not be overwritten - // by the synthetic conditional ToolNode. The synthetic names are suffixed - // instead (only in the colliding case). - const exporter = new AgentSpecExporter(); - const CaseState = Annotation.Root({ sentence: Annotation }); - const graph = new StateGraph(CaseState) - .addNode("condition", () => ({})) - .addNode("other", () => ({})) - .addConditionalEdges(START, () => "condition", { - condition: "condition", - other: "other", - }); - - const flow = exporter.toComponent( - graph.compile({ name: "Collision Flow" }), - ) as Flow; - - // 2 real nodes + __start__ + __end__ + conditional node + branching node. - expect(flow.nodes).toHaveLength(6); - - const realNode = nodeNamed(flow, "condition"); - expect(realNode.componentType).toBe("ToolNode"); - expect(realNode.tool?.name).toBe("condition_tool"); - - const conditionalNode = nodeNamed(flow, "condition_1"); - expect(conditionalNode.componentType).toBe("ToolNode"); - expect(conditionalNode.tool?.name).toBe("condition_1_tool"); - - const branchingNode = nodeNamed(flow, "condition_1_branching_node"); - expect(branchingNode.componentType).toBe("BranchingNode"); - expect(branchingNode.mapping).toEqual({ - condition: "condition", - other: "other", - }); - - // The branch-target edge is wired to the REAL node, not the synthetic one. - const branchTargetEdge = flow.controlFlowConnections.find( - (edge) => edge.name === "condition_1_branching_node_to_condition", - ); - expect(branchTargetEdge?.fromBranch).toBe("condition"); - expect((branchTargetEdge?.toNode as unknown as ExportedNodeView).id).toBe( - realNode.id, - ); - - // And the real node stays connected downstream (auto edge to END). - expect(controlFlowNames(flow)).toContain("condition_to___end__"); - }); - - it("rejects a conditional edge without a path map", () => { - const exporter = new AgentSpecExporter(); - const CaseState = Annotation.Root({ sentence: Annotation }); - const graph = new StateGraph(CaseState) - .addNode("lowercase", () => ({})) - .addConditionalEdges(START, () => "lowercase"); - - expect(() => exporter.toComponent(graph.compile())).toThrow( - "Mapping for condition not found.\n" + - " Make sure to add proper return type hints to the branching function.", - ); - }); - - it("rejects multiple conditional edges with the same source node", () => { - const exporter = new AgentSpecExporter(); - const CaseState = Annotation.Root({ sentence: Annotation }); - const graph = new StateGraph(CaseState) - .addNode("node_a", () => ({})) - .addNode("node_b", () => ({})) - .addConditionalEdges(START, () => "node_a", { go: "node_a" }); - // LangGraph JS names every conditional branch "condition" and refuses a - // second one on the same source, so the runtime shape the exporter guards - // against is reproduced on the builder directly. - const branches = ( - graph as unknown as { - branches: Record>; - } - ).branches; - branches[START]!["condition2"] = branches[START]!["condition"]!; - - expect(() => exporter.toComponent(graph)).toThrow( - "Conversion of multiple conditional edges with the same source node is not yet supported", - ); - }); - - it("converts subgraph nodes into FlowNodes recursively", () => { - const exporter = new AgentSpecExporter(); - const SubState = Annotation.Root({ foo: Annotation }); - const subgraph = new StateGraph(SubState) - .addNode("subgraph_node_1", (state) => ({ foo: `hi! ${state.foo}` })) - .addEdge(START, "subgraph_node_1") - .compile(); - const parent = new StateGraph(SubState) - .addNode("node_1", subgraph) - .addEdge(START, "node_1"); - const compiled = parent.compile({ name: "GraphWithSubgraph" }); - - const flow = exporter.toComponent(compiled) as Flow; - - expect(flow.componentType).toBe("Flow"); - expect(flow.name).toBe("GraphWithSubgraph"); - const flowNodes = nodesOf(flow).filter( - (node) => node.componentType === "FlowNode", - ); - expect(flowNodes).toHaveLength(1); - expect(flowNodes[0]!.name).toBe("node_1"); - - const subflow = flowNodes[0]!.subflow as Flow; - expect(subflow.componentType).toBe("Flow"); - // Both levels synthesize __start__/__end__ around their single node. - expect(flow.nodes).toHaveLength(3); - expect(subflow.nodes).toHaveLength(3); - expect( - nodesOf(subflow).map((node) => [node.componentType, node.name]), - ).toEqual([ - ["ToolNode", "subgraph_node_1"], - ["StartNode", "__start__"], - ["EndNode", "__end__"], - ]); - // Explicit edge + implicit edge to END at each level. - expect(controlFlowNames(flow)).toEqual([ - "__start___to_node_1", - "node_1_to___end__", - ]); - expect(controlFlowNames(subflow as Flow)).toEqual([ - "__start___to_subgraph_node_1", - "subgraph_node_1_to___end__", - ]); - }); -}); - describe("AgentSpecExporter: shared components and disaggregation", () => { it("memoizes a chat model and tool shared by two agents", () => { const converter = new LangGraphToAgentSpecConverter(); From 484e8a4bbb8f3d1a9a073d35f9ec91c457a27bb8 Mon Sep 17 00:00:00 2001 From: Salah Date: Fri, 4 Sep 2026 00:03:14 +0400 Subject: [PATCH 06/14] refactor(tsagentspec/adapters): follow-ups from refactor verification Resolves the residual gaps found while auditing the quality-review remediation against the pre-refactor behavior baseline. --- .../src/adapters/common/tools-common.ts | 15 ++++- .../langgraph/node-execution/api-node.ts | 7 +++ tsagentspec/src/property.ts | 22 ++++--- .../tests/adapters/common/json-schema.test.ts | 29 ++++++++++ .../adapters/common/tools-common.test.ts | 58 +++++++++++++++++++ .../tests/adapters/langgraph/llm.test.ts | 24 ++++++++ tsagentspec/tests/property.test.ts | 9 ++- 7 files changed, 151 insertions(+), 13 deletions(-) create mode 100644 tsagentspec/tests/adapters/common/tools-common.test.ts diff --git a/tsagentspec/src/adapters/common/tools-common.ts b/tsagentspec/src/adapters/common/tools-common.ts index 2eaebe3c..e3a41736 100644 --- a/tsagentspec/src/adapters/common/tools-common.ts +++ b/tsagentspec/src/adapters/common/tools-common.ts @@ -132,11 +132,22 @@ export interface TemplatedHttpRequestSpec { * non-empty data is not sent for those methods and the flag is returned for * the caller to surface (the ApiNode executor warns; the RemoteTool path * keeps Python's silence). + * + * `options.isRecord` decides which rendered data values count as a record for + * the urlencoded-form encoding and the GET/HEAD empty-body check. It defaults + * to the strict `isPlainRecord` (the RemoteTool path's historical guard); the + * ApiNode executor passes the loose `isRecordLike`, preserving each caller's + * pre-unification behavior when a full `{{placeholder}}` substitution renders + * `data` to a non-plain object such as a class instance. */ export function buildTemplatedHttpRequest( spec: TemplatedHttpRequestSpec, inputs: Record, + options: { + isRecord?: (value: unknown) => value is Record; + } = {}, ): { url: string; init: RequestInit; bodyDropped: boolean } { + const isRecord = options.isRecord ?? isPlainRecord; const renderedData = renderNestedObjectTemplate(spec.data, inputs); const renderedHeaders = renderRecord(spec.headers, inputs); const renderedQueryParams = renderRecord(spec.queryParams, inputs); @@ -168,11 +179,11 @@ export function buildTemplatedHttpRequest( renderedData !== undefined && renderedData !== null && renderedData !== "" && - !(isPlainRecord(renderedData) && Object.keys(renderedData).length === 0); + !(isRecord(renderedData) && Object.keys(renderedData).length === 0); let body: string | URLSearchParams | Uint8Array | undefined; if (methodAllowsBody) { - if (expectUrlencodedFormData && isPlainRecord(renderedData)) { + if (expectUrlencodedFormData && isRecord(renderedData)) { const form = new URLSearchParams(); for (const [key, value] of Object.entries(renderedData)) { form.append( diff --git a/tsagentspec/src/adapters/langgraph/node-execution/api-node.ts b/tsagentspec/src/adapters/langgraph/node-execution/api-node.ts index 1a984380..56eaecec 100644 --- a/tsagentspec/src/adapters/langgraph/node-execution/api-node.ts +++ b/tsagentspec/src/adapters/langgraph/node-execution/api-node.ts @@ -23,6 +23,7 @@ import type { ApiNode } from "../../../flows/index.js"; import { buildTemplatedHttpRequest, fetchWithAdapterDefaults, + isRecordLike, maybeWarnAboutUnrestrictedTemplatedUrl, } from "../../common/index.js"; import type { ExecuteOutput, NodeOutputs } from "../types.js"; @@ -50,9 +51,15 @@ export class ApiNodeExecutor extends NodeExecutor { inputs: NodeOutputs, _messages: BaseMessage[], ): Promise { + // The loose record guard preserves this executor's historical semantics: + // when a full `{{placeholder}}` substitution renders `data` to a + // non-plain object (class instance, Map), it still counts as a record + // for the urlencoded-form encoding and the GET/HEAD empty-body check + // (the RemoteTool path keeps the strict default). const { url, init, bodyDropped } = buildTemplatedHttpRequest( this.node, inputs, + { isRecord: isRecordLike }, ); if (bodyDropped) { // Forced divergence from Python: warn instead of silently dropping. diff --git a/tsagentspec/src/property.ts b/tsagentspec/src/property.ts index 7fc0035a..e8aac1ad 100644 --- a/tsagentspec/src/property.ts +++ b/tsagentspec/src/property.ts @@ -255,11 +255,12 @@ function normalizeUnionTypes( schema: JsonSchemaValue, ): JsonSchemaValue[] { const jsonSchemaType = schema["type"] ?? []; - const types: string[] = typeof jsonSchemaType === "string" - ? [jsonSchemaType] - : Array.isArray(jsonSchemaType) - ? (jsonSchemaType as string[]) - : []; + // Python parity: a non-array `type` is wrapped as-is — even a malformed + // non-string value ends up in the union as `{ type: }` instead of + // being silently dropped (mirrors `[json_schema_type]` in property.py). + const types: unknown[] = Array.isArray(jsonSchemaType) + ? (jsonSchemaType as unknown[]) + : [jsonSchemaType]; const allTypes: JsonSchemaValue[] = [ ...((schema["anyOf"] as JsonSchemaValue[]) ?? []), @@ -333,12 +334,17 @@ export function jsonSchemasHaveSameType( if ("properties" in a || "properties" in b) { const aProps = (a["properties"] ?? {}) as Record; const bProps = (b["properties"] ?? {}) as Record; + // Element-wise comparison of the sorted key sets (Python's + // `dict.keys() != dict.keys()`): joining with a separator could collide + // on property names containing that separator. + const aKeys = Object.keys(aProps).sort(); + const bKeys = Object.keys(bProps).sort(); if ( - Object.keys(aProps).sort().join(",") !== - Object.keys(bProps).sort().join(",") + aKeys.length !== bKeys.length || + aKeys.some((key, index) => key !== bKeys[index]) ) return false; - for (const key of Object.keys(aProps)) { + for (const key of aKeys) { if (!jsonSchemasHaveSameType(aProps[key]!, bProps[key]!)) return false; } } diff --git a/tsagentspec/tests/adapters/common/json-schema.test.ts b/tsagentspec/tests/adapters/common/json-schema.test.ts index 83698ac3..f86e82af 100644 --- a/tsagentspec/tests/adapters/common/json-schema.test.ts +++ b/tsagentspec/tests/adapters/common/json-schema.test.ts @@ -92,6 +92,35 @@ describe("jsonSchemasHaveSameType", () => { ).toBe(false); }); + it("distinguishes property names containing the sort separator", () => { + // Regression pin: comparing joined key strings would collide on names + // containing a comma ({"a,b"} vs {"a","b"}) and then throw looking up + // the missing key; element-wise comparison returns false cleanly. + expect( + jsonSchemasHaveSameType( + { type: "object", properties: { "a,b": { type: "string" } } }, + { + type: "object", + properties: { a: { type: "string" }, b: { type: "string" } }, + }, + ), + ).toBe(false); + }); + + it("keeps a malformed non-string type in the normalized union (Python parity)", () => { + // Python wraps a non-list `type` as-is, so {anyOf, type: 5} normalizes + // to [..., {type: 5}] and compares unequal to the plain anyOf schema + // instead of silently dropping the malformed member. + const malformed: JsonSchemaValue = { + anyOf: [{ type: "string" }], + type: 5, + }; + expect( + jsonSchemasHaveSameType(malformed, { anyOf: [{ type: "string" }] }), + ).toBe(false); + expect(jsonSchemasHaveSameType(malformed, malformed)).toBe(true); + }); + it("compares additionalProperties strictly when boolean", () => { expect( jsonSchemasHaveSameType( diff --git a/tsagentspec/tests/adapters/common/tools-common.test.ts b/tsagentspec/tests/adapters/common/tools-common.test.ts new file mode 100644 index 00000000..9d364a65 --- /dev/null +++ b/tsagentspec/tests/adapters/common/tools-common.test.ts @@ -0,0 +1,58 @@ +/** + * Tests for the shared templated-HTTP-request assembly. + * + * The request-body matrix (JSON / urlencoded form / raw string / GET-HEAD + * drop) is exercised end-to-end through the ApiNode flow tests; this file + * pins the per-caller record-guard contract of `buildTemplatedHttpRequest`: + * the strict default (the RemoteTool path) versus the loose `isRecordLike` + * guard the ApiNode executor passes, preserving each call site's + * pre-unification behavior for non-plain data objects. + */ +import { describe, expect, it } from "vitest"; +import { isRecordLike } from "../../../src/adapters/common/guards.js"; +import { buildTemplatedHttpRequest } from "../../../src/adapters/common/tools-common.js"; + +class InstancePayload { + a = "1"; +} + +describe("buildTemplatedHttpRequest record guard", () => { + const urlencodedSpec = { + url: "https://api.example.com/form", + httpMethod: "POST", + data: new InstancePayload(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + queryParams: {}, + }; + + it("strict default: a class-instance body is JSON-stringified (RemoteTool path)", () => { + const { init } = buildTemplatedHttpRequest(urlencodedSpec, {}); + expect(init.body).toBe('{"a":"1"}'); + }); + + it("loose isRecordLike: a class-instance body is form-encoded (ApiNode path)", () => { + const { init } = buildTemplatedHttpRequest(urlencodedSpec, {}, { + isRecord: isRecordLike, + }); + expect(init.body).toBeInstanceOf(URLSearchParams); + expect(String(init.body)).toBe("a=1"); + }); + + it("GET empty-body check follows the guard for a keyless class instance", () => { + const spec = { + url: "https://api.example.com/plain", + httpMethod: "GET", + data: new (class {})(), + headers: {}, + queryParams: {}, + }; + // Strict: not a record, so the instance counts as a declared body that + // fetch cannot send on GET. + expect(buildTemplatedHttpRequest(spec, {}).bodyDropped).toBe(true); + // Loose: a keyless record counts as empty, so nothing is dropped. + expect( + buildTemplatedHttpRequest(spec, {}, { isRecord: isRecordLike }) + .bodyDropped, + ).toBe(false); + }); +}); diff --git a/tsagentspec/tests/adapters/langgraph/llm.test.ts b/tsagentspec/tests/adapters/langgraph/llm.test.ts index b8343697..f9454d3f 100644 --- a/tsagentspec/tests/adapters/langgraph/llm.test.ts +++ b/tsagentspec/tests/adapters/langgraph/llm.test.ts @@ -250,6 +250,30 @@ describe("convertLlmConfig for OllamaConfig", () => { expect(model.numPredict).toBeUndefined(); expect(model.topP).toBeUndefined(); }); + + it("does not forward extra generation fields", async () => { + // Only temperature / maxTokens / topP are supported; extra fields in the + // (passthrough) generation config must not reach the constructed model. + const model = (await convertLlmConfig( + createOllamaConfig({ + name: "oll", + modelId: "llama3.1", + url: "http://localhost:11434", + defaultGenerationParameters: { + temperature: 0.2, + maxTokens: 128, + topP: 0.8, + presencePenalty: 1.0, + } as LlmGenerationConfig, + }), + )) as ChatOllama; + expect(model.temperature).toBe(0.2); + expect(model.numPredict).toBe(128); + expect(model.topP).toBe(0.8); + expect( + (model as unknown as { presencePenalty?: number }).presencePenalty, + ).toBeUndefined(); + }); }); describe("convertLlmConfig rejections", () => { diff --git a/tsagentspec/tests/property.test.ts b/tsagentspec/tests/property.test.ts index f4dc5037..f942e8df 100644 --- a/tsagentspec/tests/property.test.ts +++ b/tsagentspec/tests/property.test.ts @@ -600,8 +600,10 @@ describe("propertiesHaveSameType advanced", () => { }); it("should handle schema with non-standard type field", () => { - // When type is neither string nor array (e.g. number), normalizeUnionTypes - // falls back to empty array for types (line 247) + // Python parity: a non-array `type` is wrapped as-is by + // normalizeUnionTypes, so the malformed member {type: 42} stays in the + // union and the schemas compare unequal instead of the malformed value + // being silently dropped. const a = { jsonSchema: { type: 42, anyOf: [{ type: "string" }] }, title: "x", @@ -610,7 +612,8 @@ describe("propertiesHaveSameType advanced", () => { jsonSchema: { anyOf: [{ type: "string" }] }, title: "y", } as any; - expect(propertiesHaveSameType(a, b)).toBe(true); + expect(propertiesHaveSameType(a, b)).toBe(false); + expect(propertiesHaveSameType(a, a)).toBe(true); }); it("should handle normalizeUnionTypes with array and object in type list", () => { From bde0de1b8a2dfb8735c70f155ee59b724c38434d Mon Sep 17 00:00:00 2001 From: Salah Date: Sat, 5 Sep 2026 11:43:23 +0400 Subject: [PATCH 07/14] feat(tsagentspec): add RetryPolicy, url allow-lists, bare LlmConfig, 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. --- tsagentspec/src/auth.ts | 173 ++++++++++++++ tsagentspec/src/component-registry.ts | 16 ++ tsagentspec/src/component.ts | 6 +- tsagentspec/src/flows/nodes/api-node.ts | 10 + tsagentspec/src/flows/nodes/index.ts | 27 ++- tsagentspec/src/index.ts | 28 +++ tsagentspec/src/llms/generic-llm-config.ts | 64 +++++ tsagentspec/src/llms/index.ts | 15 +- tsagentspec/src/llms/oci-genai-config.ts | 3 + tsagentspec/src/llms/ollama-config.ts | 3 + .../src/llms/openai-compatible-config.ts | 3 + tsagentspec/src/llms/openai-config.ts | 3 + tsagentspec/src/llms/vllm-config.ts | 3 + tsagentspec/src/mcp/client-transport.ts | 16 ++ tsagentspec/src/mcp/mcp-tool.ts | 9 + tsagentspec/src/retry-policy.ts | 106 +++++++++ tsagentspec/src/sensitive-field.ts | 2 + .../builtin-deserialization-plugin.ts | 9 +- .../builtin-serialization-plugin.ts | 27 ++- .../serialization/serialization-context.ts | 8 +- .../src/serialization/version-gates.ts | 36 +++ tsagentspec/src/tools/remote-tool.ts | 10 + tsagentspec/src/tools/toolbox.ts | 9 + tsagentspec/tests/auth.test.ts | 218 ++++++++++++++++++ tsagentspec/tests/flows/nodes.test.ts | 26 +++ .../tests/llms/generic-llm-config.test.ts | 129 +++++++++++ tsagentspec/tests/llms/llm-config.test.ts | 83 +++++++ tsagentspec/tests/mcp/mcp-tool.test.ts | 27 +++ tsagentspec/tests/mcp/transport.test.ts | 28 +++ tsagentspec/tests/retry-policy.test.ts | 96 ++++++++ .../tests/serialization/round-trip.test.ts | 167 ++++++++++++++ .../serialization/sensitive-fields.test.ts | 39 ++++ .../tests/serialization/version-gates.test.ts | 170 ++++++++++++++ tsagentspec/tests/tools/remote-tool.test.ts | 30 +++ 34 files changed, 1590 insertions(+), 9 deletions(-) create mode 100644 tsagentspec/src/auth.ts create mode 100644 tsagentspec/src/llms/generic-llm-config.ts create mode 100644 tsagentspec/src/retry-policy.ts create mode 100644 tsagentspec/tests/auth.test.ts create mode 100644 tsagentspec/tests/llms/generic-llm-config.test.ts create mode 100644 tsagentspec/tests/retry-policy.test.ts diff --git a/tsagentspec/src/auth.ts b/tsagentspec/src/auth.ts new file mode 100644 index 00000000..2713f49d --- /dev/null +++ b/tsagentspec/src/auth.ts @@ -0,0 +1,173 @@ +/** + * Auth configuration components (Agent Spec >= 26.1.2). + * + * Port of pyagentspec/auth.py: OAuthConfig / OAuthClientConfig are + * Components; OAuthEndpoints and PKCEPolicy are plain model objects. + */ +import { z } from "zod"; +import { ComponentBaseSchema } from "./component.js"; + +/** + * Explicit OAuth endpoint configuration — non-Component model object. + * + * Use when endpoint discovery is not available or not desired. + */ +export const OAuthEndpointsSchema = z.object({ + /** Authorization endpoint where the user agent is redirected for login and consent. */ + authorizationEndpoint: z.string(), + /** Token endpoint where authorization codes (and refresh tokens) are exchanged. */ + tokenEndpoint: z.string(), + /** Optional endpoint for refresh token requests (token endpoint reused when absent). */ + refreshEndpoint: z.string().nullish(), + /** Optional endpoint for token revocation. */ + revocationEndpoint: z.string().nullish(), + /** Optional OIDC UserInfo endpoint. */ + userinfoEndpoint: z.string().nullish(), +}); + +export type OAuthEndpoints = z.infer; + +/** PKCE challenge methods */ +export const PKCEMethod = { + PLAIN: "plain", + S256: "S256", +} as const; + +export type PKCEMethod = (typeof PKCEMethod)[keyof typeof PKCEMethod]; + +/** + * Policy configuration for Proof Key for Code Exchange (PKCE) — + * non-Component model object. + */ +export const PKCEPolicySchema = z.object({ + /** If true, the runtime must refuse to proceed when PKCE cannot be used. */ + required: z.boolean().default(true), + /** PKCE challenge method. Defaults to "S256". */ + method: z.enum([PKCEMethod.PLAIN, PKCEMethod.S256]).default(PKCEMethod.S256), +}); + +export type PKCEPolicy = z.infer; + +/** How the runtime selects OAuth scopes */ +export const ScopePolicy = { + USE_CHALLENGE_OR_SUPPORTED: "use_challenge_or_supported", + FIXED: "fixed", +} as const; + +export type ScopePolicy = (typeof ScopePolicy)[keyof typeof ScopePolicy]; + +/** + * OAuth client identity / registration configuration (Component). + * + * Supports pre-registered clients (static clientId/clientSecret), Client ID + * Metadata Documents (URL-formatted client id), and dynamic client + * registration (RFC 7591). + */ +export const OAuthClientConfigSchema = ComponentBaseSchema.extend({ + componentType: z.literal("OAuthClientConfig"), + /** Strategy used to obtain client identity. */ + type: z.enum([ + "pre_registered", + "client_id_metadata_document", + "dynamic_registration", + ]), + /** OAuth client identifier (pre-registered clients) — sensitive. */ + clientId: z.string().optional(), + /** OAuth client secret (confidential pre-registered clients) — sensitive. */ + clientSecret: z.string().optional(), + /** Token endpoint authentication method (e.g. "client_secret_basic"). */ + tokenEndpointAuthMethod: z.string().optional(), + /** HTTPS URL used as the OAuth client_id for Client ID Metadata Documents — sensitive. */ + clientIdMetadataUrl: z.string().optional(), + /** Optional dynamic registration endpoint. */ + registrationEndpoint: z.string().optional(), +}); + +export type OAuthClientConfig = z.infer; + +/** + * Configure OAuth-based authentication for a tool or transport (Component). + * + * Supports discovery-based configuration (via `issuer`) and explicit + * endpoints (via `endpoints`). + */ +export const OAuthConfigSchema = ComponentBaseSchema.extend({ + componentType: z.literal("OAuthConfig"), + /** Authorization server issuer URL used for discovery (OIDC or RFC 8414). */ + issuer: z.string().optional(), + /** Explicit OAuth endpoints, used directly instead of discovery when set. */ + endpoints: OAuthEndpointsSchema.optional(), + /** OAuth client identity / registration configuration. */ + client: OAuthClientConfigSchema, + /** Redirect (callback) URI registered with the authorization server. */ + redirectUri: z.string(), + /** Requested scopes, space-delimited string or list of scope strings. */ + scopes: z.union([z.string(), z.array(z.string())]).optional(), + /** How the runtime selects scopes. */ + scopePolicy: z + .enum([ScopePolicy.USE_CHALLENGE_OR_SUPPORTED, ScopePolicy.FIXED]) + .optional(), + /** PKCE policy; authorization code flows should typically require S256. */ + pkce: PKCEPolicySchema.optional(), + /** Optional resource indicator value (RFC 8707). */ + resource: z.string().optional(), +}); + +export type OAuthConfig = z.infer; + +/** Union of auth configs (single member today, mirrors abstract AuthConfig). */ +export type AuthConfig = OAuthConfig; + +/** + * Discriminated union of auth configs. Single member today — it mirrors + * Python's abstract AuthConfig base so future auth schemes slot in here. + * The explicit annotation keeps the declaration-emit type of the component + * schemas embedding it (all remote MCP transports) small. + */ +export const AuthConfigUnion: z.ZodType< + AuthConfig, + z.ZodTypeDef, + z.input +> = z.discriminatedUnion("componentType", [OAuthConfigSchema]); + +export function createOAuthClientConfig(opts: { + name: string; + type: "pre_registered" | "client_id_metadata_document" | "dynamic_registration"; + id?: string; + description?: string; + metadata?: Record; + clientId?: string; + clientSecret?: string; + tokenEndpointAuthMethod?: string; + clientIdMetadataUrl?: string; + registrationEndpoint?: string; +}): OAuthClientConfig { + return Object.freeze( + OAuthClientConfigSchema.parse({ + ...opts, + componentType: "OAuthClientConfig" as const, + }), + ); +} + +export function createOAuthConfig(opts: { + name: string; + client: OAuthClientConfig; + redirectUri: string; + id?: string; + description?: string; + metadata?: Record; + issuer?: string; + endpoints?: OAuthEndpoints; + scopes?: string | string[]; + scopePolicy?: ScopePolicy; + pkce?: z.input; + resource?: string; +}): OAuthConfig { + return Object.freeze( + OAuthConfigSchema.parse({ + ...opts, + componentType: "OAuthConfig" as const, + }), + ); +} diff --git a/tsagentspec/src/component-registry.ts b/tsagentspec/src/component-registry.ts index d31e0a97..5e11e178 100644 --- a/tsagentspec/src/component-registry.ts +++ b/tsagentspec/src/component-registry.ts @@ -19,6 +19,7 @@ import { createAgentSpecializationParameters, } from "./agents/specialized-agent.js"; +import { LlmConfigSchema, createLlmConfig } from "./llms/generic-llm-config.js"; import { OpenAiCompatibleConfigSchema, createOpenAiCompatibleConfig } from "./llms/openai-compatible-config.js"; import { OllamaConfigSchema, createOllamaConfig } from "./llms/ollama-config.js"; import { VllmConfigSchema, createVllmConfig } from "./llms/vllm-config.js"; @@ -129,6 +130,13 @@ import { createConversationSummarizationTransform, } from "./transforms/message-transform.js"; +import { + OAuthConfigSchema, + OAuthClientConfigSchema, + createOAuthConfig, + createOAuthClientConfig, +} from "./auth.js"; + /** Maps componentType string -> Zod schema for that type */ export const BUILTIN_SCHEMA_MAP: Record = { Agent: AgentSchema, @@ -140,6 +148,7 @@ export const BUILTIN_SCHEMA_MAP: Record = { SpecializedAgent: SpecializedAgentSchema, AgentSpecializationParameters: AgentSpecializationParametersSchema, + LlmConfig: LlmConfigSchema, OpenAiCompatibleConfig: OpenAiCompatibleConfigSchema, OllamaConfig: OllamaConfigSchema, VllmConfig: VllmConfigSchema, @@ -193,6 +202,9 @@ export const BUILTIN_SCHEMA_MAP: Record = { MessageSummarizationTransform: MessageSummarizationTransformSchema, ConversationSummarizationTransform: ConversationSummarizationTransformSchema, + + OAuthConfig: OAuthConfigSchema, + OAuthClientConfig: OAuthClientConfigSchema, }; // `any` is required here: factory functions have heterogeneous signatures (each expects @@ -211,6 +223,7 @@ export const BUILTIN_FACTORY_MAP: Record = { SpecializedAgent: createSpecializedAgent, AgentSpecializationParameters: createAgentSpecializationParameters, + LlmConfig: createLlmConfig, OpenAiCompatibleConfig: createOpenAiCompatibleConfig, OllamaConfig: createOllamaConfig, VllmConfig: createVllmConfig, @@ -264,6 +277,9 @@ export const BUILTIN_FACTORY_MAP: Record = { MessageSummarizationTransform: createMessageSummarizationTransform, ConversationSummarizationTransform: createConversationSummarizationTransform, + + OAuthConfig: createOAuthConfig, + OAuthClientConfig: createOAuthClientConfig, }; /** Get the Zod schema for a built-in component type */ diff --git a/tsagentspec/src/component.ts b/tsagentspec/src/component.ts index c5fb92d8..13d062e0 100644 --- a/tsagentspec/src/component.ts +++ b/tsagentspec/src/component.ts @@ -49,7 +49,8 @@ export type AbstractComponentType = | "OciClientConfig" | "ClientTransport" | "Datastore" - | "MessageTransform"; + | "MessageTransform" + | "AuthConfig"; /** All concrete component type string literals */ export type ComponentTypeName = @@ -80,6 +81,7 @@ export type ComponentTypeName = | "BuiltinTool" | "MCPTool" | "MCPToolSpec" + | "LlmConfig" | "OpenAiCompatibleConfig" | "OllamaConfig" | "VllmConfig" @@ -106,5 +108,7 @@ export type ComponentTypeName = | "TlsPostgresDatabaseConnectionConfig" | "A2AConnectionConfig" | "AgentSpecializationParameters" + | "OAuthConfig" + | "OAuthClientConfig" | "MessageSummarizationTransform" | "ConversationSummarizationTransform"; diff --git a/tsagentspec/src/flows/nodes/api-node.ts b/tsagentspec/src/flows/nodes/api-node.ts index 8558575e..bf7f98a8 100644 --- a/tsagentspec/src/flows/nodes/api-node.ts +++ b/tsagentspec/src/flows/nodes/api-node.ts @@ -3,6 +3,7 @@ */ import { z } from "zod"; import type { Property } from "../../property.js"; +import { RetryPolicySchema } from "../../retry-policy.js"; import { getPlaceholderPropertiesFromJsonObject } from "../../templating.js"; import { NodeBaseSchema, DEFAULT_NEXT_BRANCH } from "../node.js"; @@ -17,6 +18,13 @@ export const ApiNodeSchema = NodeBaseSchema.extend({ queryParams: z.record(z.unknown()).default({}), headers: z.record(z.unknown()).default({}), sensitiveHeaders: z.record(z.unknown()).default({}), + /** + * Optional list of allowed URLs or URL prefixes for the rendered request + * URL: scheme and authority match exactly, path by prefix. + */ + urlAllowList: z.array(z.string()).optional(), + /** Optional retry configuration for the API call performed by this node. */ + retryPolicy: RetryPolicySchema.optional(), }); export type ApiNode = z.infer; @@ -51,6 +59,8 @@ export function createApiNode(opts: { queryParams?: Record; headers?: Record; sensitiveHeaders?: Record; + urlAllowList?: string[]; + retryPolicy?: z.input; inputs?: Property[]; outputs?: Property[]; }): ApiNode { diff --git a/tsagentspec/src/flows/nodes/index.ts b/tsagentspec/src/flows/nodes/index.ts index 1bd5ce9d..fc96a6e0 100644 --- a/tsagentspec/src/flows/nodes/index.ts +++ b/tsagentspec/src/flows/nodes/index.ts @@ -18,8 +18,33 @@ import { OutputMessageNodeSchema } from "./output-message-node.js"; import { CatchExceptionNodeSchema } from "./catch-exception-node.js"; import { registerNodeUnionSchema } from "../lazy-schemas.js"; +/** + * Explicit annotation: the inferred type exceeds the declaration-emit size + * limit (TS7056); spelling it out keeps the emitted type symbolic while + * preserving the discriminated-union surface (`.options` etc.). + */ +type NodeUnionSchema = z.ZodDiscriminatedUnion< + "componentType", + [ + typeof StartNodeSchema, + typeof EndNodeSchema, + typeof LlmNodeSchema, + typeof ToolNodeSchema, + typeof AgentNodeSchema, + typeof FlowNodeSchema, + typeof BranchingNodeSchema, + typeof MapNodeSchema, + typeof ParallelMapNodeSchema, + typeof ParallelFlowNodeSchema, + typeof ApiNodeSchema, + typeof InputMessageNodeSchema, + typeof OutputMessageNodeSchema, + typeof CatchExceptionNodeSchema, + ] +>; + /** Discriminated union of all node types */ -export const NodeUnion = z.discriminatedUnion("componentType", [ +export const NodeUnion: NodeUnionSchema = z.discriminatedUnion("componentType", [ StartNodeSchema, EndNodeSchema, LlmNodeSchema, diff --git a/tsagentspec/src/index.ts b/tsagentspec/src/index.ts index 854ff538..912e3dac 100644 --- a/tsagentspec/src/index.ts +++ b/tsagentspec/src/index.ts @@ -57,6 +57,31 @@ export { // Sensitive field handling export { SENSITIVE_FIELDS, isSensitiveField } from "./sensitive-field.js"; +// Retry policy +export { + RetryPolicySchema, + RetryJitter, + type RetryPolicy, +} from "./retry-policy.js"; + +// Auth configs +export { + AuthConfigUnion, + OAuthConfigSchema, + OAuthClientConfigSchema, + OAuthEndpointsSchema, + PKCEPolicySchema, + PKCEMethod, + ScopePolicy, + createOAuthConfig, + createOAuthClientConfig, + type AuthConfig, + type OAuthConfig, + type OAuthClientConfig, + type OAuthEndpoints, + type PKCEPolicy, +} from "./auth.js"; + // LLM configs export { LlmConfigUnion, @@ -64,6 +89,9 @@ export { OpenAIAPIType, type LlmConfig, type LlmGenerationConfig, + LlmConfigSchema, + createLlmConfig, + type GenericLlmConfig, OpenAiCompatibleConfigSchema, createOpenAiCompatibleConfig, type OpenAiCompatibleConfig, diff --git a/tsagentspec/src/llms/generic-llm-config.ts b/tsagentspec/src/llms/generic-llm-config.ts new file mode 100644 index 00000000..377d3d68 --- /dev/null +++ b/tsagentspec/src/llms/generic-llm-config.ts @@ -0,0 +1,64 @@ +/** + * Bare LlmConfig — provider-agnostic LLM connection config (Agent Spec >= 26.1.2). + * + * Describes any LLM without a dedicated subclass: `apiProvider` selects the + * serving API a runtime dispatches on to pick a client (e.g. "openai", + * "oci", "vllm"), `provider` names the model maker (e.g. "meta", "openai", + * "cohere"), and `apiType` picks the wire protocol (e.g. "chat_completions", + * "responses"). All three are free-form strings here; the dedicated config + * components pin them instead. + * + * The wire component_type is "LlmConfig" (matching Python's now-concrete + * base class); the TS type is exported as GenericLlmConfig because the + * public `LlmConfig` type name is already taken by the union of all LLM + * config components. Configs ported later (e.g. GeminiConfig, + * DbmsVectorChainLlmConfig) must also carry `retryPolicy`. + */ +import { z } from "zod"; +import { ComponentBaseSchema } from "../component.js"; +import { LlmGenerationConfigSchema } from "./llm-config.js"; +import { RetryPolicySchema } from "../retry-policy.js"; + +export const LlmConfigSchema = ComponentBaseSchema.extend({ + componentType: z.literal("LlmConfig"), + /** Identifier of the model to use, as expected by the selected API provider. */ + modelId: z.string(), + /** The provider of the model (e.g. "meta", "openai", "cohere"). */ + provider: z.string().optional(), + /** The API provider used to serve the model (e.g. "openai", "oci", "vllm"). */ + apiProvider: z.string().optional(), + /** The API format to use (e.g. "chat_completions", "responses"). */ + apiType: z.string().optional(), + /** URL of the API endpoint (e.g. "https://api.openai.com/v1"). */ + url: z.string().optional(), + /** Optional API key for the remote LLM model — sensitive, never serialized. */ + apiKey: z.string().optional(), + /** Parameters used for the generation call of this LLM. */ + defaultGenerationParameters: LlmGenerationConfigSchema.optional(), + /** Optional retry configuration for remote LLM calls. */ + retryPolicy: RetryPolicySchema.optional(), +}); + +export type GenericLlmConfig = z.infer; + +export function createLlmConfig(opts: { + name: string; + modelId: string; + id?: string; + description?: string; + metadata?: Record; + provider?: string; + apiProvider?: string; + apiType?: string; + url?: string; + apiKey?: string; + defaultGenerationParameters?: z.infer; + retryPolicy?: z.input; +}): GenericLlmConfig { + return Object.freeze( + LlmConfigSchema.parse({ + ...opts, + componentType: "LlmConfig" as const, + }), + ); +} diff --git a/tsagentspec/src/llms/index.ts b/tsagentspec/src/llms/index.ts index 1d9a546b..f434cca8 100644 --- a/tsagentspec/src/llms/index.ts +++ b/tsagentspec/src/llms/index.ts @@ -2,14 +2,21 @@ * LLM config types barrel export. */ import { z } from "zod"; +import { LlmConfigSchema } from "./generic-llm-config.js"; import { OpenAiCompatibleConfigSchema } from "./openai-compatible-config.js"; import { OllamaConfigSchema } from "./ollama-config.js"; import { VllmConfigSchema } from "./vllm-config.js"; import { OpenAiConfigSchema } from "./openai-config.js"; import { OciGenAiConfigSchema } from "./oci-genai-config.js"; -/** Discriminated union of all LLM config types */ +/** + * Discriminated union of all LLM config types. The bare LlmConfig component + * (wire component_type "LlmConfig") is a member; the union keeps the public + * `LlmConfig` type name, so the bare component's TS type is exported as + * `GenericLlmConfig`. + */ export const LlmConfigUnion = z.discriminatedUnion("componentType", [ + LlmConfigSchema, OpenAiCompatibleConfigSchema, OllamaConfigSchema, VllmConfigSchema, @@ -25,6 +32,12 @@ export { type LlmGenerationConfig, } from "./llm-config.js"; +export { + LlmConfigSchema, + createLlmConfig, + type GenericLlmConfig, +} from "./generic-llm-config.js"; + export { OpenAiCompatibleConfigSchema, createOpenAiCompatibleConfig, diff --git a/tsagentspec/src/llms/oci-genai-config.ts b/tsagentspec/src/llms/oci-genai-config.ts index 817b4e92..99781f01 100644 --- a/tsagentspec/src/llms/oci-genai-config.ts +++ b/tsagentspec/src/llms/oci-genai-config.ts @@ -3,6 +3,7 @@ */ import { z } from "zod"; import { ComponentBaseSchema } from "../component.js"; +import { RetryPolicySchema } from "../retry-policy.js"; import { LlmGenerationConfigSchema } from "./llm-config.js"; import { OciClientConfigUnion, type OciClientConfig } from "./oci-client-config.js"; @@ -58,6 +59,7 @@ export const OciGenAiConfigSchema = ComponentBaseSchema.extend({ .default(OciAPIType.OCI), conversationStoreId: z.string().optional(), defaultGenerationParameters: LlmGenerationConfigSchema.optional(), + retryPolicy: RetryPolicySchema.optional(), }); export type OciGenAiConfig = z.infer; @@ -75,6 +77,7 @@ export function createOciGenAiConfig(opts: { apiType?: OciAPIType; conversationStoreId?: string; defaultGenerationParameters?: z.infer; + retryPolicy?: z.input; }): OciGenAiConfig { return Object.freeze( OciGenAiConfigSchema.parse({ diff --git a/tsagentspec/src/llms/ollama-config.ts b/tsagentspec/src/llms/ollama-config.ts index a6ce241f..7dc8a738 100644 --- a/tsagentspec/src/llms/ollama-config.ts +++ b/tsagentspec/src/llms/ollama-config.ts @@ -3,6 +3,7 @@ */ import { z } from "zod"; import { ComponentBaseSchema } from "../component.js"; +import { RetryPolicySchema } from "../retry-policy.js"; import { LlmGenerationConfigSchema, OpenAIAPIType } from "./llm-config.js"; export const OllamaConfigSchema = ComponentBaseSchema.extend({ @@ -14,6 +15,7 @@ export const OllamaConfigSchema = ComponentBaseSchema.extend({ .default(OpenAIAPIType.CHAT_COMPLETIONS), defaultGenerationParameters: LlmGenerationConfigSchema.optional(), apiKey: z.string().optional(), + retryPolicy: RetryPolicySchema.optional(), }); export type OllamaConfig = z.infer; @@ -28,6 +30,7 @@ export function createOllamaConfig(opts: { apiType?: OpenAIAPIType; defaultGenerationParameters?: z.infer; apiKey?: string; + retryPolicy?: z.input; }): OllamaConfig { const parsed = OllamaConfigSchema.parse({ ...opts, diff --git a/tsagentspec/src/llms/openai-compatible-config.ts b/tsagentspec/src/llms/openai-compatible-config.ts index 3098cd5d..fc468cfc 100644 --- a/tsagentspec/src/llms/openai-compatible-config.ts +++ b/tsagentspec/src/llms/openai-compatible-config.ts @@ -3,6 +3,7 @@ */ import { z } from "zod"; import { ComponentBaseSchema } from "../component.js"; +import { RetryPolicySchema } from "../retry-policy.js"; import { LlmGenerationConfigSchema, OpenAIAPIType } from "./llm-config.js"; export const OpenAiCompatibleConfigSchema = ComponentBaseSchema.extend({ @@ -14,6 +15,7 @@ export const OpenAiCompatibleConfigSchema = ComponentBaseSchema.extend({ .default(OpenAIAPIType.CHAT_COMPLETIONS), defaultGenerationParameters: LlmGenerationConfigSchema.optional(), apiKey: z.string().optional(), + retryPolicy: RetryPolicySchema.optional(), }); export type OpenAiCompatibleConfig = z.infer< @@ -30,6 +32,7 @@ export function createOpenAiCompatibleConfig(opts: { apiType?: OpenAIAPIType; defaultGenerationParameters?: z.infer; apiKey?: string; + retryPolicy?: z.input; }): OpenAiCompatibleConfig { const raw = { ...opts, diff --git a/tsagentspec/src/llms/openai-config.ts b/tsagentspec/src/llms/openai-config.ts index 4355fb38..5fc99833 100644 --- a/tsagentspec/src/llms/openai-config.ts +++ b/tsagentspec/src/llms/openai-config.ts @@ -3,6 +3,7 @@ */ import { z } from "zod"; import { ComponentBaseSchema } from "../component.js"; +import { RetryPolicySchema } from "../retry-policy.js"; import { LlmGenerationConfigSchema, OpenAIAPIType } from "./llm-config.js"; export const OpenAiConfigSchema = ComponentBaseSchema.extend({ @@ -13,6 +14,7 @@ export const OpenAiConfigSchema = ComponentBaseSchema.extend({ .default(OpenAIAPIType.CHAT_COMPLETIONS), defaultGenerationParameters: LlmGenerationConfigSchema.optional(), apiKey: z.string().optional(), + retryPolicy: RetryPolicySchema.optional(), }); export type OpenAiConfig = z.infer; @@ -26,6 +28,7 @@ export function createOpenAiConfig(opts: { apiType?: OpenAIAPIType; defaultGenerationParameters?: z.infer; apiKey?: string; + retryPolicy?: z.input; }): OpenAiConfig { const parsed = OpenAiConfigSchema.parse({ ...opts, diff --git a/tsagentspec/src/llms/vllm-config.ts b/tsagentspec/src/llms/vllm-config.ts index a55b2af2..795af686 100644 --- a/tsagentspec/src/llms/vllm-config.ts +++ b/tsagentspec/src/llms/vllm-config.ts @@ -3,6 +3,7 @@ */ import { z } from "zod"; import { ComponentBaseSchema } from "../component.js"; +import { RetryPolicySchema } from "../retry-policy.js"; import { LlmGenerationConfigSchema, OpenAIAPIType } from "./llm-config.js"; export const VllmConfigSchema = ComponentBaseSchema.extend({ @@ -14,6 +15,7 @@ export const VllmConfigSchema = ComponentBaseSchema.extend({ .default(OpenAIAPIType.CHAT_COMPLETIONS), defaultGenerationParameters: LlmGenerationConfigSchema.optional(), apiKey: z.string().optional(), + retryPolicy: RetryPolicySchema.optional(), }); export type VllmConfig = z.infer; @@ -28,6 +30,7 @@ export function createVllmConfig(opts: { apiType?: OpenAIAPIType; defaultGenerationParameters?: z.infer; apiKey?: string; + retryPolicy?: z.input; }): VllmConfig { const parsed = VllmConfigSchema.parse({ ...opts, diff --git a/tsagentspec/src/mcp/client-transport.ts b/tsagentspec/src/mcp/client-transport.ts index 1570d5a5..128ca931 100644 --- a/tsagentspec/src/mcp/client-transport.ts +++ b/tsagentspec/src/mcp/client-transport.ts @@ -2,7 +2,9 @@ * MCP client transport types. */ import { z } from "zod"; +import { AuthConfigUnion, type AuthConfig } from "../auth.js"; import { ComponentBaseSchema } from "../component.js"; +import { RetryPolicySchema } from "../retry-policy.js"; const SessionParametersSchema = z.object({ readTimeoutSeconds: z.number().default(60.0), @@ -24,8 +26,12 @@ export type StdioTransport = z.infer; const RemoteTransportBaseSchema = ClientTransportBaseSchema.extend({ url: z.string(), + /** Auth configuration used to authenticate requests to the remote MCP server. */ + auth: AuthConfigUnion.optional(), headers: z.record(z.string()).optional(), sensitiveHeaders: z.record(z.string()).optional(), + /** Optional retry configuration for requests sent through this remote transport. */ + retryPolicy: RetryPolicySchema.optional(), }); export const SSETransportSchema = RemoteTransportBaseSchema.extend({ @@ -105,8 +111,10 @@ export function createSSETransport(opts: { id?: string; description?: string; metadata?: Record; + auth?: AuthConfig; headers?: Record; sensitiveHeaders?: Record; + retryPolicy?: z.input; sessionParameters?: { readTimeoutSeconds?: number }; }): SSETransport { return Object.freeze( @@ -126,8 +134,10 @@ export function createSSEmTLSTransport(opts: { id?: string; description?: string; metadata?: Record; + auth?: AuthConfig; headers?: Record; sensitiveHeaders?: Record; + retryPolicy?: z.input; sessionParameters?: { readTimeoutSeconds?: number }; }): SSEmTLSTransport { return Object.freeze( @@ -144,8 +154,10 @@ export function createStreamableHTTPTransport(opts: { id?: string; description?: string; metadata?: Record; + auth?: AuthConfig; headers?: Record; sensitiveHeaders?: Record; + retryPolicy?: z.input; sessionParameters?: { readTimeoutSeconds?: number }; }): StreamableHTTPTransport { return Object.freeze( @@ -165,8 +177,10 @@ export function createStreamableHTTPmTLSTransport(opts: { id?: string; description?: string; metadata?: Record; + auth?: AuthConfig; headers?: Record; sensitiveHeaders?: Record; + retryPolicy?: z.input; sessionParameters?: { readTimeoutSeconds?: number }; }): StreamableHTTPmTLSTransport { return Object.freeze( @@ -183,8 +197,10 @@ export function createRemoteTransport(opts: { id?: string; description?: string; metadata?: Record; + auth?: AuthConfig; headers?: Record; sensitiveHeaders?: Record; + retryPolicy?: z.input; sessionParameters?: { readTimeoutSeconds?: number }; }): RemoteTransport { return Object.freeze( diff --git a/tsagentspec/src/mcp/mcp-tool.ts b/tsagentspec/src/mcp/mcp-tool.ts index 8cce2488..292f6f75 100644 --- a/tsagentspec/src/mcp/mcp-tool.ts +++ b/tsagentspec/src/mcp/mcp-tool.ts @@ -4,12 +4,20 @@ import { z } from "zod"; import { ComponentWithIOSchema } from "../component.js"; import type { Property } from "../property.js"; +import { RetryPolicySchema } from "../retry-policy.js"; import { ToolBaseSchema } from "../tools/tool.js"; import { ClientTransportUnion, type ClientTransport } from "./client-transport.js"; export const MCPToolSchema = ToolBaseSchema.extend({ componentType: z.literal("MCPTool"), clientTransport: ClientTransportUnion, + /** + * Optional retry configuration for semantic MCP tool resolution and + * execution. Only the attempt and backoff fields apply to this semantic + * retry; transport request timeout and HTTP status retry fields belong to + * retry policies on remote MCP transports. + */ + retryPolicy: RetryPolicySchema.optional(), }); export type MCPTool = z.infer; @@ -20,6 +28,7 @@ export function createMCPTool(opts: { id?: string; description?: string; metadata?: Record; + retryPolicy?: z.input; inputs?: Property[]; outputs?: Property[]; requiresConfirmation?: boolean; diff --git a/tsagentspec/src/retry-policy.ts b/tsagentspec/src/retry-policy.ts new file mode 100644 index 00000000..fe012ab9 --- /dev/null +++ b/tsagentspec/src/retry-policy.ts @@ -0,0 +1,106 @@ +/** + * Retry configuration shared across networked components. + * + * Agent Spec treats RetryPolicy as a non-Component configuration object + * (like LlmGenerationConfig): it has no id/name/componentType and is nested + * inline in the components that carry it. + */ +import { z } from "zod"; + +/** Jitter methods for retry backoff */ +export const RetryJitter = { + EQUAL: "equal", + FULL: "full", + FULL_AND_EQUAL_FOR_THROTTLE: "full_and_equal_for_throttle", + DECORRELATED: "decorrelated", +} as const; + +export type RetryJitter = (typeof RetryJitter)[keyof typeof RetryJitter]; + +/** Parsed retry policy (all defaults applied). */ +export interface RetryPolicy { + maxAttempts: number; + requestTimeout: number | null; + initialRetryDelay: number; + maxRetryDelay: number; + backoffFactor: number; + jitter: RetryJitter | null; + serviceErrorRetryOnAny5xx: boolean; + recoverableStatuses: Record; +} + +/** Retry policy input (every field defaulted, so all are optional). */ +export interface RetryPolicyInput { + maxAttempts?: number; + requestTimeout?: number | null; + initialRetryDelay?: number; + maxRetryDelay?: number; + backoffFactor?: number; + jitter?: RetryJitter | null; + serviceErrorRetryOnAny5xx?: boolean; + recoverableStatuses?: Record; +} + +// The explicit annotation keeps the schema's declaration-emit type small: +// the ZodEffects-of-strict-object type is referenced by many component +// schemas and would otherwise blow up their inferred types. +export const RetryPolicySchema: z.ZodType< + RetryPolicy, + z.ZodTypeDef, + RetryPolicyInput +> = z + .object({ + /** Maximum number of retries (not counting the initial attempt). */ + maxAttempts: z.number().int().min(0).default(2), + /** Per-attempt timeout in seconds (fractional values allowed). */ + requestTimeout: z.number().gt(0).nullish().default(null), + /** Base delay (seconds) used for exponential backoff. */ + initialRetryDelay: z.number().min(0).default(1.0), + /** Cap (seconds) on the backoff delay between two retries. */ + maxRetryDelay: z.number().min(0).default(8.0), + /** Back-off factor controlling how retry delays grow between attempts. */ + backoffFactor: z.number().gt(0).default(2.0), + /** Method to add randomness to the retry time (null disables jitter). */ + jitter: z + .enum([ + RetryJitter.EQUAL, + RetryJitter.FULL, + RetryJitter.FULL_AND_EQUAL_FOR_THROTTLE, + RetryJitter.DECORRELATED, + ]) + .nullish() + .default(RetryJitter.FULL_AND_EQUAL_FOR_THROTTLE), + /** Whether to retry on all 5xx errors (network errors, except 501). */ + serviceErrorRetryOnAny5xx: z.boolean().default(true), + /** + * Additional statuses considered recoverable. Keys are HTTP status + * strings (Agent Spec configurations are JSON, so object keys are + * strings); they are emitted verbatim on the wire. + */ + recoverableStatuses: z + .record(z.array(z.string())) + .default({ "409": [], "429": [] }), + }) + .strict() // mirrors Python's extra="forbid" + .superRefine((val, ctx) => { + if (val.maxRetryDelay < val.initialRetryDelay) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "`max_retry_delay` must be greater than or equal to `initial_retry_delay`.", + }); + } + }); + +/** + * camelCase -> exact wire key overrides for RetryPolicy fields whose Python + * wire names the generic camelToSnake converter cannot produce + * ("serviceErrorRetryOnAny5xx" would become "service_error_retry_on_any5xx" + * instead of Python's "service_error_retry_on_any_5xx"). snakeToCamel + * already maps the wire name back correctly, so only serialization needs it. + */ +export const RETRY_POLICY_WIRE_KEY_OVERRIDES: Readonly< + Record +> = { + serviceErrorRetryOnAny5xx: "service_error_retry_on_any_5xx", +}; diff --git a/tsagentspec/src/sensitive-field.ts b/tsagentspec/src/sensitive-field.ts index a7b2f0a3..e0b2d208 100644 --- a/tsagentspec/src/sensitive-field.ts +++ b/tsagentspec/src/sensitive-field.ts @@ -7,6 +7,7 @@ export const SENSITIVE_FIELD_MARKER = "SENSITIVE_FIELD_MARKER" as const; /** Maps componentType -> set of field names that are sensitive */ export const SENSITIVE_FIELDS = { + LlmConfig: new Set(["apiKey"]), OpenAiCompatibleConfig: new Set(["apiKey"]), OllamaConfig: new Set(["apiKey"]), VllmConfig: new Set(["apiKey"]), @@ -33,6 +34,7 @@ export const SENSITIVE_FIELDS = { "password", "sslkey", ]), + OAuthClientConfig: new Set(["clientId", "clientSecret", "clientIdMetadataUrl"]), } satisfies Partial>>; /** Check if a field on a component type is sensitive */ diff --git a/tsagentspec/src/serialization/builtin-deserialization-plugin.ts b/tsagentspec/src/serialization/builtin-deserialization-plugin.ts index e3041d24..5d946aa1 100644 --- a/tsagentspec/src/serialization/builtin-deserialization-plugin.ts +++ b/tsagentspec/src/serialization/builtin-deserialization-plugin.ts @@ -32,8 +32,15 @@ const PROPERTY_ARRAY_FIELDS = new Set(["inputs", "outputs"]); /** * Fields (camelCase) whose object values are model objects with snake_case keys * that need conversion. All other object values are user data with preserved keys. + * (RetryPolicy's "service_error_retry_on_any_5xx" needs no special-casing here: + * snakeToCamel maps it to "serviceErrorRetryOnAny5xx" correctly.) */ -const MODEL_OBJECT_FIELDS = new Set(["defaultGenerationParameters"]); +const MODEL_OBJECT_FIELDS = new Set([ + "defaultGenerationParameters", + "retryPolicy", + "endpoints", // OAuthEndpoints + "pkce", // PKCEPolicy +]); /** Deserialize a jsonSchema dict into a Property */ function deserializeProperty(value: unknown): Property { diff --git a/tsagentspec/src/serialization/builtin-serialization-plugin.ts b/tsagentspec/src/serialization/builtin-serialization-plugin.ts index 93a47897..e7d4e12c 100644 --- a/tsagentspec/src/serialization/builtin-serialization-plugin.ts +++ b/tsagentspec/src/serialization/builtin-serialization-plugin.ts @@ -6,6 +6,7 @@ */ import { BUILTIN_SCHEMA_MAP } from "../component-registry.js"; import type { ComponentBase } from "../component.js"; +import { RETRY_POLICY_WIRE_KEY_OVERRIDES } from "../retry-policy.js"; import { isSensitiveField } from "../sensitive-field.js"; import type { ComponentSerializationPlugin } from "./serialization-plugin.js"; import type { SerializationContext } from "./serialization-context.js"; @@ -14,12 +15,28 @@ import { OPAQUE_FIELDS, sanitizeOpaqueField, type SerializedFields } from "./typ /** Fields that are internal and should not appear in serialized output */ const EXCLUDED_FIELDS = new Set(["componentType"]); +/** Per-field serialization config for model-object fields. */ +interface ModelObjectFieldConfig { + /** Whether null/undefined entries are dropped from the dump. */ + excludeNulls: boolean; + /** camelCase key -> exact wire key, for names camelToSnake cannot produce. */ + keyOverrides?: Record; +} + /** * Fields that contain model objects (not components, not user data) that need - * their keys converted to snake_case. Maps fieldName -> whether to exclude nulls. + * their keys converted to snake_case. */ -const MODEL_OBJECT_FIELDS: Record = { - defaultGenerationParameters: true, // LlmGenerationConfig - exclude nulls +const MODEL_OBJECT_FIELDS: Record = { + defaultGenerationParameters: { excludeNulls: true }, // LlmGenerationConfig + // RetryPolicy / OAuthEndpoints / PKCEPolicy match Python's model_dump, + // which keeps null values. + retryPolicy: { + excludeNulls: false, + keyOverrides: RETRY_POLICY_WIRE_KEY_OVERRIDES as Record, + }, + endpoints: { excludeNulls: false }, // OAuthEndpoints + pkce: { excludeNulls: false }, // PKCEPolicy }; function hasSerializedSensitiveValue(value: unknown): boolean { @@ -85,9 +102,11 @@ export class BuiltinsComponentSerializationPlugin fieldValue !== null && !Array.isArray(fieldValue) ) { + const modelObjectConfig = MODEL_OBJECT_FIELDS[fieldName]!; serialized[snakeName] = context.dumpModelObject( fieldValue as Record, - MODEL_OBJECT_FIELDS[fieldName]!, + modelObjectConfig.excludeNulls, + modelObjectConfig.keyOverrides, ); continue; } diff --git a/tsagentspec/src/serialization/serialization-context.ts b/tsagentspec/src/serialization/serialization-context.ts index b8aed4ce..c0ceaea7 100644 --- a/tsagentspec/src/serialization/serialization-context.ts +++ b/tsagentspec/src/serialization/serialization-context.ts @@ -172,16 +172,22 @@ export class SerializationContext { * Serialize a LlmGenerationConfig-like object. * Converts keys to snake_case and excludes null/undefined values. * Called by the builtin serialization plugin for known model fields. + * `keyOverrides` maps camelCase keys to exact wire names for the few keys + * the generic converter cannot produce (e.g. RetryPolicy's + * "service_error_retry_on_any_5xx"). */ dumpModelObject( obj: Record, excludeNulls: boolean, + keyOverrides?: Record, ): Record { const result: Record = {}; for (const [key, value] of Object.entries(obj)) { if (DANGEROUS_KEYS.has(key)) continue; if (excludeNulls && (value === null || value === undefined)) continue; - const outKey = this.camelCase ? key : camelToSnake(key); + const outKey = this.camelCase + ? key + : keyOverrides?.[key] ?? camelToSnake(key); result[outKey] = this.dumpField(value); } return result; diff --git a/tsagentspec/src/serialization/version-gates.ts b/tsagentspec/src/serialization/version-gates.ts index 3798c48c..9cda81b9 100644 --- a/tsagentspec/src/serialization/version-gates.ts +++ b/tsagentspec/src/serialization/version-gates.ts @@ -26,9 +26,12 @@ export const VERSION_GATED_FIELDS = { RemoteTool: { requiresConfirmation: AgentSpecVersion.V25_4_2, sensitiveHeaders: AgentSpecVersion.V25_4_2, + urlAllowList: AgentSpecVersion.V26_1_2, + retryPolicy: AgentSpecVersion.V26_1_2, }, MCPTool: { requiresConfirmation: AgentSpecVersion.V25_4_2, + retryPolicy: AgentSpecVersion.V26_3_0, }, BuiltinTool: { _self: AgentSpecVersion.V25_4_2, @@ -50,33 +53,66 @@ export const VERSION_GATED_FIELDS = { }, OpenAiConfig: { apiType: AgentSpecVersion.V25_4_2, + retryPolicy: AgentSpecVersion.V26_1_2, }, OpenAiCompatibleConfig: { apiType: AgentSpecVersion.V25_4_2, + retryPolicy: AgentSpecVersion.V26_1_2, }, OciGenAiConfig: { apiType: AgentSpecVersion.V25_4_2, + retryPolicy: AgentSpecVersion.V26_1_2, + }, + OllamaConfig: { + retryPolicy: AgentSpecVersion.V26_1_2, + }, + VllmConfig: { + retryPolicy: AgentSpecVersion.V26_1_2, + }, + // Bare LlmConfig was abstract before 26.1.2; the whole component is gated. + LlmConfig: { + _self: AgentSpecVersion.V26_1_2, + }, + OAuthConfig: { + _self: AgentSpecVersion.V26_1_2, + }, + OAuthClientConfig: { + _self: AgentSpecVersion.V26_1_2, }, ApiNode: { sensitiveHeaders: AgentSpecVersion.V25_4_2, + urlAllowList: AgentSpecVersion.V26_1_2, + retryPolicy: AgentSpecVersion.V26_1_2, }, SSETransport: { sensitiveHeaders: AgentSpecVersion.V25_4_2, + auth: AgentSpecVersion.V26_1_2, + retryPolicy: AgentSpecVersion.V26_1_2, }, SSEmTLSTransport: { sensitiveHeaders: AgentSpecVersion.V25_4_2, + auth: AgentSpecVersion.V26_1_2, + retryPolicy: AgentSpecVersion.V26_1_2, }, StreamableHTTPTransport: { sensitiveHeaders: AgentSpecVersion.V25_4_2, + auth: AgentSpecVersion.V26_1_2, + retryPolicy: AgentSpecVersion.V26_1_2, }, StreamableHTTPmTLSTransport: { sensitiveHeaders: AgentSpecVersion.V25_4_2, + auth: AgentSpecVersion.V26_1_2, + retryPolicy: AgentSpecVersion.V26_1_2, }, RemoteTransport: { sensitiveHeaders: AgentSpecVersion.V25_4_2, + auth: AgentSpecVersion.V26_1_2, + retryPolicy: AgentSpecVersion.V26_1_2, }, MCPToolBox: { _self: AgentSpecVersion.V25_4_2, requiresConfirmation: AgentSpecVersion.V26_2_0, + // Semantic MCP retry landed as 26.3.0 (Python has no 26.2.0 member). + retryPolicy: AgentSpecVersion.V26_3_0, }, } satisfies Partial>>; diff --git a/tsagentspec/src/tools/remote-tool.ts b/tsagentspec/src/tools/remote-tool.ts index 8b8a9e72..b131d62d 100644 --- a/tsagentspec/src/tools/remote-tool.ts +++ b/tsagentspec/src/tools/remote-tool.ts @@ -3,6 +3,7 @@ */ import { z } from "zod"; import type { Property } from "../property.js"; +import { RetryPolicySchema } from "../retry-policy.js"; import { getPlaceholderPropertiesFromJsonObject } from "../templating.js"; import { ToolBaseSchema } from "./tool.js"; @@ -15,6 +16,13 @@ export const RemoteToolSchema = ToolBaseSchema.extend({ queryParams: z.record(z.unknown()).default({}), headers: z.record(z.unknown()).default({}), sensitiveHeaders: z.record(z.unknown()).default({}), + /** + * Optional list of allowed URLs or URL prefixes for the rendered request + * URL: scheme and authority match exactly, path by prefix. + */ + urlAllowList: z.array(z.string()).optional(), + /** Optional retry configuration for the HTTP call performed by this tool. */ + retryPolicy: RetryPolicySchema.optional(), }); export type RemoteTool = z.infer; @@ -49,6 +57,8 @@ export function createRemoteTool(opts: { queryParams?: Record; headers?: Record; sensitiveHeaders?: Record; + urlAllowList?: string[]; + retryPolicy?: z.input; inputs?: Property[]; outputs?: Property[]; requiresConfirmation?: boolean; diff --git a/tsagentspec/src/tools/toolbox.ts b/tsagentspec/src/tools/toolbox.ts index f17930e7..a263fccc 100644 --- a/tsagentspec/src/tools/toolbox.ts +++ b/tsagentspec/src/tools/toolbox.ts @@ -5,10 +5,18 @@ import { z } from "zod"; import { ComponentBaseSchema } from "../component.js"; import { ClientTransportUnion, type ClientTransport } from "../mcp/client-transport.js"; import { MCPToolSpecSchema } from "../mcp/mcp-tool.js"; +import { RetryPolicySchema } from "../retry-policy.js"; export const MCPToolBoxSchema = ComponentBaseSchema.extend({ componentType: z.literal("MCPToolBox"), clientTransport: ClientTransportUnion, + /** + * Optional retry configuration for semantic MCP toolbox discovery and + * generated tool execution. Only the attempt and backoff fields apply to + * this semantic retry; transport request timeout and HTTP status retry + * fields belong to retry policies on remote MCP transports. + */ + retryPolicy: RetryPolicySchema.optional(), toolFilter: z .array(z.union([MCPToolSpecSchema, z.string()])) .optional(), @@ -23,6 +31,7 @@ export function createMCPToolBox(opts: { id?: string; description?: string; metadata?: Record; + retryPolicy?: z.input; toolFilter?: Array | string>; requiresConfirmation?: boolean; }): MCPToolBox { diff --git a/tsagentspec/tests/auth.test.ts b/tsagentspec/tests/auth.test.ts new file mode 100644 index 00000000..781caefd --- /dev/null +++ b/tsagentspec/tests/auth.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect } from "vitest"; +import { + AgentSpecSerializer, + AgentSpecDeserializer, + AgentSpecVersion, + AuthConfigUnion, + PKCEMethod, + ScopePolicy, + createOAuthClientConfig, + createOAuthConfig, + createSSETransport, + type OAuthConfig, + type SSETransport, +} from "../src/index.js"; + +function makeOAuthConfig(): OAuthConfig { + return createOAuthConfig({ + id: "oauth", + name: "OAuth", + issuer: "https://issuer.example.com", + endpoints: { + authorizationEndpoint: "https://issuer.example.com/auth", + tokenEndpoint: "https://issuer.example.com/token", + }, + client: createOAuthClientConfig({ + id: "client", + name: "OAuthClientConfig", + type: "pre_registered", + clientId: "client_id", + clientSecret: "client_secret", + }), + redirectUri: "https://app.example.com/callback", + scopes: ["openid", "profile"], + pkce: { required: true, method: PKCEMethod.S256 }, + }); +} + +function makeTransportWithOAuth(): SSETransport { + return createSSETransport({ + id: "transport", + name: "SSETransport", + url: "https://mcp.example.com", + auth: makeOAuthConfig(), + }); +} + +describe("OAuthClientConfig", () => { + it("should create a pre-registered client", () => { + const client = createOAuthClientConfig({ + name: "client", + type: "pre_registered", + clientId: "id", + clientSecret: "secret", + tokenEndpointAuthMethod: "client_secret_basic", + }); + expect(client.componentType).toBe("OAuthClientConfig"); + expect(client.type).toBe("pre_registered"); + expect(client.clientId).toBe("id"); + expect(client.clientSecret).toBe("secret"); + expect(client.tokenEndpointAuthMethod).toBe("client_secret_basic"); + expect(Object.isFrozen(client)).toBe(true); + }); + + it("should create a dynamic-registration client", () => { + const client = createOAuthClientConfig({ + name: "client", + type: "dynamic_registration", + registrationEndpoint: "https://issuer.example.com/register", + }); + expect(client.type).toBe("dynamic_registration"); + expect(client.registrationEndpoint).toBe( + "https://issuer.example.com/register", + ); + }); + + it("should reject an unknown client type", () => { + expect(() => + createOAuthClientConfig({ + name: "client", + type: "implicit" as unknown as "pre_registered", + }), + ).toThrow(); + }); +}); + +describe("OAuthConfig", () => { + it("should create with endpoints, client, scopes, and pkce", () => { + const oauth = makeOAuthConfig(); + expect(oauth.componentType).toBe("OAuthConfig"); + expect(oauth.issuer).toBe("https://issuer.example.com"); + expect(oauth.endpoints?.authorizationEndpoint).toBe( + "https://issuer.example.com/auth", + ); + expect(oauth.client.type).toBe("pre_registered"); + expect(oauth.redirectUri).toBe("https://app.example.com/callback"); + expect(oauth.scopes).toEqual(["openid", "profile"]); + expect(oauth.pkce).toEqual({ required: true, method: "S256" }); + expect(Object.isFrozen(oauth)).toBe(true); + }); + + it("should default pkce required/method when given an empty policy", () => { + const oauth = createOAuthConfig({ + name: "OAuth", + client: createOAuthClientConfig({ name: "c", type: "pre_registered" }), + redirectUri: "https://app.example.com/callback", + pkce: {}, + }); + expect(oauth.pkce).toEqual({ required: true, method: PKCEMethod.S256 }); + }); + + it("should accept scopes as a space-delimited string", () => { + const oauth = createOAuthConfig({ + name: "OAuth", + client: createOAuthClientConfig({ name: "c", type: "pre_registered" }), + redirectUri: "https://app.example.com/callback", + scopes: "openid profile", + scopePolicy: ScopePolicy.FIXED, + }); + expect(oauth.scopes).toBe("openid profile"); + expect(oauth.scopePolicy).toBe("fixed"); + }); + + it("should be accepted by AuthConfigUnion", () => { + const oauth = makeOAuthConfig(); + const parsed = AuthConfigUnion.parse(oauth); + expect(parsed.componentType).toBe("OAuthConfig"); + }); +}); + +describe("OAuth serialization on remote transports", () => { + it("should serialize the transport with a nested OAuthConfig", () => { + const serializer = new AgentSpecSerializer(); + const json = serializer.toJson(makeTransportWithOAuth()) as string; + const dict = JSON.parse(json); + + expect(dict["component_type"]).toBe("SSETransport"); + const auth = dict["auth"] as Record; + expect(auth["component_type"]).toBe("OAuthConfig"); + expect(auth["issuer"]).toBe("https://issuer.example.com"); + expect(auth["redirect_uri"]).toBe("https://app.example.com/callback"); + expect(auth["scopes"]).toEqual(["openid", "profile"]); + expect(auth["endpoints"]).toEqual({ + authorization_endpoint: "https://issuer.example.com/auth", + token_endpoint: "https://issuer.example.com/token", + }); + expect(auth["pkce"]).toEqual({ required: true, method: "S256" }); + const client = auth["client"] as Record; + expect(client["component_type"]).toBe("OAuthClientConfig"); + expect(client["type"]).toBe("pre_registered"); + }); + + it("should redact the client secrets from serialized output", () => { + const serializer = new AgentSpecSerializer(); + const json = serializer.toJson(makeTransportWithOAuth()) as string; + const dict = JSON.parse(json); + const client = (dict["auth"] as Record)[ + "client" + ] as Record; + + expect("client_id" in client).toBe(false); + expect("client_secret" in client).toBe(false); + expect("client_id_metadata_url" in client).toBe(false); + expect(json.includes("client_secret")).toBe(false); + }); + + it("should round-trip the transport with auth intact", () => { + const serializer = new AgentSpecSerializer(); + const deserializer = new AgentSpecDeserializer(); + const transport = makeTransportWithOAuth(); + + const json = serializer.toJson(transport) as string; + const loaded = deserializer.fromJson(json) as SSETransport; + + expect(loaded.componentType).toBe("SSETransport"); + expect(loaded.auth).toBeDefined(); + expect(loaded.auth!.componentType).toBe("OAuthConfig"); + expect(loaded.auth!.client.type).toBe("pre_registered"); + expect(loaded.auth!.endpoints).toEqual({ + authorizationEndpoint: "https://issuer.example.com/auth", + tokenEndpoint: "https://issuer.example.com/token", + }); + expect(loaded.auth!.pkce).toEqual({ required: true, method: "S256" }); + expect(loaded.auth!.scopes).toEqual(["openid", "profile"]); + }); + + it("should serialize stably (serialize -> parse -> re-serialize)", () => { + const serializer = new AgentSpecSerializer(); + const deserializer = new AgentSpecDeserializer(); + const transport = makeTransportWithOAuth(); + + const json = serializer.toJson(transport) as string; + const reserialized = serializer.toJson( + deserializer.fromJson(json) as SSETransport, + ) as string; + expect(reserialized).toBe(json); + }); + + it("should throw when serializing at a version before 26.1.2", () => { + const serializer = new AgentSpecSerializer(); + expect(() => + serializer.toJson(makeTransportWithOAuth(), { + agentspecVersion: AgentSpecVersion.V26_1_0, + }), + ).toThrow(/Invalid agentspec_version.*26\.1\.0.*26\.1\.2/); + }); + + it("should serialize successfully at 26.1.2", () => { + const serializer = new AgentSpecSerializer(); + const json = serializer.toJson(makeTransportWithOAuth(), { + agentspecVersion: AgentSpecVersion.V26_1_2, + }) as string; + const dict = JSON.parse(json); + expect(dict["agentspec_version"]).toBe("26.1.2"); + expect((dict["auth"] as Record)["component_type"]).toBe( + "OAuthConfig", + ); + }); +}); diff --git a/tsagentspec/tests/flows/nodes.test.ts b/tsagentspec/tests/flows/nodes.test.ts index 9d7f9f82..08ccf510 100644 --- a/tsagentspec/tests/flows/nodes.test.ts +++ b/tsagentspec/tests/flows/nodes.test.ts @@ -513,6 +513,32 @@ describe("ApiNode", () => { }); expect(node.branches).toEqual([DEFAULT_NEXT_BRANCH]); }); + + it("should leave urlAllowList and retryPolicy undefined by default", () => { + const node = createApiNode({ + name: "api", + url: "https://api.example.com", + httpMethod: "GET", + }); + expect(node.urlAllowList).toBeUndefined(); + expect(node.retryPolicy).toBeUndefined(); + }); + + it("should accept urlAllowList and a partial retryPolicy, filling defaults", () => { + const node = createApiNode({ + name: "api", + url: "https://api.example.com/orders/{{order_id}}", + httpMethod: "GET", + urlAllowList: ["https://api.example.com/orders/"], + retryPolicy: { maxAttempts: 3, requestTimeout: 0.5 }, + }); + expect(node.urlAllowList).toEqual(["https://api.example.com/orders/"]); + expect(node.retryPolicy?.maxAttempts).toBe(3); + expect(node.retryPolicy?.requestTimeout).toBe(0.5); + expect(node.retryPolicy?.initialRetryDelay).toBe(1.0); + expect(node.retryPolicy?.maxRetryDelay).toBe(8.0); + expect(node.retryPolicy?.jitter).toBe("full_and_equal_for_throttle"); + }); }); describe("InputMessageNode", () => { diff --git a/tsagentspec/tests/llms/generic-llm-config.test.ts b/tsagentspec/tests/llms/generic-llm-config.test.ts new file mode 100644 index 00000000..e9bb7f13 --- /dev/null +++ b/tsagentspec/tests/llms/generic-llm-config.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect } from "vitest"; +import { + AgentSpecSerializer, + AgentSpecDeserializer, + AgentSpecVersion, + LlmConfigUnion, + createAgent, + createLlmConfig, + type GenericLlmConfig, + type Agent, +} from "../../src/index.js"; + +function makeBareLlmConfig(): GenericLlmConfig { + return createLlmConfig({ + id: "llm", + name: "generic-llm", + modelId: "llama-3.3-70b", + provider: "meta", + apiProvider: "vllm", + apiType: "chat_completions", + url: "http://localhost:8000", + defaultGenerationParameters: { temperature: 0.5 }, + retryPolicy: { maxAttempts: 3 }, + }); +} + +describe("Bare LlmConfig component", () => { + it("should create with all fields", () => { + const config = makeBareLlmConfig(); + expect(config.componentType).toBe("LlmConfig"); + expect(config.modelId).toBe("llama-3.3-70b"); + expect(config.provider).toBe("meta"); + expect(config.apiProvider).toBe("vllm"); + expect(config.apiType).toBe("chat_completions"); + expect(config.url).toBe("http://localhost:8000"); + expect(config.retryPolicy?.maxAttempts).toBe(3); + expect(Object.isFrozen(config)).toBe(true); + }); + + it("should create with only the required fields", () => { + const config = createLlmConfig({ name: "minimal", modelId: "gpt-4o" }); + expect(config.componentType).toBe("LlmConfig"); + expect(config.modelId).toBe("gpt-4o"); + expect(config.provider).toBeUndefined(); + expect(config.apiProvider).toBeUndefined(); + expect(config.apiType).toBeUndefined(); + expect(config.url).toBeUndefined(); + expect(config.retryPolicy).toBeUndefined(); + }); + + it("should be accepted by LlmConfigUnion", () => { + const parsed = LlmConfigUnion.parse(makeBareLlmConfig()); + expect(parsed.componentType).toBe("LlmConfig"); + }); + + it("should serialize with snake_case wire names", () => { + const serializer = new AgentSpecSerializer(); + const json = serializer.toJson(makeBareLlmConfig()) as string; + const dict = JSON.parse(json); + + expect(dict["component_type"]).toBe("LlmConfig"); + expect(dict["model_id"]).toBe("llama-3.3-70b"); + expect(dict["provider"]).toBe("meta"); + expect(dict["api_provider"]).toBe("vllm"); + expect(dict["api_type"]).toBe("chat_completions"); + expect(dict["url"]).toBe("http://localhost:8000"); + expect( + (dict["retry_policy"] as Record)["max_attempts"], + ).toBe(3); + }); + + it("should redact apiKey from serialized output", () => { + const serializer = new AgentSpecSerializer(); + const config = createLlmConfig({ + name: "with-key", + modelId: "gpt-4o", + apiKey: "sk-secret-value", + }); + const json = serializer.toJson(config) as string; + expect("api_key" in JSON.parse(json)).toBe(false); + expect(json.includes("sk-secret-value")).toBe(false); + }); + + it("should throw when serializing at a version before 26.1.2", () => { + const serializer = new AgentSpecSerializer(); + expect(() => + serializer.toJson(makeBareLlmConfig(), { + agentspecVersion: AgentSpecVersion.V26_1_0, + }), + ).toThrow(/Invalid agentspec_version.*26\.1\.0.*26\.1\.2/); + }); + + it("should serialize successfully at 26.1.2", () => { + const serializer = new AgentSpecSerializer(); + const json = serializer.toJson(makeBareLlmConfig(), { + agentspecVersion: AgentSpecVersion.V26_1_2, + }) as string; + expect(JSON.parse(json)["agentspec_version"]).toBe("26.1.2"); + }); + + it("should round-trip standalone", () => { + const serializer = new AgentSpecSerializer(); + const deserializer = new AgentSpecDeserializer(); + const config = makeBareLlmConfig(); + + const json = serializer.toJson(config) as string; + const loaded = deserializer.fromJson(json) as GenericLlmConfig; + + expect(loaded).toEqual(config); + }); + + it("should round-trip on an Agent", () => { + const serializer = new AgentSpecSerializer(); + const deserializer = new AgentSpecDeserializer(); + const agent = createAgent({ + name: "agent", + llmConfig: makeBareLlmConfig(), + systemPrompt: "Hello", + }); + + const json = serializer.toJson(agent) as string; + const loaded = deserializer.fromJson(json) as Agent; + + expect(loaded.llmConfig.componentType).toBe("LlmConfig"); + const llmConfig = loaded.llmConfig as GenericLlmConfig; + expect(llmConfig.apiProvider).toBe("vllm"); + expect(llmConfig.retryPolicy?.maxAttempts).toBe(3); + }); +}); diff --git a/tsagentspec/tests/llms/llm-config.test.ts b/tsagentspec/tests/llms/llm-config.test.ts index bba0930d..4bcee591 100644 --- a/tsagentspec/tests/llms/llm-config.test.ts +++ b/tsagentspec/tests/llms/llm-config.test.ts @@ -2,6 +2,13 @@ import { describe, it, expect } from "vitest"; import { LlmGenerationConfigSchema, OpenAIAPIType, + createOpenAiCompatibleConfig, + createOllamaConfig, + createVllmConfig, + createOpenAiConfig, + createOciGenAiConfig, + createOciClientConfigWithApiKey, + type RetryPolicy, } from "../../src/index.js"; describe("LlmGenerationConfig", () => { @@ -50,3 +57,79 @@ describe("OpenAIAPIType", () => { expect(OpenAIAPIType.RESPONSES).toBe("responses"); }); }); + +describe("retryPolicy on LLM configs", () => { + // Python declares retry_policy once on the LlmConfig base; every TS config + // schema must carry it explicitly. Each factory accepts a partial policy + // and fills the defaults. + const retryPolicy = { maxAttempts: 3, requestTimeout: 0.5 }; + + function expectPolicy(policy: RetryPolicy | undefined) { + expect(policy?.maxAttempts).toBe(3); + expect(policy?.requestTimeout).toBe(0.5); + expect(policy?.initialRetryDelay).toBe(1.0); + expect(policy?.maxRetryDelay).toBe(8.0); + expect(policy?.backoffFactor).toBe(2.0); + expect(policy?.jitter).toBe("full_and_equal_for_throttle"); + } + + it("should be accepted by OpenAiCompatibleConfig", () => { + const config = createOpenAiCompatibleConfig({ + name: "llm", + url: "http://localhost:8000", + modelId: "gpt-4", + retryPolicy, + }); + expectPolicy(config.retryPolicy); + }); + + it("should be accepted by OllamaConfig", () => { + const config = createOllamaConfig({ + name: "ollama", + url: "http://localhost:11434", + modelId: "llama3", + retryPolicy, + }); + expectPolicy(config.retryPolicy); + }); + + it("should be accepted by VllmConfig", () => { + const config = createVllmConfig({ + name: "vllm", + url: "http://localhost:8000", + modelId: "mistral", + retryPolicy, + }); + expectPolicy(config.retryPolicy); + }); + + it("should be accepted by OpenAiConfig", () => { + const config = createOpenAiConfig({ + name: "openai", + modelId: "gpt-4o", + retryPolicy, + }); + expectPolicy(config.retryPolicy); + }); + + it("should be accepted by OciGenAiConfig", () => { + const config = createOciGenAiConfig({ + name: "oci-llm", + modelId: "cohere.command-r-plus", + compartmentId: "ocid1.compartment.oc1..aaa", + clientConfig: createOciClientConfigWithApiKey({ + name: "oci-client", + serviceEndpoint: "https://inference.example.oraclecloud.com", + authProfile: "DEFAULT", + authFileLocation: "~/.oci/config", + }), + retryPolicy, + }); + expectPolicy(config.retryPolicy); + }); + + it("should default to undefined when not provided", () => { + const config = createOpenAiConfig({ name: "openai", modelId: "gpt-4o" }); + expect(config.retryPolicy).toBeUndefined(); + }); +}); diff --git a/tsagentspec/tests/mcp/mcp-tool.test.ts b/tsagentspec/tests/mcp/mcp-tool.test.ts index 763c6789..36e8f158 100644 --- a/tsagentspec/tests/mcp/mcp-tool.test.ts +++ b/tsagentspec/tests/mcp/mcp-tool.test.ts @@ -73,6 +73,33 @@ describe("MCPTool", () => { }); expect(Object.isFrozen(tool)).toBe(true); }); + + it("should accept a partial semantic retryPolicy, filling defaults", () => { + const transport = createStdioTransport({ + name: "stdio", + command: "node", + }); + const tool = createMCPTool({ + name: "mcp-tool", + clientTransport: transport, + retryPolicy: { maxAttempts: 3, initialRetryDelay: 0.25 }, + }); + expect(tool.retryPolicy?.maxAttempts).toBe(3); + expect(tool.retryPolicy?.initialRetryDelay).toBe(0.25); + expect(tool.retryPolicy?.maxRetryDelay).toBe(8.0); + }); + + it("should leave retryPolicy undefined by default", () => { + const transport = createStdioTransport({ + name: "stdio", + command: "node", + }); + const tool = createMCPTool({ + name: "mcp-tool", + clientTransport: transport, + }); + expect(tool.retryPolicy).toBeUndefined(); + }); }); describe("MCPToolSpec", () => { diff --git a/tsagentspec/tests/mcp/transport.test.ts b/tsagentspec/tests/mcp/transport.test.ts index 2e007162..71dbc2a8 100644 --- a/tsagentspec/tests/mcp/transport.test.ts +++ b/tsagentspec/tests/mcp/transport.test.ts @@ -52,6 +52,24 @@ describe("SSETransport", () => { expect(t.headers).toEqual({ Authorization: "Bearer token" }); expect(t.sensitiveHeaders).toEqual({ "X-Secret": "value" }); }); + + it("should accept a partial retryPolicy, filling defaults", () => { + const t = createSSETransport({ + name: "sse", + url: "http://localhost/sse", + retryPolicy: { maxAttempts: 3, initialRetryDelay: 0.25 }, + }); + expect(t.retryPolicy?.maxAttempts).toBe(3); + expect(t.retryPolicy?.initialRetryDelay).toBe(0.25); + expect(t.retryPolicy?.maxRetryDelay).toBe(8.0); + expect(t.retryPolicy?.jitter).toBe("full_and_equal_for_throttle"); + }); + + it("should leave auth and retryPolicy undefined by default", () => { + const t = createSSETransport({ name: "sse", url: "http://localhost/sse" }); + expect(t.auth).toBeUndefined(); + expect(t.retryPolicy).toBeUndefined(); + }); }); describe("SSEmTLSTransport", () => { @@ -109,4 +127,14 @@ describe("RemoteTransport", () => { }); expect(t.sessionParameters.readTimeoutSeconds).toBe(60.0); }); + + it("should accept a partial retryPolicy, filling defaults", () => { + const t = createRemoteTransport({ + name: "remote", + url: "http://localhost", + retryPolicy: { maxAttempts: 5 }, + }); + expect(t.retryPolicy?.maxAttempts).toBe(5); + expect(t.retryPolicy?.backoffFactor).toBe(2.0); + }); }); diff --git a/tsagentspec/tests/retry-policy.test.ts b/tsagentspec/tests/retry-policy.test.ts new file mode 100644 index 00000000..bd03aa5b --- /dev/null +++ b/tsagentspec/tests/retry-policy.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from "vitest"; +import { RetryPolicySchema, RetryJitter } from "../src/index.js"; + +describe("RetryPolicy", () => { + it("should apply the Python defaults when parsing an empty object", () => { + const policy = RetryPolicySchema.parse({}); + expect(policy.maxAttempts).toBe(2); + expect(policy.requestTimeout).toBeNull(); + expect(policy.initialRetryDelay).toBe(1.0); + expect(policy.maxRetryDelay).toBe(8.0); + expect(policy.backoffFactor).toBe(2.0); + expect(policy.jitter).toBe(RetryJitter.FULL_AND_EQUAL_FOR_THROTTLE); + expect(policy.serviceErrorRetryOnAny5xx).toBe(true); + expect(policy.recoverableStatuses).toEqual({ "409": [], "429": [] }); + }); + + it("should always carry all eight fields after parsing", () => { + // Python's model_dump emits every field (None as null); the parsed + // object must therefore materialize all keys, defaults included. + const policy = RetryPolicySchema.parse({ maxAttempts: 3 }); + expect(Object.keys(policy).sort()).toEqual( + [ + "backoffFactor", + "initialRetryDelay", + "jitter", + "maxAttempts", + "maxRetryDelay", + "recoverableStatuses", + "requestTimeout", + "serviceErrorRetryOnAny5xx", + ].sort(), + ); + }); + + it("should accept a full configuration", () => { + const policy = RetryPolicySchema.parse({ + maxAttempts: 5, + requestTimeout: 0.5, + initialRetryDelay: 0.25, + maxRetryDelay: 30, + backoffFactor: 3, + jitter: RetryJitter.DECORRELATED, + serviceErrorRetryOnAny5xx: false, + recoverableStatuses: { "408": [], "429": ["Retry-After"] }, + }); + expect(policy.maxAttempts).toBe(5); + expect(policy.requestTimeout).toBe(0.5); + expect(policy.jitter).toBe("decorrelated"); + expect(policy.serviceErrorRetryOnAny5xx).toBe(false); + expect(policy.recoverableStatuses).toEqual({ + "408": [], + "429": ["Retry-After"], + }); + }); + + it("should reject unknown fields (extra='forbid')", () => { + expect(() => + RetryPolicySchema.parse({ maxAttmpts: 7 }), + ).toThrow(/[Uu]nrecognized key/); + }); + + it.each([ + ["maxAttempts", -1], + ["maxAttempts", 1.5], + ["requestTimeout", 0.0], + ["requestTimeout", -1.0], + ["initialRetryDelay", -0.1], + ["maxRetryDelay", -0.1], + ["backoffFactor", 0.0], + ["backoffFactor", -1.0], + ])("should reject invalid numeric value %s=%s", (fieldName, value) => { + expect(() => RetryPolicySchema.parse({ [fieldName]: value })).toThrow(); + }); + + it("should reject maxRetryDelay lower than initialRetryDelay", () => { + expect(() => + RetryPolicySchema.parse({ initialRetryDelay: 2.0, maxRetryDelay: 1.0 }), + ).toThrow( + "`max_retry_delay` must be greater than or equal to `initial_retry_delay`.", + ); + }); + + it("should enforce the delay bound against defaults too", () => { + // initialRetryDelay 10 against the default maxRetryDelay of 8 must fail. + expect(() => + RetryPolicySchema.parse({ initialRetryDelay: 10 }), + ).toThrow( + "`max_retry_delay` must be greater than or equal to `initial_retry_delay`.", + ); + }); + + it("should accept an explicit null jitter (no jitter)", () => { + const policy = RetryPolicySchema.parse({ jitter: null }); + expect(policy.jitter).toBeNull(); + }); +}); diff --git a/tsagentspec/tests/serialization/round-trip.test.ts b/tsagentspec/tests/serialization/round-trip.test.ts index 325d11f6..093ecc47 100644 --- a/tsagentspec/tests/serialization/round-trip.test.ts +++ b/tsagentspec/tests/serialization/round-trip.test.ts @@ -17,9 +17,19 @@ import { createControlFlowEdge, createFlow, createApiNode, + createMCPTool, + createMCPToolBox, + createOAuthClientConfig, + createOAuthConfig, + createSSETransport, + createStdioTransport, FlowBuilder, stringProperty, integerProperty, + type RemoteTool, + type ApiNode, + type MCPTool, + type MCPToolBox, } from "../../src/index.js"; const serializer = new AgentSpecSerializer(); @@ -322,4 +332,161 @@ describe("Round-trip serialization", () => { expect(result["configuration"]).toEqual(configuration); }); }); + + describe("RetryPolicy, url allow-lists, and MCP auth", () => { + it("should round-trip a RemoteTool with urlAllowList and retryPolicy", () => { + const tool = createRemoteTool({ + name: "remote-tool", + url: "https://api.example.com/orders/{{order_id}}", + httpMethod: "GET", + urlAllowList: ["https://api.example.com/orders/"], + retryPolicy: { maxAttempts: 3, requestTimeout: 0.5, initialRetryDelay: 1 }, + }); + const loaded = deserializer.fromJson( + serializer.toJson(tool) as string, + ) as RemoteTool; + expect(loaded).toEqual(tool); + }); + + it("should round-trip an ApiNode with urlAllowList and retryPolicy", () => { + const node = createApiNode({ + name: "api", + url: "https://api.example.com/orders/{{order_id}}", + httpMethod: "GET", + urlAllowList: ["https://api.example.com/orders/"], + retryPolicy: { maxAttempts: 3, requestTimeout: 0.5 }, + }); + const loaded = deserializer.fromJson( + serializer.toJson(node) as string, + ) as ApiNode; + expect(loaded).toEqual(node); + }); + + it("should emit the exact Python wire keys for a retry policy", () => { + const tool = createRemoteTool({ + name: "remote-tool", + url: "https://api.example.com", + httpMethod: "GET", + retryPolicy: { serviceErrorRetryOnAny5xx: false, jitter: null }, + }); + const json = serializer.toJson(tool) as string; + const retryDict = JSON.parse(json)["retry_policy"] as Record; + + // All eight fields are present (Python's model_dump keeps nulls) with + // exact snake_case names; "service_error_retry_on_any_5xx" cannot be + // produced by the generic camelToSnake converter and is pinned here. + expect(Object.keys(retryDict).sort()).toEqual([ + "backoff_factor", + "initial_retry_delay", + "jitter", + "max_attempts", + "max_retry_delay", + "recoverable_statuses", + "request_timeout", + "service_error_retry_on_any_5xx", + ]); + expect(retryDict["service_error_retry_on_any_5xx"]).toBe(false); + expect(retryDict["jitter"]).toBeNull(); + expect(retryDict["request_timeout"]).toBeNull(); + + const loaded = deserializer.fromJson(json) as RemoteTool; + expect(loaded.retryPolicy?.serviceErrorRetryOnAny5xx).toBe(false); + expect(loaded.retryPolicy?.jitter).toBeNull(); + expect(loaded).toEqual(tool); + }); + + it("should round-trip an MCPTool with a semantic retryPolicy", () => { + const tool = createMCPTool({ + name: "mcp-tool", + clientTransport: createStdioTransport({ name: "stdio", command: "node" }), + retryPolicy: { maxAttempts: 3, initialRetryDelay: 0.25 }, + }); + const json = serializer.toJson(tool) as string; + expect( + (JSON.parse(json)["retry_policy"] as Record)["max_attempts"], + ).toBe(3); + const loaded = deserializer.fromJson(json) as MCPTool; + expect(loaded).toEqual(tool); + }); + + it("should round-trip an MCPToolBox with transport and semantic retry policies", () => { + const toolbox = createMCPToolBox({ + name: "toolbox", + clientTransport: createSSETransport({ + name: "sse", + url: "https://mcp.example.com/sse", + retryPolicy: { maxAttempts: 3, initialRetryDelay: 0.25 }, + }), + retryPolicy: { maxAttempts: 4, initialRetryDelay: 0.5 }, + }); + const json = serializer.toJson(toolbox) as string; + const dict = JSON.parse(json); + expect((dict["retry_policy"] as Record)["max_attempts"]).toBe(4); + const transportDict = dict["client_transport"] as Record; + expect( + (transportDict["retry_policy"] as Record)["max_attempts"], + ).toBe(3); + const loaded = deserializer.fromJson(json) as MCPToolBox; + expect(loaded).toEqual(toolbox); + }); + + it("should round-trip a full spec carrying the new fields byte-stably", () => { + const agent = createAgent({ + id: "agent", + name: "agent", + llmConfig: createOpenAiCompatibleConfig({ + id: "llm", + name: "llm", + url: "http://localhost:8000", + modelId: "gpt-4", + retryPolicy: { maxAttempts: 3, requestTimeout: 0.5 }, + }), + systemPrompt: "Hello", + tools: [ + createRemoteTool({ + id: "remote-tool", + name: "remote-tool", + url: "https://api.example.com/orders/{{order_id}}", + httpMethod: "GET", + urlAllowList: ["https://api.example.com/orders/"], + retryPolicy: { maxAttempts: 2 }, + }), + ], + toolboxes: [ + createMCPToolBox({ + id: "toolbox", + name: "toolbox", + clientTransport: createSSETransport({ + id: "transport", + name: "sse", + url: "https://mcp.example.com/sse", + retryPolicy: { maxAttempts: 3, initialRetryDelay: 0.25 }, + auth: createOAuthConfig({ + id: "oauth", + name: "oauth", + issuer: "https://issuer.example.com", + client: createOAuthClientConfig({ + id: "client", + name: "client", + type: "pre_registered", + clientId: "client_id", + clientSecret: "client_secret", + }), + redirectUri: "https://app.example.com/callback", + scopes: ["openid"], + pkce: { required: true, method: "S256" }, + }), + }), + retryPolicy: { maxAttempts: 4, initialRetryDelay: 0.5 }, + }), + ], + }); + + const json = serializer.toJson(agent) as string; + const reserialized = serializer.toJson( + deserializer.fromJson(json) as any, + ) as string; + expect(reserialized).toBe(json); + }); + }); }); diff --git a/tsagentspec/tests/serialization/sensitive-fields.test.ts b/tsagentspec/tests/serialization/sensitive-fields.test.ts index 29453115..3f86d580 100644 --- a/tsagentspec/tests/serialization/sensitive-fields.test.ts +++ b/tsagentspec/tests/serialization/sensitive-fields.test.ts @@ -2,6 +2,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { AgentSpecSerializer, createAgent, + createLlmConfig, + createOAuthClientConfig, createOpenAiCompatibleConfig, createOllamaConfig, createVllmConfig, @@ -98,6 +100,43 @@ describe("sensitive field exclusion", () => { expect("api_key" in llmDict).toBe(false); }); + it("should exclude apiKey from the bare LlmConfig", () => { + const serializer = new AgentSpecSerializer(); + const llm = createLlmConfig({ + name: "bare-llm", + modelId: "gpt-4o", + apiProvider: "openai", + apiKey: "sk-secret", + }); + const agent = createAgent({ + name: "agent", + llmConfig: llm, + systemPrompt: "Hello", + }); + const json = serializer.toJson(agent) as string; + const llmDict = JSON.parse(json)["llm_config"] as Record; + expect("api_key" in llmDict).toBe(false); + expect(json.includes("sk-secret")).toBe(false); + }); + + it("should exclude clientId, clientSecret, and clientIdMetadataUrl from OAuthClientConfig", () => { + const serializer = new AgentSpecSerializer(); + const client = createOAuthClientConfig({ + name: "client", + type: "pre_registered", + clientId: "the-client-id", + clientSecret: "the-client-secret", + clientIdMetadataUrl: "https://app.example.com/client-metadata.json", + }); + const json = serializer.toJson(client) as string; + const dict = JSON.parse(json); + expect("client_id" in dict).toBe(false); + expect("client_secret" in dict).toBe(false); + expect("client_id_metadata_url" in dict).toBe(false); + expect(json.includes("the-client-id")).toBe(false); + expect(json.includes("the-client-secret")).toBe(false); + }); + it("should exclude sensitiveHeaders from RemoteTool", () => { const serializer = new AgentSpecSerializer(); const tool = createRemoteTool({ diff --git a/tsagentspec/tests/serialization/version-gates.test.ts b/tsagentspec/tests/serialization/version-gates.test.ts index 603e5d01..d0595271 100644 --- a/tsagentspec/tests/serialization/version-gates.test.ts +++ b/tsagentspec/tests/serialization/version-gates.test.ts @@ -3,7 +3,11 @@ import { AgentSpecSerializer, AgentSpecVersion, createAgent, + createApiNode, + createMCPTool, createOpenAiCompatibleConfig, + createRemoteTool, + createSSETransport, createServerTool, createBuiltinTool, createMCPToolBox, @@ -187,6 +191,172 @@ describe("version-gated field serialization", () => { expect("requires_confirmation" in tools[0]!).toBe(true); }); + it("should include RemoteTool urlAllowList and retryPolicy for version 26.1.2+", () => { + const serializer = new AgentSpecSerializer(); + const tool = createRemoteTool({ + name: "remote", + url: "https://api.example.com/orders/", + httpMethod: "GET", + urlAllowList: ["https://api.example.com/orders/"], + retryPolicy: { maxAttempts: 3 }, + }); + const json = serializer.toJson(tool, { + agentspecVersion: AgentSpecVersion.V26_1_2, + }) as string; + const dict = JSON.parse(json); + expect(dict["url_allow_list"]).toEqual(["https://api.example.com/orders/"]); + expect((dict["retry_policy"] as Record)["max_attempts"]).toBe(3); + }); + + it("should exclude RemoteTool urlAllowList and retryPolicy for versions before 26.1.2", () => { + const serializer = new AgentSpecSerializer(); + const tool = createRemoteTool({ + name: "remote", + url: "https://api.example.com/orders/", + httpMethod: "GET", + urlAllowList: ["https://api.example.com/orders/"], + retryPolicy: { maxAttempts: 3 }, + }); + const json = serializer.toJson(tool, { + agentspecVersion: AgentSpecVersion.V26_1_0, + }) as string; + const dict = JSON.parse(json); + expect("url_allow_list" in dict).toBe(false); + expect("retry_policy" in dict).toBe(false); + }); + + it("should gate ApiNode urlAllowList and retryPolicy on 26.1.2", () => { + const serializer = new AgentSpecSerializer(); + const node = createApiNode({ + name: "api", + url: "https://api.example.com", + httpMethod: "GET", + urlAllowList: ["https://api.example.com/"], + retryPolicy: { maxAttempts: 3 }, + }); + + const current = JSON.parse( + serializer.toJson(node, { + agentspecVersion: AgentSpecVersion.V26_1_2, + }) as string, + ); + expect(current["url_allow_list"]).toEqual(["https://api.example.com/"]); + expect((current["retry_policy"] as Record)["max_attempts"]).toBe(3); + + const old = JSON.parse( + serializer.toJson(node, { + agentspecVersion: AgentSpecVersion.V26_1_0, + }) as string, + ); + expect("url_allow_list" in old).toBe(false); + expect("retry_policy" in old).toBe(false); + }); + + it("should gate LLM config retryPolicy on 26.1.2", () => { + const serializer = new AgentSpecSerializer(); + const agent = createAgent({ + name: "agent", + llmConfig: createOpenAiCompatibleConfig({ + name: "llm", + url: "http://localhost:8000", + modelId: "gpt-4", + retryPolicy: { maxAttempts: 3 }, + }), + systemPrompt: "Hello", + }); + + const current = JSON.parse(serializer.toJson(agent) as string); + const llmDict = current["llm_config"] as Record; + expect((llmDict["retry_policy"] as Record)["max_attempts"]).toBe(3); + + const old = JSON.parse( + serializer.toJson(agent, { + agentspecVersion: AgentSpecVersion.V26_1_0, + }) as string, + ); + expect("retry_policy" in (old["llm_config"] as Record)).toBe(false); + }); + + it("should exclude remote transport retryPolicy for versions before 26.1.2", () => { + const serializer = new AgentSpecSerializer(); + const transport = createSSETransport({ + name: "sse", + url: "http://localhost/sse", + retryPolicy: { maxAttempts: 3, initialRetryDelay: 0.25 }, + }); + const json = serializer.toJson(transport, { + agentspecVersion: AgentSpecVersion.V26_1_0, + }) as string; + expect("retry_policy" in JSON.parse(json)).toBe(false); + }); + + it("should gate MCPTool semantic retryPolicy on 26.3.0, not 26.2.0", () => { + const serializer = new AgentSpecSerializer(); + const tool = createMCPTool({ + name: "mcp-tool", + clientTransport: createStdioTransport({ name: "stdio", command: "node" }), + retryPolicy: { maxAttempts: 3, initialRetryDelay: 0.25 }, + }); + + const at26_3 = JSON.parse( + serializer.toJson(tool, { + agentspecVersion: AgentSpecVersion.V26_3_0, + }) as string, + ); + expect((at26_3["retry_policy"] as Record)["max_attempts"]).toBe(3); + + // The Python threshold is v26_3_0 (there is no 26.2.0 member in Python's + // current versioning); the field must still be gated out at 26.2.0. + const at26_2 = JSON.parse( + serializer.toJson(tool, { + agentspecVersion: AgentSpecVersion.V26_2_0, + }) as string, + ); + expect("retry_policy" in at26_2).toBe(false); + + const at26_1_2 = JSON.parse( + serializer.toJson(tool, { + agentspecVersion: AgentSpecVersion.V26_1_2, + }) as string, + ); + expect("retry_policy" in at26_1_2).toBe(false); + }); + + it("should gate MCPToolBox semantic retryPolicy on 26.3.0", () => { + const serializer = new AgentSpecSerializer(); + const toolbox = createMCPToolBox({ + name: "toolbox", + clientTransport: createStdioTransport({ name: "stdio", command: "node" }), + retryPolicy: { maxAttempts: 4, initialRetryDelay: 0.5 }, + }); + + const at26_3 = JSON.parse( + serializer.toJson(toolbox, { + agentspecVersion: AgentSpecVersion.V26_3_0, + }) as string, + ); + expect((at26_3["retry_policy"] as Record)["max_attempts"]).toBe(4); + + const at26_2 = JSON.parse( + serializer.toJson(toolbox, { + agentspecVersion: AgentSpecVersion.V26_2_0, + }) as string, + ); + expect("retry_policy" in at26_2).toBe(false); + }); + + it("should serialize MCPToolBox without a retryPolicy key when unset", () => { + const serializer = new AgentSpecSerializer(); + const toolbox = createMCPToolBox({ + name: "toolbox", + clientTransport: createStdioTransport({ name: "stdio", command: "node" }), + }); + const json = serializer.toJson(toolbox, { + agentspecVersion: AgentSpecVersion.V26_1_2, + }) as string; + expect("retry_policy" in JSON.parse(json)).toBe(false); + }); + it("should throw when serializing BuiltinTool at version before 25.4.2", () => { const serializer = new AgentSpecSerializer(); const tool = createBuiltinTool({ diff --git a/tsagentspec/tests/tools/remote-tool.test.ts b/tsagentspec/tests/tools/remote-tool.test.ts index 096356e0..2bcdab9a 100644 --- a/tsagentspec/tests/tools/remote-tool.test.ts +++ b/tsagentspec/tests/tools/remote-tool.test.ts @@ -99,4 +99,34 @@ describe("RemoteTool", () => { }); expect(Object.isFrozen(tool)).toBe(true); }); + + it("should leave urlAllowList and retryPolicy undefined by default", () => { + const tool = createRemoteTool({ + name: "api-tool", + url: "https://api.example.com", + httpMethod: "GET", + }); + expect(tool.urlAllowList).toBeUndefined(); + expect(tool.retryPolicy).toBeUndefined(); + }); + + it("should accept urlAllowList and a partial retryPolicy, filling defaults", () => { + const tool = createRemoteTool({ + name: "api-tool", + url: "https://api.example.com/orders/{{order_id}}", + httpMethod: "GET", + urlAllowList: ["https://api.example.com/orders/"], + retryPolicy: { maxAttempts: 3, requestTimeout: 0.5, initialRetryDelay: 1 }, + }); + expect(tool.urlAllowList).toEqual(["https://api.example.com/orders/"]); + expect(tool.retryPolicy?.maxAttempts).toBe(3); + expect(tool.retryPolicy?.requestTimeout).toBe(0.5); + expect(tool.retryPolicy?.initialRetryDelay).toBe(1); + // defaults filled in for the unset fields + expect(tool.retryPolicy?.maxRetryDelay).toBe(8.0); + expect(tool.retryPolicy?.backoffFactor).toBe(2.0); + expect(tool.retryPolicy?.jitter).toBe("full_and_equal_for_throttle"); + expect(tool.retryPolicy?.serviceErrorRetryOnAny5xx).toBe(true); + expect(tool.retryPolicy?.recoverableStatuses).toEqual({ "409": [], "429": [] }); + }); }); From d3461b2757090a00da589b28e1b9f363d1543cbb Mon Sep 17 00:00:00 2001 From: Salah Date: Sat, 5 Sep 2026 11:56:57 +0400 Subject: [PATCH 08/14] feat(tsagentspec): add the tracing package 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. --- tsagentspec/src/index.ts | 81 ++++ tsagentspec/src/tracing/base.ts | 183 ++++++++ tsagentspec/src/tracing/context.ts | 88 ++++ tsagentspec/src/tracing/events/agent.ts | 51 +++ tsagentspec/src/tracing/events/event.ts | 44 ++ tsagentspec/src/tracing/events/exception.ts | 51 +++ tsagentspec/src/tracing/events/flow.ts | 55 +++ .../src/tracing/events/human-in-the-loop.ts | 50 +++ tsagentspec/src/tracing/events/index.ts | 68 +++ .../src/tracing/events/llm-generation.ts | 126 ++++++ .../src/tracing/events/manager-workers.ts | 51 +++ tsagentspec/src/tracing/events/node.ts | 55 +++ tsagentspec/src/tracing/events/state.ts | 93 ++++ tsagentspec/src/tracing/events/swarm.ts | 51 +++ tsagentspec/src/tracing/events/tool.ts | 144 ++++++ tsagentspec/src/tracing/index.ts | 84 ++++ tsagentspec/src/tracing/message.ts | 76 ++++ tsagentspec/src/tracing/span-processor.ts | 59 +++ tsagentspec/src/tracing/spans/agent.ts | 29 ++ tsagentspec/src/tracing/spans/flow.ts | 29 ++ tsagentspec/src/tracing/spans/index.ts | 15 + tsagentspec/src/tracing/spans/llm.ts | 29 ++ .../src/tracing/spans/manager-workers.ts | 29 ++ tsagentspec/src/tracing/spans/node.ts | 29 ++ tsagentspec/src/tracing/spans/root.ts | 16 + tsagentspec/src/tracing/spans/span.ts | 170 +++++++ tsagentspec/src/tracing/spans/swarm.ts | 29 ++ tsagentspec/src/tracing/spans/tool.ts | 29 ++ tsagentspec/src/tracing/trace.ts | 100 +++++ tsagentspec/tests/tracing/events.test.ts | 413 ++++++++++++++++++ tsagentspec/tests/tracing/fixtures.ts | 123 ++++++ tsagentspec/tests/tracing/spans.test.ts | 165 +++++++ .../tests/tracing/state-snapshot.test.ts | 113 +++++ tsagentspec/tests/tracing/trace.test.ts | 389 +++++++++++++++++ 34 files changed, 3117 insertions(+) create mode 100644 tsagentspec/src/tracing/base.ts create mode 100644 tsagentspec/src/tracing/context.ts create mode 100644 tsagentspec/src/tracing/events/agent.ts create mode 100644 tsagentspec/src/tracing/events/event.ts create mode 100644 tsagentspec/src/tracing/events/exception.ts create mode 100644 tsagentspec/src/tracing/events/flow.ts create mode 100644 tsagentspec/src/tracing/events/human-in-the-loop.ts create mode 100644 tsagentspec/src/tracing/events/index.ts create mode 100644 tsagentspec/src/tracing/events/llm-generation.ts create mode 100644 tsagentspec/src/tracing/events/manager-workers.ts create mode 100644 tsagentspec/src/tracing/events/node.ts create mode 100644 tsagentspec/src/tracing/events/state.ts create mode 100644 tsagentspec/src/tracing/events/swarm.ts create mode 100644 tsagentspec/src/tracing/events/tool.ts create mode 100644 tsagentspec/src/tracing/index.ts create mode 100644 tsagentspec/src/tracing/message.ts create mode 100644 tsagentspec/src/tracing/span-processor.ts create mode 100644 tsagentspec/src/tracing/spans/agent.ts create mode 100644 tsagentspec/src/tracing/spans/flow.ts create mode 100644 tsagentspec/src/tracing/spans/index.ts create mode 100644 tsagentspec/src/tracing/spans/llm.ts create mode 100644 tsagentspec/src/tracing/spans/manager-workers.ts create mode 100644 tsagentspec/src/tracing/spans/node.ts create mode 100644 tsagentspec/src/tracing/spans/root.ts create mode 100644 tsagentspec/src/tracing/spans/span.ts create mode 100644 tsagentspec/src/tracing/spans/swarm.ts create mode 100644 tsagentspec/src/tracing/spans/tool.ts create mode 100644 tsagentspec/src/tracing/trace.ts create mode 100644 tsagentspec/tests/tracing/events.test.ts create mode 100644 tsagentspec/tests/tracing/fixtures.ts create mode 100644 tsagentspec/tests/tracing/spans.test.ts create mode 100644 tsagentspec/tests/tracing/state-snapshot.test.ts create mode 100644 tsagentspec/tests/tracing/trace.test.ts diff --git a/tsagentspec/src/index.ts b/tsagentspec/src/index.ts index 912e3dac..c24ee1a6 100644 --- a/tsagentspec/src/index.ts +++ b/tsagentspec/src/index.ts @@ -338,3 +338,84 @@ export { type ComponentDeserializationPlugin, type ComponentsRegistry, } from "./serialization/index.js"; + +// Tracing +export { + PII_MASK, + TracingSerializable, + Message, + SpanProcessor, + Trace, + getTrace, + getCurrentSpan, + getActiveSpanStack, + Span, + RootSpan, + AgentExecutionSpan, + FlowExecutionSpan, + LlmGenerationSpan, + ManagerWorkersExecutionSpan, + NodeExecutionSpan, + SwarmExecutionSpan, + ToolExecutionSpan, + Event, + ExceptionRaised, + exceptionRaisedFromError, + AgentExecutionStart, + AgentExecutionEnd, + FlowExecutionStart, + FlowExecutionEnd, + NodeExecutionStart, + NodeExecutionEnd, + ManagerWorkersExecutionStart, + ManagerWorkersExecutionEnd, + SwarmExecutionStart, + SwarmExecutionEnd, + HumanInTheLoopRequest, + HumanInTheLoopResponse, + LlmGenerationRequest, + LlmGenerationResponse, + LlmGenerationChunkReceived, + ToolCall, + ToolExecutionRequest, + ToolExecutionResponse, + ToolConfirmationRequest, + ToolConfirmationResponse, + ToolExecutionStreamingChunkReceived, + StateSnapshotEmitted, + type TracingSerializeOptions, + type MessageOptions, + type TraceOptions, + type SpanOptions, + type AgentExecutionSpanOptions, + type FlowExecutionSpanOptions, + type LlmGenerationSpanOptions, + type ManagerWorkersExecutionSpanOptions, + type NodeExecutionSpanOptions, + type SwarmExecutionSpanOptions, + type ToolExecutionSpanOptions, + type EventOptions, + type ExceptionRaisedOptions, + type AgentExecutionStartOptions, + type AgentExecutionEndOptions, + type FlowExecutionStartOptions, + type FlowExecutionEndOptions, + type NodeExecutionStartOptions, + type NodeExecutionEndOptions, + type ManagerWorkersExecutionStartOptions, + type ManagerWorkersExecutionEndOptions, + type SwarmExecutionStartOptions, + type SwarmExecutionEndOptions, + type HumanInTheLoopRequestOptions, + type HumanInTheLoopResponseOptions, + type LlmGenerationRequestOptions, + type LlmGenerationResponseOptions, + type LlmGenerationChunkReceivedOptions, + type ToolCallOptions, + type ToolExecutionRequestOptions, + type ToolExecutionResponseOptions, + type ToolConfirmationRequestOptions, + type ToolConfirmationResponseOptions, + type ToolExecutionStreamingChunkReceivedOptions, + type StateSnapshotEmittedOptions, +} from "./tracing/index.js"; diff --git a/tsagentspec/src/tracing/base.ts b/tsagentspec/src/tracing/base.ts new file mode 100644 index 00000000..d5e5f6b9 --- /dev/null +++ b/tsagentspec/src/tracing/base.ts @@ -0,0 +1,183 @@ +/** + * Serialization core for the tracing package. + * + * Mirrors Python's `pyagentspec.tracing._basemodel.BaseModelWithSensitiveInfo`: + * every Span and Event serializes with sensitive payload fields masked by + * default, embedded Agent Spec components serialized through the regular + * serialization machinery pinned to the CURRENT spec version (so component + * level sensitive fields such as `LlmConfig.api_key` are redacted), and a + * `type` discriminant carrying the class name appended to the dump. + */ +import { CURRENT_VERSION } from "../versioning.js"; +import { isComponent } from "../component.js"; +import { SerializationContext, camelToSnake } from "../serialization/serialization-context.js"; +import { BuiltinsComponentSerializationPlugin } from "../serialization/builtin-serialization-plugin.js"; +import { DANGEROUS_KEYS } from "../serialization/types.js"; +import { Message, ToolCall } from "./message.js"; + +/** Placeholder value replacing sensitive fields in masked dumps (Python `_PII_MASK`). */ +export const PII_MASK = "** MASKED **"; + +/** Maximum recursion depth when dumping tracing payloads. */ +const MAX_TRACING_DUMP_DEPTH = 100; + +/** + * Sensitive payload fields per tracing type (Python `SensitiveField` markers). + * Field names are the in-memory camelCase names; they are masked wholesale + * with {@link PII_MASK} on serialization unless masking is explicitly opted + * out of. Types without sensitive fields are omitted. + */ +export const TRACING_SENSITIVE_FIELDS: Readonly>> = { + ExceptionRaised: new Set(["exceptionMessage", "exceptionStacktrace"]), + AgentExecutionStart: new Set(["inputs"]), + AgentExecutionEnd: new Set(["outputs"]), + FlowExecutionStart: new Set(["inputs"]), + FlowExecutionEnd: new Set(["outputs"]), + NodeExecutionStart: new Set(["inputs"]), + NodeExecutionEnd: new Set(["outputs"]), + ManagerWorkersExecutionStart: new Set(["inputs"]), + ManagerWorkersExecutionEnd: new Set(["outputs"]), + SwarmExecutionStart: new Set(["inputs"]), + SwarmExecutionEnd: new Set(["outputs"]), + HumanInTheLoopRequest: new Set(["content"]), + HumanInTheLoopResponse: new Set(["content"]), + LlmGenerationRequest: new Set(["prompt"]), + LlmGenerationResponse: new Set(["content", "toolCalls"]), + LlmGenerationChunkReceived: new Set(["content", "toolCalls"]), + ToolExecutionRequest: new Set(["inputs"]), + ToolExecutionResponse: new Set(["outputs"]), + ToolExecutionStreamingChunkReceived: new Set(["content"]), + StateSnapshotEmitted: new Set(["stateSnapshot", "extraState"]), +}; + +/** + * Fields holding plain model objects (not components, not user data) whose + * keys must be converted to snake_case with unset values excluded, matching + * Python's `LlmGenerationConfig.model_dump(exclude_none=True)`. + */ +const TRACING_MODEL_OBJECT_FIELDS: Readonly>> = { + LlmGenerationRequest: new Set(["llmGenerationConfig"]), +}; + +/** + * In-memory bookkeeping fields never included in serialized dumps + * (Python models them as pydantic private attributes). + */ +const EXCLUDED_TRACING_FIELDS: ReadonlySet = new Set(["parentSpan"]); + +export interface TracingSerializeOptions { + /** + * Whether sensitive payload fields are replaced with {@link PII_MASK}. + * Defaults to `true`; pass `false` only as an explicit opt-out (mirrors + * Python's `model_dump(mask_sensitive_information=...)`). + */ + maskSensitiveInformation?: boolean; +} + +/** + * Base class of all tracing spans and events. + * + * Provides {@link serialize}: dumps own fields with snake_case wire names, + * masks sensitive fields by default, embeds Agent Spec components through the + * regular serialization context pinned to {@link CURRENT_VERSION}, and appends + * the `type` discriminant. Do not log or serialize spans/events outside + * `serialize()` — the in-memory objects hold unmasked payloads. + */ +export abstract class TracingSerializable { + /** The tracing class name, used as the `type` discriminant on the wire. */ + abstract get type(): string; + + serialize(options?: TracingSerializeOptions): Record { + const mask = options?.maskSensitiveInformation ?? true; + // Fresh context per dump, pinned to the current spec version — mirrors + // Python's `_TracingSerializationContextImpl` (component-level sensitive + // fields stay redacted: `includeSensitiveFields` is never set here). + const context = new SerializationContext( + [new BuiltinsComponentSerializationPlugin()], + { targetVersion: CURRENT_VERSION }, + ); + const sensitiveFields = TRACING_SENSITIVE_FIELDS[this.type]; + const modelObjectFields = TRACING_MODEL_OBJECT_FIELDS[this.type]; + + const serialized: Record = {}; + for (const [fieldName, value] of Object.entries(this)) { + if (fieldName.startsWith("_") || EXCLUDED_TRACING_FIELDS.has(fieldName)) { + continue; + } + const wireName = camelToSnake(fieldName); + if (mask && sensitiveFields?.has(fieldName)) { + serialized[wireName] = PII_MASK; + continue; + } + if ( + modelObjectFields?.has(fieldName) && + value !== null && + typeof value === "object" && + !Array.isArray(value) + ) { + serialized[wireName] = context.dumpModelObject( + value as Record, + /* excludeNulls */ true, + ); + continue; + } + serialized[wireName] = dumpTracingValue(value, context, mask); + } + serialized["type"] = this.type; + return serialized; + } +} + +/** + * Dump a single tracing field value. + * + * Nested spans/events propagate the masking flag (a deliberate hardening over + * Python, where nested events inside a span dump bypass the masking override); + * Message/ToolCall models dump to their fixed wire shapes; components go + * through the serialization context; plain arrays/objects are carried through + * with their keys preserved. + */ +function dumpTracingValue( + value: unknown, + context: SerializationContext, + mask: boolean, + depth = 0, +): unknown { + if (depth > MAX_TRACING_DUMP_DEPTH) { + throw new Error( + `Tracing serialization nesting depth exceeds maximum of ${MAX_TRACING_DUMP_DEPTH}`, + ); + } + if (value === null || value === undefined) { + return null; + } + if (value instanceof TracingSerializable) { + return value.serialize({ maskSensitiveInformation: mask }); + } + if (value instanceof Message || value instanceof ToolCall) { + return value.toWireDict(); + } + if (isComponent(value)) { + return context.dumpComponentToDict(value); + } + if (Array.isArray(value)) { + return value.map((item) => dumpTracingValue(item, context, mask, depth + 1)); + } + if (typeof value === "object") { + const result: Record = {}; + for (const [key, item] of Object.entries(value as Record)) { + if (DANGEROUS_KEYS.has(key)) continue; + result[key] = dumpTracingValue(item, context, mask, depth + 1); + } + return result; + } + return value; +} + +/** + * Current timestamp in nanoseconds since the Unix epoch (Python + * `time.time_ns()`; sub-millisecond digits are always zero in JS). + */ +export function nowNs(): number { + return Date.now() * 1e6; +} diff --git a/tsagentspec/src/tracing/context.ts b/tsagentspec/src/tracing/context.ts new file mode 100644 index 00000000..bb778663 --- /dev/null +++ b/tsagentspec/src/tracing/context.ts @@ -0,0 +1,88 @@ +/** + * Ambient trace/span context (port of Python's contextvars machinery). + * + * Python keeps two ContextVars: `_TRACE` (the ambient Trace) and + * `_ACTIVE_SPAN_STACK` (the stack of active spans, copy-on-write). In + * async-only JS both live in a single AsyncLocalStorage store; when no store + * is active (plain sequential code without `run()` scoping), a module-level + * fallback store is used. + * + * Mutations replace the stack array on the current store (never mutate it in + * place), so a forked child context — created by `Trace.run` / `Span.run` — + * holding its own copied stack stays isolated from parallel branches, while + * sequential `start()`/`end()` calls in the same context observe each other. + */ +import { AsyncLocalStorage } from "node:async_hooks"; +import type { Span } from "./spans/span.js"; +import type { Trace } from "./trace.js"; + +interface TraceContextStore { + trace: Trace | undefined; + spanStack: readonly Span[]; +} + +const storage = new AsyncLocalStorage(); + +/** Fallback store for code running outside any `run()`-scoped context. */ +const moduleStore: TraceContextStore = { trace: undefined, spanStack: [] }; + +function currentStore(): TraceContextStore { + return storage.getStore() ?? moduleStore; +} + +/** + * Get the Trace object active in the current context. + * + * @returns The active Trace object, or `undefined` when no trace is active. + */ +export function getTrace(): Trace | undefined { + return currentStore().trace; +} + +/** + * Retrieve the stack of active spans in this context. + * + * @returns A copy of the stack of active spans in this context. + */ +export function getActiveSpanStack(): Span[] { + return [...currentStore().spanStack]; +} + +/** + * Retrieve the currently active span in this context. + * + * @returns The active span in this context, or `undefined` when none is active. + */ +export function getCurrentSpan(): Span | undefined { + const spanStack = currentStore().spanStack; + return spanStack.length > 0 ? spanStack[spanStack.length - 1] : undefined; +} + +/** @internal Set (or clear) the ambient trace on the current context. */ +export function setAmbientTrace(trace: Trace | undefined): void { + currentStore().trace = trace; +} + +/** @internal Push a span onto the active stack (copy-on-write). */ +export function appendSpanToActiveStack(span: Span): void { + const store = currentStore(); + store.spanStack = [...store.spanStack, span]; +} + +/** @internal Pop the top span from the active stack (copy-on-write). */ +export function popSpanFromActiveStack(): void { + const store = currentStore(); + store.spanStack = store.spanStack.slice(0, -1); +} + +/** + * @internal Run `fn` in a forked child context seeded with the current trace + * and a copy of the current span stack. Mutations inside the child (span + * pushes/pops, trace set/clear) are invisible to the parent and to parallel + * sibling branches — the JS equivalent of asyncio tasks copying the Python + * context at creation time. + */ +export function runInChildContext(fn: () => T): T { + const store = currentStore(); + return storage.run({ trace: store.trace, spanStack: [...store.spanStack] }, fn); +} diff --git a/tsagentspec/src/tracing/events/agent.ts b/tsagentspec/src/tracing/events/agent.ts new file mode 100644 index 00000000..64382946 --- /dev/null +++ b/tsagentspec/src/tracing/events/agent.ts @@ -0,0 +1,51 @@ +/** + * Agent execution events (port of `pyagentspec.tracing.events.agent`). + */ +import type { Agent } from "../../agents/index.js"; +import { Event, type EventOptions } from "./event.js"; + +export interface AgentExecutionStartOptions extends EventOptions { + /** The Agent being executed */ + agent: Agent; + /** The inputs used for the agent's execution, one per property defined in agent's inputs (sensitive) */ + inputs: Record; +} + +/** The execution of an agent is starting. Emitted when an AgentExecutionSpan starts. */ +export class AgentExecutionStart extends Event { + agent: Agent; + inputs: Record; + + override get type(): string { + return "AgentExecutionStart"; + } + + constructor(options: AgentExecutionStartOptions) { + super(options); + this.agent = options.agent; + this.inputs = options.inputs; + } +} + +export interface AgentExecutionEndOptions extends EventOptions { + /** The Agent being executed */ + agent: Agent; + /** The outputs generated by the agent's execution, one per property defined in agent's outputs (sensitive) */ + outputs: Record; +} + +/** The execution of an agent is ending. Emitted when an AgentExecutionSpan ends. */ +export class AgentExecutionEnd extends Event { + agent: Agent; + outputs: Record; + + override get type(): string { + return "AgentExecutionEnd"; + } + + constructor(options: AgentExecutionEndOptions) { + super(options); + this.agent = options.agent; + this.outputs = options.outputs; + } +} diff --git a/tsagentspec/src/tracing/events/event.ts b/tsagentspec/src/tracing/events/event.ts new file mode 100644 index 00000000..d01accc7 --- /dev/null +++ b/tsagentspec/src/tracing/events/event.ts @@ -0,0 +1,44 @@ +/** + * Event base class (port of `pyagentspec.tracing.events.event.Event`). + */ +import { TracingSerializable, nowNs } from "../base.js"; + +export interface EventOptions { + /** A unique identifier for the event */ + id?: string; + /** The name of the event. If not provided, the event class name is used. */ + name?: string; + /** The description of the event. */ + description?: string; + /** The timestamp of when the event occurred (ns since the Unix epoch) */ + timestamp?: number; + /** Metadata related to the event */ + metadata?: Record; +} + +export class Event extends TracingSerializable { + /** A unique identifier for the event */ + readonly id: string; + /** The name of the event. Defaults to the event class name. */ + name: string; + /** The description of the event. */ + description: string; + /** The timestamp of when the event occurred (ns since the Unix epoch) */ + timestamp: number; + /** Metadata related to the event */ + metadata: Record; + + get type(): string { + return "Event"; + } + + constructor(options: EventOptions = {}) { + super(); + this.id = options.id ?? crypto.randomUUID(); + // Like Python's model_post_init: a falsy name defaults to the class name. + this.name = options.name || this.type; + this.description = options.description ?? ""; + this.timestamp = options.timestamp ?? nowNs(); + this.metadata = options.metadata ?? {}; + } +} diff --git a/tsagentspec/src/tracing/events/exception.ts b/tsagentspec/src/tracing/events/exception.ts new file mode 100644 index 00000000..ece00df3 --- /dev/null +++ b/tsagentspec/src/tracing/events/exception.ts @@ -0,0 +1,51 @@ +/** + * ExceptionRaised event (port of `pyagentspec.tracing.events.exception`). + */ +import { Event, type EventOptions } from "./event.js"; + +export interface ExceptionRaisedOptions extends EventOptions { + /** Type of the exception */ + exceptionType: string; + /** Message of the exception (sensitive) */ + exceptionMessage: string; + /** Stacktrace of the exception (sensitive) */ + exceptionStacktrace?: string; +} + +/** This event is recorded whenever an exception occurs. */ +export class ExceptionRaised extends Event { + exceptionType: string; + exceptionMessage: string; + exceptionStacktrace: string; + + override get type(): string { + return "ExceptionRaised"; + } + + constructor(options: ExceptionRaisedOptions) { + super(options); + this.exceptionType = options.exceptionType; + this.exceptionMessage = options.exceptionMessage; + this.exceptionStacktrace = options.exceptionStacktrace ?? ""; + } +} + +/** + * Build an ExceptionRaised event from a caught value, mirroring how Python's + * `Span.__exit__` records `exc_type.__name__` / `str(exc_value)` / the + * formatted traceback (empty when no stacktrace is available). + */ +export function exceptionRaisedFromError(error: unknown): ExceptionRaised { + if (error instanceof Error) { + return new ExceptionRaised({ + exceptionType: error.name || "Error", + exceptionMessage: error.message, + exceptionStacktrace: error.stack ?? "", + }); + } + return new ExceptionRaised({ + exceptionType: "Unknown", + exceptionMessage: String(error), + exceptionStacktrace: "", + }); +} diff --git a/tsagentspec/src/tracing/events/flow.ts b/tsagentspec/src/tracing/events/flow.ts new file mode 100644 index 00000000..b41816cf --- /dev/null +++ b/tsagentspec/src/tracing/events/flow.ts @@ -0,0 +1,55 @@ +/** + * Flow execution events (port of `pyagentspec.tracing.events.flow`). + */ +import type { Flow } from "../../flows/index.js"; +import { Event, type EventOptions } from "./event.js"; + +export interface FlowExecutionStartOptions extends EventOptions { + /** The Flow being executed */ + flow: Flow; + /** The inputs used for the flow's execution, one per property defined in flow's inputs (sensitive) */ + inputs: Record; +} + +/** The execution of a flow is starting. Emitted when a FlowExecutionSpan starts. */ +export class FlowExecutionStart extends Event { + flow: Flow; + inputs: Record; + + override get type(): string { + return "FlowExecutionStart"; + } + + constructor(options: FlowExecutionStartOptions) { + super(options); + this.flow = options.flow; + this.inputs = options.inputs; + } +} + +export interface FlowExecutionEndOptions extends EventOptions { + /** The Flow being executed */ + flow: Flow; + /** The outputs generated by the flow's execution, one per property defined in flow's outputs (sensitive) */ + outputs: Record; + /** The exit branch selected at the end of the Flow's execution */ + branchSelected: string; +} + +/** The execution of a flow is ending. Emitted when a FlowExecutionSpan ends. */ +export class FlowExecutionEnd extends Event { + flow: Flow; + outputs: Record; + branchSelected: string; + + override get type(): string { + return "FlowExecutionEnd"; + } + + constructor(options: FlowExecutionEndOptions) { + super(options); + this.flow = options.flow; + this.outputs = options.outputs; + this.branchSelected = options.branchSelected; + } +} diff --git a/tsagentspec/src/tracing/events/human-in-the-loop.ts b/tsagentspec/src/tracing/events/human-in-the-loop.ts new file mode 100644 index 00000000..c1ed2d44 --- /dev/null +++ b/tsagentspec/src/tracing/events/human-in-the-loop.ts @@ -0,0 +1,50 @@ +/** + * Human-in-the-loop events (port of `pyagentspec.tracing.events.humanintheloop`). + */ +import { Event, type EventOptions } from "./event.js"; + +export interface HumanInTheLoopRequestOptions extends EventOptions { + /** Identifier of the human-in-the-loop request */ + requestId: string; + /** The content of the request forwarded to the user (sensitive) */ + content: Record; +} + +/** A human-in-the-loop (HITL) intervention is required. Emitted when the execution is interrupted due to HITL request. */ +export class HumanInTheLoopRequest extends Event { + requestId: string; + content: Record; + + override get type(): string { + return "HumanInTheLoopRequest"; + } + + constructor(options: HumanInTheLoopRequestOptions) { + super(options); + this.requestId = options.requestId; + this.content = options.content; + } +} + +export interface HumanInTheLoopResponseOptions extends EventOptions { + /** Identifier of the human-in-the-loop request */ + requestId: string; + /** The content of the response received from the user (sensitive) */ + content: Record; +} + +/** A human-in-the-loop response is provided. Emitted when the execution restarts after HITL response. */ +export class HumanInTheLoopResponse extends Event { + requestId: string; + content: Record; + + override get type(): string { + return "HumanInTheLoopResponse"; + } + + constructor(options: HumanInTheLoopResponseOptions) { + super(options); + this.requestId = options.requestId; + this.content = options.content; + } +} diff --git a/tsagentspec/src/tracing/events/index.ts b/tsagentspec/src/tracing/events/index.ts new file mode 100644 index 00000000..b1f1e851 --- /dev/null +++ b/tsagentspec/src/tracing/events/index.ts @@ -0,0 +1,68 @@ +/** + * Tracing events barrel (mirrors `pyagentspec.tracing.events.__init__`). + */ +export { Event, type EventOptions } from "./event.js"; +export { + ExceptionRaised, + exceptionRaisedFromError, + type ExceptionRaisedOptions, +} from "./exception.js"; +export { + AgentExecutionStart, + AgentExecutionEnd, + type AgentExecutionStartOptions, + type AgentExecutionEndOptions, +} from "./agent.js"; +export { + FlowExecutionStart, + FlowExecutionEnd, + type FlowExecutionStartOptions, + type FlowExecutionEndOptions, +} from "./flow.js"; +export { + NodeExecutionStart, + NodeExecutionEnd, + type NodeExecutionStartOptions, + type NodeExecutionEndOptions, +} from "./node.js"; +export { + ManagerWorkersExecutionStart, + ManagerWorkersExecutionEnd, + type ManagerWorkersExecutionStartOptions, + type ManagerWorkersExecutionEndOptions, +} from "./manager-workers.js"; +export { + SwarmExecutionStart, + SwarmExecutionEnd, + type SwarmExecutionStartOptions, + type SwarmExecutionEndOptions, +} from "./swarm.js"; +export { + HumanInTheLoopRequest, + HumanInTheLoopResponse, + type HumanInTheLoopRequestOptions, + type HumanInTheLoopResponseOptions, +} from "./human-in-the-loop.js"; +export { + LlmGenerationRequest, + LlmGenerationResponse, + LlmGenerationChunkReceived, + ToolCall, + type LlmGenerationRequestOptions, + type LlmGenerationResponseOptions, + type LlmGenerationChunkReceivedOptions, + type ToolCallOptions, +} from "./llm-generation.js"; +export { + ToolExecutionRequest, + ToolExecutionResponse, + ToolConfirmationRequest, + ToolConfirmationResponse, + ToolExecutionStreamingChunkReceived, + type ToolExecutionRequestOptions, + type ToolExecutionResponseOptions, + type ToolConfirmationRequestOptions, + type ToolConfirmationResponseOptions, + type ToolExecutionStreamingChunkReceivedOptions, +} from "./tool.js"; +export { StateSnapshotEmitted, type StateSnapshotEmittedOptions } from "./state.js"; diff --git a/tsagentspec/src/tracing/events/llm-generation.ts b/tsagentspec/src/tracing/events/llm-generation.ts new file mode 100644 index 00000000..feb5055b --- /dev/null +++ b/tsagentspec/src/tracing/events/llm-generation.ts @@ -0,0 +1,126 @@ +/** + * LLM generation events (port of `pyagentspec.tracing.events.llmgeneration`). + */ +import type { LlmConfig, LlmGenerationConfig } from "../../llms/index.js"; +import type { Tool } from "../../tools/index.js"; +import { Message, ToolCall, type ToolCallOptions } from "../message.js"; +import { Event, type EventOptions } from "./event.js"; + +export { ToolCall, type ToolCallOptions }; + +export interface LlmGenerationRequestOptions extends EventOptions { + /** The LlmConfig that performs the generation */ + llmConfig: LlmConfig; + /** The content of the prompt that will be sent to the LLM (sensitive) */ + prompt: Message[]; + /** The list of tools sent as part of the generation request */ + tools: Tool[]; + /** Identifier of the generation request */ + requestId: string; + /** The LLM configuration used for this LLM call */ + llmGenerationConfig?: LlmGenerationConfig | null; +} + +/** An LLM generation request was received. Start of the LlmGenerationSpan. */ +export class LlmGenerationRequest extends Event { + llmConfig: LlmConfig; + prompt: Message[]; + tools: Tool[]; + requestId: string; + llmGenerationConfig: LlmGenerationConfig | null; + + override get type(): string { + return "LlmGenerationRequest"; + } + + constructor(options: LlmGenerationRequestOptions) { + super(options); + this.llmConfig = options.llmConfig; + this.prompt = options.prompt; + this.tools = options.tools; + this.requestId = options.requestId; + this.llmGenerationConfig = options.llmGenerationConfig ?? null; + } +} + +export interface LlmGenerationResponseOptions extends EventOptions { + /** The LlmConfig that performed the generation */ + llmConfig: LlmConfig; + /** The content of the response received from the LLM (sensitive) */ + content: string | null; + /** The list of tool calls that should be performed, received as part of the generation response (sensitive) */ + toolCalls?: ToolCall[]; + /** Identifier of the generation request */ + requestId: string; + /** The identifier of the completion related to this response */ + completionId?: string | null; + /** Number of input tokens */ + inputTokens?: number | null; + /** Number of output tokens */ + outputTokens?: number | null; +} + +/** An LLM response was received. End of an LlmGenerationSpan. */ +export class LlmGenerationResponse extends Event { + llmConfig: LlmConfig; + content: string | null; + toolCalls: ToolCall[]; + requestId: string; + completionId: string | null; + inputTokens: number | null; + outputTokens: number | null; + + override get type(): string { + return "LlmGenerationResponse"; + } + + constructor(options: LlmGenerationResponseOptions) { + super(options); + this.llmConfig = options.llmConfig; + this.content = options.content; + this.toolCalls = options.toolCalls ?? []; + this.requestId = options.requestId; + this.completionId = options.completionId ?? null; + this.inputTokens = options.inputTokens ?? null; + this.outputTokens = options.outputTokens ?? null; + } +} + +export interface LlmGenerationChunkReceivedOptions extends EventOptions { + /** The LlmConfig that performs the generation */ + llmConfig: LlmConfig; + /** The content of the chunk received from the LLM (sensitive) */ + content: string | null; + /** Identifier of the generation request */ + requestId: string; + /** The list of tool calls that should be performed, received as part of the generation response chunk (sensitive) */ + toolCalls?: ToolCall[]; + /** The identifier of the completion related to this response chunk */ + completionId?: string | null; + /** Number of output tokens for this chunk */ + outputTokens?: number | null; +} + +/** A chunk of an LLM response was received during streaming generation. */ +export class LlmGenerationChunkReceived extends Event { + llmConfig: LlmConfig; + content: string | null; + requestId: string; + toolCalls: ToolCall[]; + completionId: string | null; + outputTokens: number | null; + + override get type(): string { + return "LlmGenerationChunkReceived"; + } + + constructor(options: LlmGenerationChunkReceivedOptions) { + super(options); + this.llmConfig = options.llmConfig; + this.content = options.content; + this.requestId = options.requestId; + this.toolCalls = options.toolCalls ?? []; + this.completionId = options.completionId ?? null; + this.outputTokens = options.outputTokens ?? null; + } +} diff --git a/tsagentspec/src/tracing/events/manager-workers.ts b/tsagentspec/src/tracing/events/manager-workers.ts new file mode 100644 index 00000000..665acb4a --- /dev/null +++ b/tsagentspec/src/tracing/events/manager-workers.ts @@ -0,0 +1,51 @@ +/** + * ManagerWorkers execution events (port of `pyagentspec.tracing.events.managerworkers`). + */ +import type { ManagerWorkers } from "../../agents/index.js"; +import { Event, type EventOptions } from "./event.js"; + +export interface ManagerWorkersExecutionStartOptions extends EventOptions { + /** The ManagerWorkers being executed */ + managerworkers: ManagerWorkers; + /** The inputs used for the manager-workers's execution, one per property defined in manager-workers's inputs (sensitive) */ + inputs: Record; +} + +/** The execution of a manager-workers is starting. Emitted when a ManagerWorkersExecutionSpan starts. */ +export class ManagerWorkersExecutionStart extends Event { + managerworkers: ManagerWorkers; + inputs: Record; + + override get type(): string { + return "ManagerWorkersExecutionStart"; + } + + constructor(options: ManagerWorkersExecutionStartOptions) { + super(options); + this.managerworkers = options.managerworkers; + this.inputs = options.inputs; + } +} + +export interface ManagerWorkersExecutionEndOptions extends EventOptions { + /** The ManagerWorkers being executed */ + managerworkers: ManagerWorkers; + /** The outputs generated by the manager-workers's execution, one per property defined in manager-workers's outputs (sensitive) */ + outputs: Record; +} + +/** The execution of a manager-workers is ending. Emitted when a ManagerWorkersExecutionSpan ends. */ +export class ManagerWorkersExecutionEnd extends Event { + managerworkers: ManagerWorkers; + outputs: Record; + + override get type(): string { + return "ManagerWorkersExecutionEnd"; + } + + constructor(options: ManagerWorkersExecutionEndOptions) { + super(options); + this.managerworkers = options.managerworkers; + this.outputs = options.outputs; + } +} diff --git a/tsagentspec/src/tracing/events/node.ts b/tsagentspec/src/tracing/events/node.ts new file mode 100644 index 00000000..ce3df5c3 --- /dev/null +++ b/tsagentspec/src/tracing/events/node.ts @@ -0,0 +1,55 @@ +/** + * Node execution events (port of `pyagentspec.tracing.events.node`). + */ +import type { Node } from "../../flows/index.js"; +import { Event, type EventOptions } from "./event.js"; + +export interface NodeExecutionStartOptions extends EventOptions { + /** The Node being executed */ + node: Node; + /** The inputs used for the node's execution, one per property defined in node's inputs (sensitive) */ + inputs: Record; +} + +/** The execution of a node is starting. Emitted when a NodeExecutionSpan starts. */ +export class NodeExecutionStart extends Event { + node: Node; + inputs: Record; + + override get type(): string { + return "NodeExecutionStart"; + } + + constructor(options: NodeExecutionStartOptions) { + super(options); + this.node = options.node; + this.inputs = options.inputs; + } +} + +export interface NodeExecutionEndOptions extends EventOptions { + /** The Node being executed */ + node: Node; + /** The outputs generated by the node's execution, one per property defined in node's outputs (sensitive) */ + outputs: Record; + /** The exit branch selected at the end of the Node's execution */ + branchSelected: string; +} + +/** The execution of a node is ending. Emitted when a NodeExecutionSpan ends. */ +export class NodeExecutionEnd extends Event { + node: Node; + outputs: Record; + branchSelected: string; + + override get type(): string { + return "NodeExecutionEnd"; + } + + constructor(options: NodeExecutionEndOptions) { + super(options); + this.node = options.node; + this.outputs = options.outputs; + this.branchSelected = options.branchSelected; + } +} diff --git a/tsagentspec/src/tracing/events/state.ts b/tsagentspec/src/tracing/events/state.ts new file mode 100644 index 00000000..01ba98e2 --- /dev/null +++ b/tsagentspec/src/tracing/events/state.ts @@ -0,0 +1,93 @@ +/** + * StateSnapshotEmitted event (port of `pyagentspec.tracing.events.state`). + */ +import { Event, type EventOptions } from "./event.js"; + +/** + * Validate that a state snapshot payload can be encoded as strict JSON. + * + * Mirrors Python's `json.dumps(payload, allow_nan=False)` check: NaN/Infinity + * and non-JSON values are rejected. Snapshot payloads are forwarded through + * JSON-based transports and may later be stored and replayed for + * resumability; if NaN/Infinity were accepted here, later JSON encoding could + * silently coerce them (for example to `null`), breaking the expectation that + * the runtime snapshot payload is carried through unchanged. + */ +function validateJsonSerializablePayload( + payloadName: string, + payload: Record | null, +): void { + if (payload === null) return; + if (!isStrictJsonValue(payload)) { + throw new Error(`${payloadName} must be JSON-serializable`); + } +} + +function isStrictJsonValue(value: unknown): boolean { + if (value === null) return true; + switch (typeof value) { + case "string": + case "boolean": + return true; + case "number": + return Number.isFinite(value); + case "object": { + if (Array.isArray(value)) { + return value.every(isStrictJsonValue); + } + const proto = Object.getPrototypeOf(value); + if (proto !== Object.prototype && proto !== null) { + // Class instances, Maps, Dates, ... are not strict-JSON payloads + // (Python's json.dumps rejects arbitrary objects the same way). + return false; + } + return Object.values(value as Record).every(isStrictJsonValue); + } + default: + // undefined, function, symbol, bigint + return false; + } +} + +export interface StateSnapshotEmittedOptions extends EventOptions { + /** Stable identifier of the logical conversation or thread this snapshot refers to. */ + conversationId: string; + /** + * Runtime-defined JSON-serializable snapshot content for the current state + * (sensitive). This payload may contain opaque runtime-owned state needed + * for resuming or reconstructing execution later. + */ + stateSnapshot?: Record | null; + /** Developer-defined JSON-serializable state such as UI or application state (sensitive). */ + extraState?: Record | null; +} + +/** + * A runtime emits a state snapshot for downstream consumers. + * + * This event carries a JSON-serializable snapshot of the current logical + * conversation or thread state. The exact schema of `stateSnapshot` is + * intentionally runtime-defined. + */ +export class StateSnapshotEmitted extends Event { + conversationId: string; + stateSnapshot: Record | null; + extraState: Record | null; + + override get type(): string { + return "StateSnapshotEmitted"; + } + + constructor(options: StateSnapshotEmittedOptions) { + super(options); + this.conversationId = options.conversationId; + this.stateSnapshot = options.stateSnapshot ?? null; + this.extraState = options.extraState ?? null; + + if (this.stateSnapshot === null && this.extraState === null) { + throw new Error("At least one of state_snapshot or extra_state must be provided"); + } + validateJsonSerializablePayload("state_snapshot", this.stateSnapshot); + validateJsonSerializablePayload("extra_state", this.extraState); + } +} diff --git a/tsagentspec/src/tracing/events/swarm.ts b/tsagentspec/src/tracing/events/swarm.ts new file mode 100644 index 00000000..4f1d2130 --- /dev/null +++ b/tsagentspec/src/tracing/events/swarm.ts @@ -0,0 +1,51 @@ +/** + * Swarm execution events (port of `pyagentspec.tracing.events.swarm`). + */ +import type { Swarm } from "../../agents/index.js"; +import { Event, type EventOptions } from "./event.js"; + +export interface SwarmExecutionStartOptions extends EventOptions { + /** The Swarm being executed */ + swarm: Swarm; + /** The inputs used for the swarm's execution, one per property defined in swarm's inputs (sensitive) */ + inputs: Record; +} + +/** The execution of a swarm is starting. Emitted when a SwarmExecutionSpan starts. */ +export class SwarmExecutionStart extends Event { + swarm: Swarm; + inputs: Record; + + override get type(): string { + return "SwarmExecutionStart"; + } + + constructor(options: SwarmExecutionStartOptions) { + super(options); + this.swarm = options.swarm; + this.inputs = options.inputs; + } +} + +export interface SwarmExecutionEndOptions extends EventOptions { + /** The Swarm being executed */ + swarm: Swarm; + /** The outputs generated by the swarm's execution, one per property defined in swarm's outputs (sensitive) */ + outputs: Record; +} + +/** The execution of a swarm is ending. Emitted when a SwarmExecutionSpan ends. */ +export class SwarmExecutionEnd extends Event { + swarm: Swarm; + outputs: Record; + + override get type(): string { + return "SwarmExecutionEnd"; + } + + constructor(options: SwarmExecutionEndOptions) { + super(options); + this.swarm = options.swarm; + this.outputs = options.outputs; + } +} diff --git a/tsagentspec/src/tracing/events/tool.ts b/tsagentspec/src/tracing/events/tool.ts new file mode 100644 index 00000000..cd6e2c5e --- /dev/null +++ b/tsagentspec/src/tracing/events/tool.ts @@ -0,0 +1,144 @@ +/** + * Tool execution events (port of `pyagentspec.tracing.events.tool`). + */ +import type { Tool } from "../../tools/index.js"; +import { Event, type EventOptions } from "./event.js"; + +export interface ToolExecutionRequestOptions extends EventOptions { + /** The Tool being executed */ + tool: Tool; + /** The input values that should be used to execute the tool, one per property defined in tool's inputs (sensitive) */ + inputs: Record; + /** Identifier of the tool execution request */ + requestId: string; +} + +/** A tool execution request is received. Emitted when a ToolExecutionSpan starts, or a client tool is called. */ +export class ToolExecutionRequest extends Event { + tool: Tool; + inputs: Record; + requestId: string; + + override get type(): string { + return "ToolExecutionRequest"; + } + + constructor(options: ToolExecutionRequestOptions) { + super(options); + this.tool = options.tool; + this.inputs = options.inputs; + this.requestId = options.requestId; + } +} + +export interface ToolExecutionResponseOptions extends EventOptions { + /** The Tool being executed */ + tool: Tool; + /** The return value generated by the tool's execution, one per property defined in tool's outputs (sensitive) */ + outputs: Record; + /** Identifier of the tool execution request */ + requestId: string; +} + +/** A tool execution finishes and a result is received. Raised when a ToolExecutionSpan ends, or a client tool result is received. */ +export class ToolExecutionResponse extends Event { + tool: Tool; + outputs: Record; + requestId: string; + + override get type(): string { + return "ToolExecutionResponse"; + } + + constructor(options: ToolExecutionResponseOptions) { + super(options); + this.tool = options.tool; + this.outputs = options.outputs; + this.requestId = options.requestId; + } +} + +export interface ToolConfirmationRequestOptions extends EventOptions { + /** The Tool being executed */ + tool: Tool; + /** Identifier of the confirmation request */ + requestId: string; + /** Identifier of the tool execution request this confirmation relates to */ + toolExecutionRequestId?: string | null; +} + +/** A tool confirmation request is raised. */ +export class ToolConfirmationRequest extends Event { + tool: Tool; + requestId: string; + toolExecutionRequestId: string | null; + + override get type(): string { + return "ToolConfirmationRequest"; + } + + constructor(options: ToolConfirmationRequestOptions) { + super(options); + this.tool = options.tool; + this.requestId = options.requestId; + this.toolExecutionRequestId = options.toolExecutionRequestId ?? null; + } +} + +export interface ToolConfirmationResponseOptions extends EventOptions { + /** The Tool being executed */ + tool: Tool; + /** Whether the execution of the tool was confirmed */ + executionConfirmed: boolean; + /** Identifier of the confirmation request */ + requestId: string; + /** Identifier of the tool execution request this confirmation relates to */ + toolExecutionRequestId?: string | null; +} + +/** A tool confirmation response is received. */ +export class ToolConfirmationResponse extends Event { + tool: Tool; + executionConfirmed: boolean; + requestId: string; + toolExecutionRequestId: string | null; + + override get type(): string { + return "ToolConfirmationResponse"; + } + + constructor(options: ToolConfirmationResponseOptions) { + super(options); + this.tool = options.tool; + this.executionConfirmed = options.executionConfirmed; + this.requestId = options.requestId; + this.toolExecutionRequestId = options.toolExecutionRequestId ?? null; + } +} + +export interface ToolExecutionStreamingChunkReceivedOptions extends EventOptions { + /** The Tool being executed */ + tool: Tool; + /** Identifier of the tool execution request */ + requestId: string; + /** A streamed portion of the tool's output emitted during execution (sensitive) */ + content: string; +} + +/** A tool streams a portion of the output during its execution. */ +export class ToolExecutionStreamingChunkReceived extends Event { + tool: Tool; + requestId: string; + content: string; + + override get type(): string { + return "ToolExecutionStreamingChunkReceived"; + } + + constructor(options: ToolExecutionStreamingChunkReceivedOptions) { + super(options); + this.tool = options.tool; + this.requestId = options.requestId; + this.content = options.content; + } +} diff --git a/tsagentspec/src/tracing/index.ts b/tsagentspec/src/tracing/index.ts new file mode 100644 index 00000000..b4e04a8f --- /dev/null +++ b/tsagentspec/src/tracing/index.ts @@ -0,0 +1,84 @@ +/** + * Agent Spec tracing package — async-only port of `pyagentspec.tracing`. + * + * Class and field names match the Python package so span processors written + * against either SDK see the same serialized shapes (snake_case wire fields, + * a `type` discriminant carrying the class name, sensitive payloads masked + * with `** MASKED **` by default). Python's sync/async bridging machinery has + * no JS equivalent and is intentionally not ported. + */ +export { PII_MASK, TracingSerializable, type TracingSerializeOptions } from "./base.js"; +export { Message, type MessageOptions } from "./message.js"; +export { SpanProcessor } from "./span-processor.js"; +export { Trace, type TraceOptions } from "./trace.js"; +export { getTrace, getCurrentSpan, getActiveSpanStack } from "./context.js"; +export { + Span, + RootSpan, + AgentExecutionSpan, + FlowExecutionSpan, + LlmGenerationSpan, + ManagerWorkersExecutionSpan, + NodeExecutionSpan, + SwarmExecutionSpan, + ToolExecutionSpan, + type SpanOptions, + type AgentExecutionSpanOptions, + type FlowExecutionSpanOptions, + type LlmGenerationSpanOptions, + type ManagerWorkersExecutionSpanOptions, + type NodeExecutionSpanOptions, + type SwarmExecutionSpanOptions, + type ToolExecutionSpanOptions, +} from "./spans/index.js"; +export { + Event, + ExceptionRaised, + exceptionRaisedFromError, + AgentExecutionStart, + AgentExecutionEnd, + FlowExecutionStart, + FlowExecutionEnd, + NodeExecutionStart, + NodeExecutionEnd, + ManagerWorkersExecutionStart, + ManagerWorkersExecutionEnd, + SwarmExecutionStart, + SwarmExecutionEnd, + HumanInTheLoopRequest, + HumanInTheLoopResponse, + LlmGenerationRequest, + LlmGenerationResponse, + LlmGenerationChunkReceived, + ToolCall, + ToolExecutionRequest, + ToolExecutionResponse, + ToolConfirmationRequest, + ToolConfirmationResponse, + ToolExecutionStreamingChunkReceived, + StateSnapshotEmitted, + type EventOptions, + type ExceptionRaisedOptions, + type AgentExecutionStartOptions, + type AgentExecutionEndOptions, + type FlowExecutionStartOptions, + type FlowExecutionEndOptions, + type NodeExecutionStartOptions, + type NodeExecutionEndOptions, + type ManagerWorkersExecutionStartOptions, + type ManagerWorkersExecutionEndOptions, + type SwarmExecutionStartOptions, + type SwarmExecutionEndOptions, + type HumanInTheLoopRequestOptions, + type HumanInTheLoopResponseOptions, + type LlmGenerationRequestOptions, + type LlmGenerationResponseOptions, + type LlmGenerationChunkReceivedOptions, + type ToolCallOptions, + type ToolExecutionRequestOptions, + type ToolExecutionResponseOptions, + type ToolConfirmationRequestOptions, + type ToolConfirmationResponseOptions, + type ToolExecutionStreamingChunkReceivedOptions, + type StateSnapshotEmittedOptions, +} from "./events/index.js"; diff --git a/tsagentspec/src/tracing/message.ts b/tsagentspec/src/tracing/message.ts new file mode 100644 index 00000000..41463c0e --- /dev/null +++ b/tsagentspec/src/tracing/message.ts @@ -0,0 +1,76 @@ +/** + * Plain data models carried by tracing events. + * + * `Message` mirrors `pyagentspec.tracing.messages.message.Message`; `ToolCall` + * mirrors `pyagentspec.tracing.events.llmgeneration.ToolCall` (defined here so + * the serialization core can reference both without import cycles, and + * re-exported from `events/llm-generation.ts` to match the Python layout). + * Neither is an Event: their wire dumps carry no `type` discriminant. + */ + +export interface MessageOptions { + /** Identifier of the message */ + id?: string | null; + /** Content of the message */ + content: string; + /** Sender of the message */ + sender?: string | null; + /** Role of the sender of the message. Typically "user", "assistant", or "system" */ + role: string; +} + +/** Model used to specify LLM message details in events and spans */ +export class Message { + id: string | null; + content: string; + sender: string | null; + role: string; + + constructor(options: MessageOptions) { + this.id = options.id ?? null; + this.content = options.content; + this.sender = options.sender ?? null; + this.role = options.role; + } + + /** Wire dump matching Python's `Message.model_dump()`. */ + toWireDict(): Record { + return { + id: this.id, + content: this.content, + sender: this.sender, + role: this.role, + }; + } +} + +export interface ToolCallOptions { + /** Identifier of the tool call */ + callId: string; + /** The name of the tool that should be called */ + toolName: string; + /** The values of the arguments that should be passed to the tool, in JSON format */ + arguments: string; +} + +/** Model for an LLM tool call. */ +export class ToolCall { + callId: string; + toolName: string; + arguments: string; + + constructor(options: ToolCallOptions) { + this.callId = options.callId; + this.toolName = options.toolName; + this.arguments = options.arguments; + } + + /** Wire dump matching Python's `ToolCall.model_dump()`. */ + toWireDict(): Record { + return { + call_id: this.callId, + tool_name: this.toolName, + arguments: this.arguments, + }; + } +} diff --git a/tsagentspec/src/tracing/span-processor.ts b/tsagentspec/src/tracing/span-processor.ts new file mode 100644 index 00000000..ef3fde61 --- /dev/null +++ b/tsagentspec/src/tracing/span-processor.ts @@ -0,0 +1,59 @@ +/** + * SpanProcessor (port of `pyagentspec.tracing.spanprocessor.SpanProcessor`). + * + * Python defines sync and async twins for every hook; async-only JS has a + * single set of hooks that may return either `void` or a `Promise` (the + * callers await them either way). The `NotImplementedError` async-to-sync + * fallback chains are therefore not ported. + */ +import type { Event } from "./events/event.js"; +import type { Span } from "./spans/span.js"; + +/** + * Interface which allows hooks for `Span` start and end method invocations. + * + * Aligned with OpenTelemetry APIs. Processors are registered on a `Trace` + * (`new Trace({ spanProcessors: [...] })`); spans discover them at start time + * through the ambient trace. + */ +export abstract class SpanProcessor { + /** + * Whether this processor masks sensitive information when it serializes + * spans and events. The tracing core never reads this flag; processors use + * it themselves when dumping (`span.serialize({ maskSensitiveInformation: + * this.maskSensitiveInformation })`). Defaults to `true`. + */ + maskSensitiveInformation: boolean; + + constructor(maskSensitiveInformation: boolean = true) { + this.maskSensitiveInformation = maskSensitiveInformation; + } + + /** + * Called when a `Span` is started. + * + * @param span - The span that starts + */ + abstract onStart(span: Span): void | Promise; + + /** + * Called when a `Span` is ended. + * + * @param span - The span that ends + */ + abstract onEnd(span: Span): void | Promise; + + /** + * Called when an `Event` is triggered. + * + * @param event - The event that is happening + * @param span - The span where the event occurs + */ + abstract onEvent(event: Event, span: Span): void | Promise; + + /** Called when a `Trace` is started. */ + abstract startup(): void | Promise; + + /** Called when a `Trace` is shutdown. */ + abstract shutdown(): void | Promise; +} diff --git a/tsagentspec/src/tracing/spans/agent.ts b/tsagentspec/src/tracing/spans/agent.ts new file mode 100644 index 00000000..88206883 --- /dev/null +++ b/tsagentspec/src/tracing/spans/agent.ts @@ -0,0 +1,29 @@ +/** + * AgentExecutionSpan (port of `pyagentspec.tracing.spans.agent`). + */ +import type { Agent } from "../../agents/index.js"; +import { Span, type SpanOptions } from "./span.js"; + +export interface AgentExecutionSpanOptions extends SpanOptions { + /** The Agent being executed */ + agent: Agent; +} + +/** + * Span to represent the execution of an agent. Can be nested when executing sub-agents. + * + * - Starts when: agent execution starts + * - Ends when: the agent execution is completed, and the result is ready to be processed + */ +export class AgentExecutionSpan extends Span { + agent: Agent; + + override get type(): string { + return "AgentExecutionSpan"; + } + + constructor(options: AgentExecutionSpanOptions) { + super(options); + this.agent = options.agent; + } +} diff --git a/tsagentspec/src/tracing/spans/flow.ts b/tsagentspec/src/tracing/spans/flow.ts new file mode 100644 index 00000000..2d45eea5 --- /dev/null +++ b/tsagentspec/src/tracing/spans/flow.ts @@ -0,0 +1,29 @@ +/** + * FlowExecutionSpan (port of `pyagentspec.tracing.spans.flow`). + */ +import type { Flow } from "../../flows/index.js"; +import { Span, type SpanOptions } from "./span.js"; + +export interface FlowExecutionSpanOptions extends SpanOptions { + /** The Flow being executed */ + flow: Flow; +} + +/** + * Span that covers the execution of a Flow. + * + * - Starts when: the StartNode execution of this flow starts + * - Ends when: one of the EndNode executions finishes + */ +export class FlowExecutionSpan extends Span { + flow: Flow; + + override get type(): string { + return "FlowExecutionSpan"; + } + + constructor(options: FlowExecutionSpanOptions) { + super(options); + this.flow = options.flow; + } +} diff --git a/tsagentspec/src/tracing/spans/index.ts b/tsagentspec/src/tracing/spans/index.ts new file mode 100644 index 00000000..f02ad773 --- /dev/null +++ b/tsagentspec/src/tracing/spans/index.ts @@ -0,0 +1,15 @@ +/** + * Tracing spans barrel (mirrors `pyagentspec.tracing.spans.__init__`). + */ +export { Span, type SpanOptions } from "./span.js"; +export { RootSpan } from "./root.js"; +export { AgentExecutionSpan, type AgentExecutionSpanOptions } from "./agent.js"; +export { FlowExecutionSpan, type FlowExecutionSpanOptions } from "./flow.js"; +export { LlmGenerationSpan, type LlmGenerationSpanOptions } from "./llm.js"; +export { + ManagerWorkersExecutionSpan, + type ManagerWorkersExecutionSpanOptions, +} from "./manager-workers.js"; +export { NodeExecutionSpan, type NodeExecutionSpanOptions } from "./node.js"; +export { SwarmExecutionSpan, type SwarmExecutionSpanOptions } from "./swarm.js"; +export { ToolExecutionSpan, type ToolExecutionSpanOptions } from "./tool.js"; diff --git a/tsagentspec/src/tracing/spans/llm.ts b/tsagentspec/src/tracing/spans/llm.ts new file mode 100644 index 00000000..b8d4eefb --- /dev/null +++ b/tsagentspec/src/tracing/spans/llm.ts @@ -0,0 +1,29 @@ +/** + * LlmGenerationSpan (port of `pyagentspec.tracing.spans.llm`). + */ +import type { LlmConfig } from "../../llms/index.js"; +import { Span, type SpanOptions } from "./span.js"; + +export interface LlmGenerationSpanOptions extends SpanOptions { + /** The LlmConfig that performs the generation */ + llmConfig: LlmConfig; +} + +/** + * Span that covers the whole LLM generation process. + * + * - Starts when: the LLM generation request is received and the LLM call is performed + * - Ends when: the LLM output was generated, and it's ready to be processed + */ +export class LlmGenerationSpan extends Span { + llmConfig: LlmConfig; + + override get type(): string { + return "LlmGenerationSpan"; + } + + constructor(options: LlmGenerationSpanOptions) { + super(options); + this.llmConfig = options.llmConfig; + } +} diff --git a/tsagentspec/src/tracing/spans/manager-workers.ts b/tsagentspec/src/tracing/spans/manager-workers.ts new file mode 100644 index 00000000..abc4e509 --- /dev/null +++ b/tsagentspec/src/tracing/spans/manager-workers.ts @@ -0,0 +1,29 @@ +/** + * ManagerWorkersExecutionSpan (port of `pyagentspec.tracing.spans.managerworkers`). + */ +import type { ManagerWorkers } from "../../agents/index.js"; +import { Span, type SpanOptions } from "./span.js"; + +export interface ManagerWorkersExecutionSpanOptions extends SpanOptions { + /** The ManagerWorkers being executed */ + managerworkers: ManagerWorkers; +} + +/** + * Span to represent the execution of a ManagerWorkers. Can be nested when executing sub-agents. + * + * - Starts when: manager-workers pattern execution starts + * - Ends when: the manager-workers execution is completed and the result is ready to be processed + */ +export class ManagerWorkersExecutionSpan extends Span { + managerworkers: ManagerWorkers; + + override get type(): string { + return "ManagerWorkersExecutionSpan"; + } + + constructor(options: ManagerWorkersExecutionSpanOptions) { + super(options); + this.managerworkers = options.managerworkers; + } +} diff --git a/tsagentspec/src/tracing/spans/node.ts b/tsagentspec/src/tracing/spans/node.ts new file mode 100644 index 00000000..c8f1144f --- /dev/null +++ b/tsagentspec/src/tracing/spans/node.ts @@ -0,0 +1,29 @@ +/** + * NodeExecutionSpan (port of `pyagentspec.tracing.spans.node`). + */ +import type { Node } from "../../flows/index.js"; +import { Span, type SpanOptions } from "./span.js"; + +export interface NodeExecutionSpanOptions extends SpanOptions { + /** The Node being executed */ + node: Node; +} + +/** + * Span that covers the execution of a Node. + * + * - Starts when: the node execution starts on the given inputs + * - Ends when: the node execution ends and outputs are ready to be processed + */ +export class NodeExecutionSpan extends Span { + node: Node; + + override get type(): string { + return "NodeExecutionSpan"; + } + + constructor(options: NodeExecutionSpanOptions) { + super(options); + this.node = options.node; + } +} diff --git a/tsagentspec/src/tracing/spans/root.ts b/tsagentspec/src/tracing/spans/root.ts new file mode 100644 index 00000000..6a88911e --- /dev/null +++ b/tsagentspec/src/tracing/spans/root.ts @@ -0,0 +1,16 @@ +/** + * RootSpan (port of `pyagentspec.tracing.spans.root`). + */ +import { Span } from "./span.js"; + +/** + * Span that covers a whole Trace. + * + * - Starts when: a Trace is started + * - Ends when: a Trace is closed + */ +export class RootSpan extends Span { + override get type(): string { + return "RootSpan"; + } +} diff --git a/tsagentspec/src/tracing/spans/span.ts b/tsagentspec/src/tracing/spans/span.ts new file mode 100644 index 00000000..eb1d89c2 --- /dev/null +++ b/tsagentspec/src/tracing/spans/span.ts @@ -0,0 +1,170 @@ +/** + * Span base class (port of `pyagentspec.tracing.spans.span.Span`). + * + * Async-only: the sync/async twin methods collapse into single async + * `start`/`end`/`addEvent`. Python's context-manager protocol is provided by + * {@link Span.run}, which also forks the ambient context so parallel async + * branches keep isolated span stacks. + */ +import { TracingSerializable, nowNs } from "../base.js"; +import { + appendSpanToActiveStack, + getCurrentSpan, + getTrace, + popSpanFromActiveStack, + runInChildContext, +} from "../context.js"; +import type { Event } from "../events/event.js"; +import { exceptionRaisedFromError } from "../events/exception.js"; +import type { SpanProcessor } from "../span-processor.js"; + +export interface SpanOptions { + /** A unique identifier for the span */ + id?: string; + /** The name of the span. If not provided, the span class name is used. */ + name?: string; + /** The description of the span. */ + description?: string; + /** Metadata related to the span */ + metadata?: Record; +} + +export class Span extends TracingSerializable { + /** A unique identifier for the span */ + readonly id: string; + /** The name of the span. Defaults to the span class name. */ + name: string; + /** The description of the span. */ + description: string; + /** The timestamp of when the span was started (ns since the Unix epoch) */ + startTime: number | null = null; + /** The timestamp of when the span was closed (ns since the Unix epoch) */ + endTime: number | null = null; + /** The list of events recorded in the scope of this span */ + readonly events: Event[] = []; + /** Metadata related to the span */ + metadata: Record; + /** The parent span captured from the ambient context when this span started */ + parentSpan: Span | undefined = undefined; + + private _spanWasAppendedToActiveStack = false; + private _startedSpanProcessors: SpanProcessor[] = []; + + get type(): string { + return "Span"; + } + + constructor(options: SpanOptions = {}) { + super(); + this.id = options.id ?? crypto.randomUUID(); + // Like Python's model_post_init: a falsy name defaults to the class name. + this.name = options.name || this.type; + this.description = options.description ?? ""; + this.metadata = options.metadata ?? {}; + } + + /** The list of SpanProcessors to which this Span should be forwarded. */ + private get spanProcessors(): SpanProcessor[] { + return getTrace()?.spanProcessors ?? []; + } + + /** + * Start the span. + * + * This includes calling the `onStart` hook of the active SpanProcessors. + * If any hook throws, the span records an ExceptionRaised event, ends + * (notifying only the processors that were successfully started), never + * enters the active stack, and the error is re-thrown. + */ + async start(): Promise { + try { + this.parentSpan = getCurrentSpan(); + this.startTime = nowNs(); + for (const spanProcessor of this.spanProcessors) { + await spanProcessor.onStart(this); + // We remember which span processors were started, so that we call + // onEnd on them only, e.g., when an exception happens. + this._startedSpanProcessors.push(spanProcessor); + } + appendSpanToActiveStack(this); + this._spanWasAppendedToActiveStack = true; + } catch (error) { + // If anything happens during the recording of the start span, we still + // have to do the work needed to exit the context, including the + // spanProcessors' onEnd call and removing the span from the active stack. + await this.recordException(error); + await this.end(); + throw error; + } + } + + /** + * End the span. + * + * This includes calling the `onEnd` hook of the active SpanProcessors. + * Per-processor errors are caught so every started processor gets its + * `onEnd`; the first caught error is re-thrown after the loop. The span is + * always popped from the active stack if it was appended. + */ + async end(): Promise { + try { + const caughtErrors: unknown[] = []; + this.endTime = nowNs(); + // We call onEnd only on the span processors that were successfully started. + for (const spanProcessor of this._startedSpanProcessors) { + try { + await spanProcessor.onEnd(this); + } catch (error) { + caughtErrors.push(error); + } + } + if (caughtErrors.length > 0) { + throw caughtErrors[0]; + } + } finally { + // Whatever happens, we have to pop the span if it is on the active stack. + if (this._spanWasAppendedToActiveStack) { + popSpanFromActiveStack(); + } + } + } + + /** Add an event to the span and trigger `onEvent` on the started SpanProcessors. */ + async addEvent(event: Event): Promise { + this.events.push(event); + for (const spanProcessor of this._startedSpanProcessors) { + await spanProcessor.onEvent(event, this); + } + } + + /** Record a caught error as an ExceptionRaised event on this span. */ + async recordException(error: unknown): Promise { + await this.addEvent(exceptionRaisedFromError(error)); + } + + /** + * Run `fn` inside this span — the JS equivalent of Python's + * `with Span(...) as span:` — in a forked ambient context: start the span, + * run `fn`, record an ExceptionRaised event if it throws, and always end + * the span. Parallel `run` branches keep isolated span stacks. + */ + async run(fn: (span: this) => Promise | T): Promise { + return runInChildContext(async () => { + await this.start(); + let caughtError: unknown; + let didThrow = false; + try { + return await fn(this); + } catch (error) { + didThrow = true; + caughtError = error; + throw error; + } finally { + if (didThrow) { + await this.recordException(caughtError); + } + await this.end(); + } + }); + } +} diff --git a/tsagentspec/src/tracing/spans/swarm.ts b/tsagentspec/src/tracing/spans/swarm.ts new file mode 100644 index 00000000..2dc5b03c --- /dev/null +++ b/tsagentspec/src/tracing/spans/swarm.ts @@ -0,0 +1,29 @@ +/** + * SwarmExecutionSpan (port of `pyagentspec.tracing.spans.swarm`). + */ +import type { Swarm } from "../../agents/index.js"; +import { Span, type SpanOptions } from "./span.js"; + +export interface SwarmExecutionSpanOptions extends SpanOptions { + /** The Swarm being executed */ + swarm: Swarm; +} + +/** + * Span to represent the execution of a Swarm. Can be nested when executing sub-agents. + * + * - Starts when: swarm pattern execution starts + * - Ends when: the swarm execution is completed and the result is ready to be processed + */ +export class SwarmExecutionSpan extends Span { + swarm: Swarm; + + override get type(): string { + return "SwarmExecutionSpan"; + } + + constructor(options: SwarmExecutionSpanOptions) { + super(options); + this.swarm = options.swarm; + } +} diff --git a/tsagentspec/src/tracing/spans/tool.ts b/tsagentspec/src/tracing/spans/tool.ts new file mode 100644 index 00000000..d0a269fc --- /dev/null +++ b/tsagentspec/src/tracing/spans/tool.ts @@ -0,0 +1,29 @@ +/** + * ToolExecutionSpan (port of `pyagentspec.tracing.spans.tool`). + */ +import type { Tool } from "../../tools/index.js"; +import { Span, type SpanOptions } from "./span.js"; + +export interface ToolExecutionSpanOptions extends SpanOptions { + /** The Tool being executed */ + tool: Tool; +} + +/** + * Span that covers a tool execution. This does not include client tools. + * + * - Starts when: tool execution starts + * - Ends when: the tool execution is completed and the result is ready to be processed + */ +export class ToolExecutionSpan extends Span { + tool: Tool; + + override get type(): string { + return "ToolExecutionSpan"; + } + + constructor(options: ToolExecutionSpanOptions) { + super(options); + this.tool = options.tool; + } +} diff --git a/tsagentspec/src/tracing/trace.ts b/tsagentspec/src/tracing/trace.ts new file mode 100644 index 00000000..28a9754f --- /dev/null +++ b/tsagentspec/src/tracing/trace.ts @@ -0,0 +1,100 @@ +/** + * Trace (port of `pyagentspec.tracing.trace.Trace`). + * + * Async-only: Python's sync/async context-manager pairs collapse into + * `start()`/`end()` plus the `run()` helper. Python's + * `is_async_mode_active` bookkeeping has no JS equivalent and is not ported. + */ +import { runInChildContext, setAmbientTrace, getTrace } from "./context.js"; +import type { SpanProcessor } from "./span-processor.js"; +import { RootSpan } from "./spans/root.js"; +import type { Span } from "./spans/span.js"; + +export { getTrace }; + +export interface TraceOptions { + /** The name of the trace */ + name?: string; + /** A unique identifier for the trace */ + id?: string; + /** The list of SpanProcessors active on this trace */ + spanProcessors?: SpanProcessor[]; + /** Whether to call shutdown on span processors when the trace ends */ + shutdownOnExit?: boolean; + /** The root span of the trace. If not provided, a new RootSpan with default values is used. */ + rootSpan?: Span; +} + +/** + * The root of a collection of Spans. + * + * It is used to group together all the spans and events emitted during the + * execution of an assistant. + */ +export class Trace { + /** The name of the trace */ + name: string; + /** A unique identifier for the trace */ + id: string; + /** The list of SpanProcessors active on this trace */ + spanProcessors: SpanProcessor[]; + /** Whether to call shutdown on span processors when the trace ends */ + shutdownOnExit: boolean; + /** The root span of the trace */ + readonly rootSpan: Span; + + constructor(options: TraceOptions = {}) { + this.name = options.name || "Trace"; + this.id = options.id || crypto.randomUUID(); + this.spanProcessors = options.spanProcessors ?? []; + this.shutdownOnExit = options.shutdownOnExit ?? true; + this.rootSpan = options.rootSpan ?? new RootSpan(); + } + + /** + * Start the trace in the current context: register it as the ambient trace, + * call `startup` on every span processor, then start the root span. Throws + * if a trace is already active in this context. + */ + async start(): Promise { + if (getTrace() !== undefined) { + throw new Error("A Trace already exists. Cannot create two nested Traces."); + } + setAmbientTrace(this); + for (const spanProcessor of this.spanProcessors) { + await spanProcessor.startup(); + } + await this.rootSpan.start(); + } + + /** + * End the trace: end the root span, clear the ambient trace, and — when + * `shutdownOnExit` is set — call `shutdown` on every span processor. + */ + async end(): Promise { + await this.rootSpan.end(); + setAmbientTrace(undefined); + if (this.shutdownOnExit) { + for (const spanProcessor of this.spanProcessors) { + await spanProcessor.shutdown(); + } + } + } + + /** + * Run `fn` inside this trace — the JS equivalent of Python's + * `with Trace(...) as trace:` — in a forked ambient context: start the + * trace, run `fn`, and always end the trace (which, like Python's + * `__exit__`, does not record exceptions on the root span). + */ + async run(fn: (trace: this) => Promise | T): Promise { + return runInChildContext(async () => { + await this.start(); + try { + return await fn(this); + } finally { + await this.end(); + } + }); + } +} diff --git a/tsagentspec/tests/tracing/events.test.ts b/tsagentspec/tests/tracing/events.test.ts new file mode 100644 index 00000000..8876d6d0 --- /dev/null +++ b/tsagentspec/tests/tracing/events.test.ts @@ -0,0 +1,413 @@ +/** + * Port of pyagentspec/tests/tracing/events/test_events.py. + */ +import { describe, expect, it } from "vitest"; +import { + AgentExecutionEnd, + AgentExecutionStart, + ExceptionRaised, + FlowExecutionEnd, + FlowExecutionStart, + HumanInTheLoopRequest, + HumanInTheLoopResponse, + LlmGenerationChunkReceived, + LlmGenerationRequest, + LlmGenerationResponse, + ManagerWorkersExecutionEnd, + ManagerWorkersExecutionStart, + Message, + NodeExecutionEnd, + NodeExecutionStart, + PII_MASK, + SwarmExecutionEnd, + SwarmExecutionStart, + ToolCall, + ToolConfirmationRequest, + ToolConfirmationResponse, + ToolExecutionRequest, + ToolExecutionResponse, + ToolExecutionStreamingChunkReceived, +} from "../../src/index.js"; +import { + dummyAgent, + dummyFlow, + dummyLlmConfig, + dummyManagerWorkers, + dummyNode, + dummySwarm, + dummyTool, +} from "./fixtures.js"; + +describe("tracing events", () => { + // Exception events + it("creates and masks ExceptionRaised", () => { + const event = new ExceptionRaised({ + exceptionType: "ValueError", + exceptionMessage: "bad", + exceptionStacktrace: "trace", + }); + expect(event.exceptionType).toBe("ValueError"); + expect(event.exceptionMessage).toBe("bad"); + expect(typeof event.exceptionStacktrace).toBe("string"); + // Masking behavior + const masked = event.serialize({ maskSensitiveInformation: true }); + const unmasked = event.serialize({ maskSensitiveInformation: false }); + expect(masked["exception_message"]).toBe(PII_MASK); + expect(masked["exception_stacktrace"]).toBe(PII_MASK); + expect(unmasked["exception_message"]).toBe("bad"); + expect(unmasked["exception_stacktrace"]).toBe("trace"); + expect(masked["type"]).toBe("ExceptionRaised"); + }); + + it("masks sensitive fields by default", () => { + const event = new ExceptionRaised({ + exceptionType: "ValueError", + exceptionMessage: "bad", + }); + // Default serialization masks (mirrors mask_sensitive_information=True default) + expect(event.serialize()["exception_message"]).toBe(PII_MASK); + }); + + // Agent events + it("creates and masks AgentExecutionStart", () => { + const agent = dummyAgent(); + const event = new AgentExecutionStart({ agent, inputs: { x: 1 }, name: "custom" }); + expect(event.name).toBe("custom"); + expect(event.agent).toBe(agent); + expect(event.inputs).toEqual({ x: 1 }); + const masked = event.serialize({ maskSensitiveInformation: true }); + const unmasked = event.serialize({ maskSensitiveInformation: false }); + expect(masked["inputs"]).toBe(PII_MASK); + expect(unmasked["inputs"]).toEqual({ x: 1 }); + expect(masked["type"]).toBe("AgentExecutionStart"); + }); + + it("creates and masks AgentExecutionEnd", () => { + const agent = dummyAgent(); + const event = new AgentExecutionEnd({ agent, outputs: { y: 2 } }); + expect(event.agent).toBe(agent); + expect(event.outputs).toEqual({ y: 2 }); + const masked = event.serialize({ maskSensitiveInformation: true }); + const unmasked = event.serialize({ maskSensitiveInformation: false }); + expect(masked["outputs"]).toBe(PII_MASK); + expect(unmasked["outputs"]).toEqual({ y: 2 }); + expect(masked["type"]).toBe("AgentExecutionEnd"); + }); + + // Flow events + it("creates and masks FlowExecutionStart", () => { + const flow = dummyFlow(); + const event = new FlowExecutionStart({ + flow, + inputs: { a: 1 }, + name: "flow_start_custom", + }); + expect(event.name).toBe("flow_start_custom"); + expect(event.flow).toBe(flow); + expect(event.inputs).toEqual({ a: 1 }); + const masked = event.serialize({ maskSensitiveInformation: true }); + const unmasked = event.serialize({ maskSensitiveInformation: false }); + expect(masked["inputs"]).toBe(PII_MASK); + expect(unmasked["inputs"]).toEqual({ a: 1 }); + expect(masked["type"]).toBe("FlowExecutionStart"); + }); + + it("creates and masks FlowExecutionEnd", () => { + const flow = dummyFlow(); + const event = new FlowExecutionEnd({ + flow, + outputs: { b: 2 }, + branchSelected: "next", + }); + expect(event.flow).toBe(flow); + expect(event.outputs).toEqual({ b: 2 }); + expect(event.branchSelected).toBe("next"); + const masked = event.serialize({ maskSensitiveInformation: true }); + const unmasked = event.serialize({ maskSensitiveInformation: false }); + expect(masked["outputs"]).toBe(PII_MASK); + expect(unmasked["outputs"]).toEqual({ b: 2 }); + expect(masked["branch_selected"]).toBe("next"); + expect(unmasked["branch_selected"]).toBe("next"); + expect(masked["type"]).toBe("FlowExecutionEnd"); + }); + + // HITL events + it("creates and masks HumanInTheLoopRequest", () => { + const event = new HumanInTheLoopRequest({ + requestId: "r1", + content: { question: "ok?" }, + }); + expect(event.requestId).toBe("r1"); + expect(event.content).toEqual({ question: "ok?" }); + const masked = event.serialize({ maskSensitiveInformation: true }); + const unmasked = event.serialize({ maskSensitiveInformation: false }); + expect(masked["content"]).toBe(PII_MASK); + expect(unmasked["content"]).toEqual({ question: "ok?" }); + expect(masked["type"]).toBe("HumanInTheLoopRequest"); + }); + + it("creates and masks HumanInTheLoopResponse", () => { + const event = new HumanInTheLoopResponse({ + requestId: "r1", + content: { answer: "yes" }, + }); + expect(event.requestId).toBe("r1"); + expect(event.content).toEqual({ answer: "yes" }); + const masked = event.serialize({ maskSensitiveInformation: true }); + const unmasked = event.serialize({ maskSensitiveInformation: false }); + expect(masked["content"]).toBe(PII_MASK); + expect(unmasked["content"]).toEqual({ answer: "yes" }); + expect(masked["type"]).toBe("HumanInTheLoopResponse"); + }); + + // LLM generation events + it("creates and masks LlmGenerationRequest", () => { + const llmConfig = dummyLlmConfig(); + const tool = dummyTool(); + const messages = [new Message({ content: "hello", role: "user" })]; + const event = new LlmGenerationRequest({ + llmConfig, + prompt: messages, + tools: [tool], + requestId: "req-1", + }); + expect(event.llmConfig).toBe(llmConfig); + expect(event.prompt).toBe(messages); + expect(event.tools).toEqual([tool]); + expect(event.requestId).toBe("req-1"); + const masked = event.serialize({ maskSensitiveInformation: true }); + const unmasked = event.serialize({ maskSensitiveInformation: false }); + expect(masked["prompt"]).toBe(PII_MASK); + expect(unmasked["prompt"]).toEqual([ + { id: null, content: "hello", sender: null, role: "user" }, + ]); + const toolDumps = unmasked["tools"] as Array>; + expect(toolDumps[0]!["name"]).toBe(tool.name); + expect(masked["type"]).toBe("LlmGenerationRequest"); + }); + + it("creates and masks LlmGenerationResponse", () => { + const llmConfig = dummyLlmConfig(); + const event = new LlmGenerationResponse({ + llmConfig, + content: "hi", + requestId: "req-1", + completionId: "c-1", + toolCalls: [new ToolCall({ callId: "a", toolName: "b", arguments: "{'c': 1}" })], + inputTokens: 10, + outputTokens: 2, + }); + expect(event.content).toBe("hi"); + expect(event.requestId).toBe("req-1"); + expect(event.completionId).toBe("c-1"); + expect(event.inputTokens).toBe(10); + expect(event.outputTokens).toBe(2); + const masked = event.serialize({ maskSensitiveInformation: true }); + const unmasked = event.serialize({ maskSensitiveInformation: false }); + expect(masked["content"]).toBe(PII_MASK); + expect(masked["tool_calls"]).toBe(PII_MASK); + expect(unmasked["content"]).toBe("hi"); + expect(unmasked["tool_calls"]).toEqual([ + { call_id: "a", tool_name: "b", arguments: "{'c': 1}" }, + ]); + expect(unmasked["request_id"]).toBe("req-1"); + expect(unmasked["completion_id"]).toBe("c-1"); + expect(unmasked["input_tokens"]).toBe(10); + expect(unmasked["output_tokens"]).toBe(2); + expect(masked["type"]).toBe("LlmGenerationResponse"); + }); + + it("creates and masks LlmGenerationChunkReceived", () => { + const llmConfig = dummyLlmConfig(); + const event = new LlmGenerationChunkReceived({ + llmConfig, + content: "piece", + toolCalls: [new ToolCall({ callId: "a", toolName: "b", arguments: "{'c': 1}" })], + requestId: "r", + completionId: "c", + outputTokens: 1, + }); + expect(event.llmConfig).toBe(llmConfig); + expect(event.content).toBe("piece"); + expect(event.requestId).toBe("r"); + expect(event.outputTokens).toBe(1); + const masked = event.serialize({ maskSensitiveInformation: true }); + const unmasked = event.serialize({ maskSensitiveInformation: false }); + expect(masked["content"]).toBe(PII_MASK); + expect(masked["tool_calls"]).toBe(PII_MASK); + expect(unmasked["content"]).toBe("piece"); + expect(unmasked["tool_calls"]).toEqual([ + { call_id: "a", tool_name: "b", arguments: "{'c': 1}" }, + ]); + expect(masked["type"]).toBe("LlmGenerationChunkReceived"); + }); + + // Manager-workers events + it("creates and masks ManagerWorkersExecutionStart", () => { + const managerworkers = dummyManagerWorkers(); + const event = new ManagerWorkersExecutionStart({ + managerworkers, + inputs: { foo: "bar" }, + }); + expect(event.managerworkers).toBe(managerworkers); + expect(event.inputs).toEqual({ foo: "bar" }); + const masked = event.serialize({ maskSensitiveInformation: true }); + const unmasked = event.serialize({ maskSensitiveInformation: false }); + expect(masked["inputs"]).toBe(PII_MASK); + expect(unmasked["inputs"]).toEqual({ foo: "bar" }); + expect(masked["type"]).toBe("ManagerWorkersExecutionStart"); + }); + + it("creates and masks ManagerWorkersExecutionEnd", () => { + const managerworkers = dummyManagerWorkers(); + const event = new ManagerWorkersExecutionEnd({ + managerworkers, + outputs: { foo: "baz" }, + }); + expect(event.managerworkers).toBe(managerworkers); + expect(event.outputs).toEqual({ foo: "baz" }); + const masked = event.serialize({ maskSensitiveInformation: true }); + const unmasked = event.serialize({ maskSensitiveInformation: false }); + expect(masked["outputs"]).toBe(PII_MASK); + expect(unmasked["outputs"]).toEqual({ foo: "baz" }); + expect(masked["type"]).toBe("ManagerWorkersExecutionEnd"); + }); + + // Node events + it("creates and masks NodeExecutionStart", () => { + const node = dummyNode(); + const event = new NodeExecutionStart({ node, inputs: { v: 3 } }); + expect(event.node).toBe(node); + expect(event.inputs).toEqual({ v: 3 }); + const masked = event.serialize({ maskSensitiveInformation: true }); + const unmasked = event.serialize({ maskSensitiveInformation: false }); + expect(masked["inputs"]).toBe(PII_MASK); + expect(unmasked["inputs"]).toEqual({ v: 3 }); + expect(masked["type"]).toBe("NodeExecutionStart"); + }); + + it("creates and masks NodeExecutionEnd", () => { + const node = dummyNode(); + const event = new NodeExecutionEnd({ + node, + outputs: { v: 4 }, + branchSelected: "next", + }); + expect(event.node).toBe(node); + expect(event.outputs).toEqual({ v: 4 }); + expect(event.branchSelected).toBe("next"); + const masked = event.serialize({ maskSensitiveInformation: true }); + const unmasked = event.serialize({ maskSensitiveInformation: false }); + expect(masked["outputs"]).toBe(PII_MASK); + expect(unmasked["outputs"]).toEqual({ v: 4 }); + expect(masked["branch_selected"]).toBe("next"); + expect(unmasked["branch_selected"]).toBe("next"); + expect(masked["type"]).toBe("NodeExecutionEnd"); + }); + + // Swarm events + it("creates and masks SwarmExecutionStart", () => { + const swarm = dummySwarm(); + const event = new SwarmExecutionStart({ swarm, inputs: { q: "x" } }); + expect(event.swarm).toBe(swarm); + expect(event.inputs).toEqual({ q: "x" }); + const masked = event.serialize({ maskSensitiveInformation: true }); + const unmasked = event.serialize({ maskSensitiveInformation: false }); + expect(masked["inputs"]).toBe(PII_MASK); + expect(unmasked["inputs"]).toEqual({ q: "x" }); + expect(masked["type"]).toBe("SwarmExecutionStart"); + }); + + it("creates and masks SwarmExecutionEnd", () => { + const swarm = dummySwarm(); + const event = new SwarmExecutionEnd({ swarm, outputs: { r: "y" } }); + expect(event.swarm).toBe(swarm); + expect(event.outputs).toEqual({ r: "y" }); + const masked = event.serialize({ maskSensitiveInformation: true }); + const unmasked = event.serialize({ maskSensitiveInformation: false }); + expect(masked["outputs"]).toBe(PII_MASK); + expect(unmasked["outputs"]).toEqual({ r: "y" }); + expect(masked["type"]).toBe("SwarmExecutionEnd"); + }); + + // Tool events + it("creates and masks ToolExecutionRequest", () => { + const tool = dummyTool(); + const event = new ToolExecutionRequest({ tool, inputs: { x: 1 }, requestId: "t1" }); + expect(event.tool).toBe(tool); + expect(event.inputs).toEqual({ x: 1 }); + expect(event.requestId).toBe("t1"); + const masked = event.serialize({ maskSensitiveInformation: true }); + const unmasked = event.serialize({ maskSensitiveInformation: false }); + expect(masked["inputs"]).toBe(PII_MASK); + expect(unmasked["inputs"]).toEqual({ x: 1 }); + expect(unmasked["request_id"]).toBe("t1"); + expect(masked["type"]).toBe("ToolExecutionRequest"); + }); + + it("creates and masks ToolExecutionResponse", () => { + const tool = dummyTool(); + const event = new ToolExecutionResponse({ tool, outputs: { y: 2 }, requestId: "t1" }); + expect(event.tool).toBe(tool); + expect(event.outputs).toEqual({ y: 2 }); + expect(event.requestId).toBe("t1"); + const masked = event.serialize({ maskSensitiveInformation: true }); + const unmasked = event.serialize({ maskSensitiveInformation: false }); + expect(masked["outputs"]).toBe(PII_MASK); + expect(unmasked["outputs"]).toEqual({ y: 2 }); + expect(unmasked["request_id"]).toBe("t1"); + expect(masked["type"]).toBe("ToolExecutionResponse"); + }); + + it("creates and masks ToolExecutionStreamingChunkReceived", () => { + const tool = dummyTool(); + const event = new ToolExecutionStreamingChunkReceived({ + tool, + requestId: "t1", + content: "piece", + }); + expect(event.tool).toBe(tool); + expect(event.content).toBe("piece"); + expect(event.requestId).toBe("t1"); + const masked = event.serialize({ maskSensitiveInformation: true }); + const unmasked = event.serialize({ maskSensitiveInformation: false }); + expect(masked["content"]).toBe(PII_MASK); + expect(unmasked["content"]).toBe("piece"); + expect(unmasked["request_id"]).toBe("t1"); + expect(masked["type"]).toBe("ToolExecutionStreamingChunkReceived"); + }); + + it("creates ToolConfirmationRequest (no sensitive fields)", () => { + const tool = dummyTool(); + const event = new ToolConfirmationRequest({ + tool, + requestId: "c1", + toolExecutionRequestId: "t1", + }); + expect(event.tool).toBe(tool); + expect(event.requestId).toBe("c1"); + expect(event.toolExecutionRequestId).toBe("t1"); + // Masking behavior (no sensitive fields) + const masked = event.serialize({ maskSensitiveInformation: true }); + const unmasked = event.serialize({ maskSensitiveInformation: false }); + expect(masked).toEqual(unmasked); + expect(masked["type"]).toBe("ToolConfirmationRequest"); + }); + + it("creates ToolConfirmationResponse (no sensitive fields)", () => { + const tool = dummyTool(); + const event = new ToolConfirmationResponse({ + tool, + executionConfirmed: true, + requestId: "c1", + toolExecutionRequestId: "t1", + }); + expect(event.tool).toBe(tool); + expect(event.executionConfirmed).toBe(true); + expect(event.requestId).toBe("c1"); + const masked = event.serialize({ maskSensitiveInformation: true }); + const unmasked = event.serialize({ maskSensitiveInformation: false }); + expect(masked).toEqual(unmasked); + expect(masked["execution_confirmed"]).toBe(true); + }); +}); diff --git a/tsagentspec/tests/tracing/fixtures.ts b/tsagentspec/tests/tracing/fixtures.ts new file mode 100644 index 00000000..f1052528 --- /dev/null +++ b/tsagentspec/tests/tracing/fixtures.ts @@ -0,0 +1,123 @@ +/** + * Shared fixtures for the tracing test suites — port of + * pyagentspec/tests/tracing/conftest.py. + */ +import { + createAgent, + createControlFlowEdge, + createDataFlowEdge, + createEndNode, + createFlow, + createLlmNode, + createManagerWorkers, + createOpenAiConfig, + createServerTool, + createStartNode, + createSwarm, + integerProperty, + stringProperty, + type Agent, + type Flow, + type LlmConfig, + type LlmNode, + type ManagerWorkers, + type ServerTool, + type Swarm, +} from "../../src/index.js"; + +export function dummyLlmConfig(): LlmConfig { + return createOpenAiConfig({ name: "openai", modelId: "gpt-test" }); +} + +export function dummyAgent(llmConfig: LlmConfig = dummyLlmConfig()): Agent { + return createAgent({ name: "agent", llmConfig, systemPrompt: "Hello" }); +} + +export function dummyTool(): ServerTool { + return createServerTool({ + name: "servertool", + inputs: [integerProperty({ title: "x" })], + outputs: [integerProperty({ title: "y" })], + }); +} + +export function dummyFlow(llmConfig: LlmConfig = dummyLlmConfig()): Flow { + const promptProp = stringProperty({ title: "prompt" }); + const llmOutProp = stringProperty({ title: "generated_text" }); + const startNode = createStartNode({ + name: "start", + inputs: [promptProp], + outputs: [promptProp], + }); + const llmNode = createLlmNode({ + name: "llm", + llmConfig, + promptTemplate: "{{prompt}}", + inputs: [promptProp], + outputs: [llmOutProp], + }); + const endNode = createEndNode({ + name: "end", + inputs: [llmOutProp], + outputs: [llmOutProp], + }); + const controlFlowEdges = [ + createControlFlowEdge({ name: "s_to_llm", fromNode: startNode, toNode: llmNode }), + createControlFlowEdge({ name: "llm_to_e", fromNode: llmNode, toNode: endNode }), + ]; + const dataFlowEdges = [ + createDataFlowEdge({ + name: "prompt_edge", + sourceNode: startNode, + sourceOutput: "prompt", + destinationNode: llmNode, + destinationInput: "prompt", + }), + createDataFlowEdge({ + name: "out_edge", + sourceNode: llmNode, + sourceOutput: "generated_text", + destinationNode: endNode, + destinationInput: "generated_text", + }), + ]; + return createFlow({ + name: "flow", + startNode, + nodes: [startNode, llmNode, endNode], + controlFlowConnections: controlFlowEdges, + dataFlowConnections: dataFlowEdges, + }); +} + +export function dummyNode(llmConfig: LlmConfig = dummyLlmConfig()): LlmNode { + return createLlmNode({ + name: "llm_node", + llmConfig, + promptTemplate: "{{prompt}}", + inputs: [stringProperty({ title: "prompt" })], + outputs: [stringProperty({ title: "generated_text" })], + }); +} + +export function dummyManagerWorkers( + llmConfig: LlmConfig = dummyLlmConfig(), +): ManagerWorkers { + const manager = createAgent({ + name: "manager", + llmConfig, + systemPrompt: "You are a manager", + }); + const worker = createAgent({ + name: "worker", + llmConfig, + systemPrompt: "You are a worker", + }); + return createManagerWorkers({ name: "mw", groupManager: manager, workers: [worker] }); +} + +export function dummySwarm(llmConfig: LlmConfig = dummyLlmConfig()): Swarm { + const a1 = createAgent({ name: "a1", llmConfig, systemPrompt: "You are a1" }); + const a2 = createAgent({ name: "a2", llmConfig, systemPrompt: "You are a2" }); + return createSwarm({ name: "sw", firstAgent: a1, relationships: [[a1, a2]] }); +} diff --git a/tsagentspec/tests/tracing/spans.test.ts b/tsagentspec/tests/tracing/spans.test.ts new file mode 100644 index 00000000..9483d32a --- /dev/null +++ b/tsagentspec/tests/tracing/spans.test.ts @@ -0,0 +1,165 @@ +/** + * Port of pyagentspec/tests/tracing/spans/test_spans.py. + */ +import { describe, expect, it } from "vitest"; +import { + AgentExecutionSpan, + Event, + FlowExecutionSpan, + LlmGenerationSpan, + ManagerWorkersExecutionSpan, + NodeExecutionSpan, + SwarmExecutionSpan, + ToolExecutionSpan, +} from "../../src/index.js"; +import { + dummyAgent, + dummyFlow, + dummyLlmConfig, + dummyManagerWorkers, + dummyNode, + dummySwarm, + dummyTool, +} from "./fixtures.js"; + +function dummyEvent(): Event { + return new Event({ id: "dummy_event_id", name: "dummy_event" }); +} + +describe("tracing spans", () => { + it("creates an AgentExecutionSpan", async () => { + const agent = dummyAgent(); + const span = new AgentExecutionSpan({ agent, name: "custom_agent_span" }); + expect(span.name).toBe("custom_agent_span"); + expect(span.agent).toBe(agent); + const event = dummyEvent(); + await span.addEvent(event); + expect(span.events).toHaveLength(1); + expect(span.events[0]).toBe(event); + // Masking behavior (no sensitive fields in spans) + const masked = span.serialize({ maskSensitiveInformation: true }); + const unmasked = span.serialize({ maskSensitiveInformation: false }); + expect(masked).toEqual(unmasked); + expect(masked["type"]).toBe("AgentExecutionSpan"); + }); + + it("creates a FlowExecutionSpan", async () => { + const flow = dummyFlow(); + const span = new FlowExecutionSpan({ flow, name: "custom_flow_span" }); + expect(span.name).toBe("custom_flow_span"); + expect(span.flow).toBe(flow); + const event = dummyEvent(); + await span.addEvent(event); + expect(span.events).toHaveLength(1); + expect(span.events[0]).toBe(event); + const masked = span.serialize({ maskSensitiveInformation: true }); + const unmasked = span.serialize({ maskSensitiveInformation: false }); + expect(masked).toEqual(unmasked); + expect(masked["type"]).toBe("FlowExecutionSpan"); + }); + + it("creates an LlmGenerationSpan", async () => { + const llmConfig = dummyLlmConfig(); + const span = new LlmGenerationSpan({ llmConfig, name: "custom_llm_span" }); + expect(span.name).toBe("custom_llm_span"); + expect(span.llmConfig).toBe(llmConfig); + const event = dummyEvent(); + await span.addEvent(event); + expect(span.events).toHaveLength(1); + expect(span.events[0]).toBe(event); + const masked = span.serialize({ maskSensitiveInformation: true }); + const unmasked = span.serialize({ maskSensitiveInformation: false }); + expect(masked).toEqual(unmasked); + expect(masked["type"]).toBe("LlmGenerationSpan"); + }); + + it("creates a ManagerWorkersExecutionSpan", async () => { + const managerworkers = dummyManagerWorkers(); + const span = new ManagerWorkersExecutionSpan({ + managerworkers, + name: "custom_mw_span", + }); + expect(span.name).toBe("custom_mw_span"); + expect(span.managerworkers).toBe(managerworkers); + const event = dummyEvent(); + await span.addEvent(event); + expect(span.events).toHaveLength(1); + expect(span.events[0]).toBe(event); + const masked = span.serialize({ maskSensitiveInformation: true }); + const unmasked = span.serialize({ maskSensitiveInformation: false }); + expect(masked).toEqual(unmasked); + expect(masked["type"]).toBe("ManagerWorkersExecutionSpan"); + }); + + it("creates a NodeExecutionSpan", async () => { + const node = dummyNode(); + const span = new NodeExecutionSpan({ node, name: "custom_node_span" }); + expect(span.name).toBe("custom_node_span"); + expect(span.node).toBe(node); + const event = dummyEvent(); + await span.addEvent(event); + expect(span.events).toHaveLength(1); + expect(span.events[0]).toBe(event); + const masked = span.serialize({ maskSensitiveInformation: true }); + const unmasked = span.serialize({ maskSensitiveInformation: false }); + expect(masked).toEqual(unmasked); + expect(masked["type"]).toBe("NodeExecutionSpan"); + }); + + it("creates a SwarmExecutionSpan", async () => { + const swarm = dummySwarm(); + const span = new SwarmExecutionSpan({ swarm, name: "custom_swarm_span" }); + expect(span.name).toBe("custom_swarm_span"); + expect(span.swarm).toBe(swarm); + const event = dummyEvent(); + await span.addEvent(event); + expect(span.events).toHaveLength(1); + expect(span.events[0]).toBe(event); + const masked = span.serialize({ maskSensitiveInformation: true }); + const unmasked = span.serialize({ maskSensitiveInformation: false }); + expect(masked).toEqual(unmasked); + expect(masked["type"]).toBe("SwarmExecutionSpan"); + }); + + it("creates a ToolExecutionSpan", async () => { + const tool = dummyTool(); + const span = new ToolExecutionSpan({ tool, name: "custom_tool_span" }); + expect(span.name).toBe("custom_tool_span"); + expect(span.tool).toBe(tool); + const event = dummyEvent(); + await span.addEvent(event); + expect(span.events).toHaveLength(1); + expect(span.events[0]).toBe(event); + const masked = span.serialize({ maskSensitiveInformation: true }); + const unmasked = span.serialize({ maskSensitiveInformation: false }); + expect(masked).toEqual(unmasked); + expect(masked["type"]).toBe("ToolExecutionSpan"); + }); + + it("span dumps use snake_case wire names and embed the component", () => { + const agent = dummyAgent(); + const span = new AgentExecutionSpan({ agent, name: "custom_agent_span" }); + const dump = span.serialize(); + expect(Object.keys(dump)).toEqual( + expect.arrayContaining([ + "id", + "name", + "description", + "start_time", + "end_time", + "events", + "metadata", + "agent", + "type", + ]), + ); + expect(dump["start_time"]).toBeNull(); + expect(dump["end_time"]).toBeNull(); + const agentDump = dump["agent"] as Record; + expect(agentDump["component_type"]).toBe("Agent"); + expect(agentDump["name"]).toBe("agent"); + // Bookkeeping fields never serialize + expect(dump).not.toHaveProperty("parentSpan"); + expect(dump).not.toHaveProperty("parent_span"); + }); +}); diff --git a/tsagentspec/tests/tracing/state-snapshot.test.ts b/tsagentspec/tests/tracing/state-snapshot.test.ts new file mode 100644 index 00000000..424afa2c --- /dev/null +++ b/tsagentspec/tests/tracing/state-snapshot.test.ts @@ -0,0 +1,113 @@ +/** + * Port of pyagentspec/tests/tracing/events/test_state_snapshot_emitted.py. + */ +import { describe, expect, it } from "vitest"; +import { PII_MASK, StateSnapshotEmitted } from "../../src/index.js"; + +describe("StateSnapshotEmitted", () => { + it("creates and masks the snapshot payloads", () => { + const event = new StateSnapshotEmitted({ + conversationId: "conversation-123", + stateSnapshot: { conversation: { messages: [] } }, + extraState: { ui: { active_tab: "plan" } }, + name: "snapshot", + }); + + expect(event.name).toBe("snapshot"); + expect(event.conversationId).toBe("conversation-123"); + expect(event.stateSnapshot).toEqual({ conversation: { messages: [] } }); + expect(event.extraState).toEqual({ ui: { active_tab: "plan" } }); + + const masked = event.serialize({ maskSensitiveInformation: true }); + const unmasked = event.serialize({ maskSensitiveInformation: false }); + + expect(masked["state_snapshot"]).toBe(PII_MASK); + expect(masked["extra_state"]).toBe(PII_MASK); + expect(masked["conversation_id"]).toBe("conversation-123"); + expect(masked["type"]).toBe("StateSnapshotEmitted"); + + expect(unmasked["state_snapshot"]).toEqual({ conversation: { messages: [] } }); + expect(unmasked["extra_state"]).toEqual({ ui: { active_tab: "plan" } }); + }); + + it.each([ + [{ conversation: { messages: [] } }, null], + [null, { ui: { active_tab: "plan" } }], + ] as Array< + [Record | null, Record | null] + >)("allows either payload alone (%#)", (stateSnapshot, extraState) => { + const event = new StateSnapshotEmitted({ + conversationId: "conversation-123", + stateSnapshot, + extraState, + }); + + expect(event.stateSnapshot).toEqual(stateSnapshot); + expect(event.extraState).toEqual(extraState); + }); + + it("requires at least one payload", () => { + expect( + () => new StateSnapshotEmitted({ conversationId: "conversation-123" }), + ).toThrow("At least one of state_snapshot or extra_state must be provided"); + }); + + it.each([ + [ + { stateSnapshot: { conversation: { opaque: () => "not-json" } } }, + "state_snapshot must be JSON-serializable", + ], + [ + { extraState: { ui: { opaque: () => "not-json" } } }, + "extra_state must be JSON-serializable", + ], + [ + { stateSnapshot: { value: Number.NaN } }, + "state_snapshot must be JSON-serializable", + ], + [ + { extraState: { value: Number.POSITIVE_INFINITY } }, + "extra_state must be JSON-serializable", + ], + [ + { stateSnapshot: { when: new Date(0) } }, + "state_snapshot must be JSON-serializable", + ], + ] as Array<[Record>, string]>)( + "rejects non-JSON-serializable payloads (%#)", + (payloads, expectedMessage) => { + expect( + () => + new StateSnapshotEmitted({ conversationId: "conversation-123", ...payloads }), + ).toThrow(expectedMessage); + }, + ); + + it("accepts a realistic runtime resumable payload", () => { + const stateSnapshot = { + runtime: "my-agent-runtime", + schema_version: 1, + conversation_state: '{"type":"FlowConversation","version":1}', + conversation: { + id: "runtime-conversation-123", + messages: [], + }, + execution: { + status: null, + status_handled: false, + }, + }; + const event = new StateSnapshotEmitted({ + conversationId: "conversation-123", + stateSnapshot, + extraState: { ui: { active_tab: "plan" } }, + }); + + expect(event.conversationId).toBe("conversation-123"); + expect(event.stateSnapshot).toEqual(stateSnapshot); + expect(event.stateSnapshot!["conversation_state"]).toBe( + '{"type":"FlowConversation","version":1}', + ); + expect(event.extraState).toEqual({ ui: { active_tab: "plan" } }); + }); +}); diff --git a/tsagentspec/tests/tracing/trace.test.ts b/tsagentspec/tests/tracing/trace.test.ts new file mode 100644 index 00000000..0fa3bb2e --- /dev/null +++ b/tsagentspec/tests/tracing/trace.test.ts @@ -0,0 +1,389 @@ +/** + * Port of pyagentspec/tests/tracing/test_tracing.py (async-only: Python's + * sync/async twin cases collapse onto the single async API, so the + * sync-vs-async segregation and NotImplementedError-fallback cases are N/A). + */ +import { describe, expect, it } from "vitest"; +import { + Event, + ExceptionRaised, + RootSpan, + Span, + SpanProcessor, + Trace, + getActiveSpanStack, + getCurrentSpan, + getTrace, +} from "../../src/index.js"; + +const UUID_RE = + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/; + +function nowNs(): number { + return Date.now() * 1e6; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +class DummySpanProcessor extends SpanProcessor { + startedUp = false; + shutDown = false; + starts: Span[] = []; + ends: Span[] = []; + events: Array<[Event, Span]> = []; + + onStart(span: Span): void { + this.starts.push(span); + } + + onEnd(span: Span): void { + this.ends.push(span); + } + + onEvent(event: Event, span: Span): void { + this.events.push([event, span]); + } + + startup(): void { + this.startedUp = true; + } + + shutdown(): void { + this.shutDown = true; + } +} + +class FailingStartSpanProcessor extends DummySpanProcessor { + override onStart(span: Span): void { + this.starts.push(span); + throw new Error("start failed"); + } +} + +class FailingEndSpanProcessor extends DummySpanProcessor { + constructor(private readonly message: string) { + super(); + } + + override onEnd(span: Span): void { + this.ends.push(span); + throw new Error(this.message); + } +} + +describe("tracing core", () => { + it("event defaults: name is the class name, uuid id, ns timestamp", () => { + const before = nowNs(); + const event = new Event(); + const after = nowNs(); + expect(event.name).toBe("Event"); + expect(typeof event.id).toBe("string"); + expect(event.id).toMatch(UUID_RE); + expect(event.timestamp).toBeGreaterThan(0); + expect(event.timestamp).toBeGreaterThanOrEqual(before); + expect(event.timestamp).toBeLessThanOrEqual(after); + }); + + it("span instantiation defaults", () => { + const span = new Span(); + expect(span.name).toBe("Span"); + expect(typeof span.id).toBe("string"); + expect(span.id).toMatch(UUID_RE); + expect(span.startTime).toBeNull(); + expect(span.endTime).toBeNull(); + expect(span.events).toEqual([]); + // Not started, shouldn't be active + expect(getCurrentSpan()).toBeUndefined(); + }); + + it("exception event creation defaults", () => { + const event = new ExceptionRaised({ + exceptionType: "ValueError", + exceptionMessage: "bad input", + }); + expect(event.name).toBe("ExceptionRaised"); + expect(event.exceptionType).toBe("ValueError"); + expect(event.exceptionMessage).toBe("bad input"); + expect(typeof event.exceptionStacktrace).toBe("string"); + }); + + it("span start/end updates timestamps and the active stack", async () => { + const stackLenBefore = getActiveSpanStack().length; + const span = new Span({ name: "current_span" }); + const beforeSpanStart = nowNs(); + await span.start(); + const afterSpanStartBeforeEnd = nowNs(); + // Span is the current one while active + expect(getCurrentSpan()).toBe(span); + expect(span.startTime).not.toBeNull(); + expect(span.startTime!).toBeGreaterThanOrEqual(beforeSpanStart); + expect(span.startTime!).toBeLessThanOrEqual(afterSpanStartBeforeEnd); + expect(span.endTime).toBeNull(); + // Active stack grew by 1 + expect(getActiveSpanStack().length).toBe(stackLenBefore + 1); + await span.end(); + const afterSpanEnd = nowNs(); + // After exit, span is closed and stack restored + expect(span.endTime).not.toBeNull(); + expect(span.endTime!).toBeGreaterThanOrEqual(afterSpanStartBeforeEnd); + expect(span.endTime!).toBeLessThanOrEqual(afterSpanEnd); + expect(getCurrentSpan()).toBeUndefined(); + expect(getActiveSpanStack().length).toBe(stackLenBefore); + }); + + it("span run() scopes the span like Python's context manager", async () => { + const stackLenBefore = getActiveSpanStack().length; + const span = new Span({ name: "current_span" }); + await span.run(async (s) => { + expect(s).toBe(span); + expect(getCurrentSpan()).toBe(span); + expect(span.startTime).not.toBeNull(); + expect(span.endTime).toBeNull(); + expect(getActiveSpanStack().length).toBe(stackLenBefore + 1); + }); + expect(span.endTime).not.toBeNull(); + expect(span.endTime!).toBeGreaterThanOrEqual(span.startTime!); + expect(getCurrentSpan()).toBeUndefined(); + expect(getActiveSpanStack().length).toBe(stackLenBefore); + }); + + it("nested spans record their parent span", async () => { + const parent = new Span(); + await parent.run(async () => { + const child = new Span(); + await child.run(async () => { + expect(child.parentSpan).toBe(parent); + expect(getCurrentSpan()).toBe(child); + }); + // After child exits, current is parent + expect(getCurrentSpan()).toBe(parent); + }); + expect(getCurrentSpan()).toBeUndefined(); + }); + + it("span processor hooks are called through the span lifecycle", async () => { + const processor = new DummySpanProcessor(); + const rootSpan = new RootSpan(); + const trace = new Trace({ name: "T1", spanProcessors: [processor], rootSpan }); + let innerSpan: Span | undefined; + await trace.run(async (t) => { + // Trace set in context, root span active + expect(getActiveSpanStack()).toContain(rootSpan); + expect(getCurrentSpan()).toBe(rootSpan); + expect(processor.starts).toHaveLength(1); + expect(processor.starts[0]).toBe(rootSpan); + expect(getTrace()).toBe(t); + const span = new Span(); + innerSpan = span; + await span.run(async () => { + expect(getActiveSpanStack()).toContain(span); + // onStart called for processor + expect(processor.starts).toHaveLength(2); + expect(processor.starts[1]).toBe(span); + const event = new Event({ name: "custom_event" }); + await span.addEvent(event); + // Event recorded both in span and processor + expect(span.events).toHaveLength(1); + expect(span.events[0]).toBe(event); + expect(processor.events).toHaveLength(1); + expect(processor.events[0]![0]).toBe(event); + expect(processor.events[0]![1]).toBe(span); + }); + // onEnd called + expect(processor.ends).toHaveLength(1); + expect(processor.ends[0]).toBe(span); + // Trace lifecycle hooks were invoked + expect(processor.startedUp).toBe(true); + expect(getActiveSpanStack()).toContain(rootSpan); + expect(getCurrentSpan()).toBe(rootSpan); + }); + expect(processor.ends).toHaveLength(2); + expect(processor.ends[0]).toBe(innerSpan); + expect(processor.ends[1]).toBe(rootSpan); + expect(processor.shutDown).toBe(true); + // After exiting the trace, no active trace + expect(getTrace()).toBeUndefined(); + }); + + it("trace startup/shutdown are called and nested traces are rejected", async () => { + const processor = new DummySpanProcessor(); + const trace = new Trace({ spanProcessors: [processor] }); + await trace.run(async () => { + expect(processor.startedUp).toBe(true); + expect(getTrace()).toBeDefined(); + // Nested Trace not allowed + await expect(new Trace().run(async () => undefined)).rejects.toThrow( + "A Trace already exists. Cannot create two nested Traces.", + ); + await expect(new Trace().start()).rejects.toThrow("A Trace already exists"); + }); + expect(processor.shutDown).toBe(true); + }); + + it("a span records an ExceptionRaised event when its body throws", async () => { + const span = new Span(); + await new Trace().run(async () => { + await expect( + span.run(async () => { + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + }); + // After the exception, the span ended and contains an ExceptionRaised event + const exceptionEvent = span.events.find( + (event): event is ExceptionRaised => event instanceof ExceptionRaised, + ); + expect(exceptionEvent).toBeDefined(); + expect(exceptionEvent!.exceptionType).toBe("Error"); + expect(exceptionEvent!.exceptionMessage).toBe("boom"); + expect(exceptionEvent!.exceptionStacktrace).toContain("Error: boom"); + expect(span.endTime).not.toBeNull(); + }); + + it("a failure in a processor onStart triggers cleanup", async () => { + const successfulProcessor = new DummySpanProcessor(); + const failingProcessor = new FailingStartSpanProcessor(); + + const trace = new Trace({ spanProcessors: [successfulProcessor] }); + await trace.run(async (t) => { + // Spans read the live processor list from the ambient trace at start time + t.spanProcessors = [successfulProcessor, failingProcessor]; + await expect(new Span({ name: "startup-failure" }).start()).rejects.toThrow( + "start failed", + ); + + const failedSpan = successfulProcessor.ends[0]; + expect(failedSpan).toBeInstanceOf(Span); + // Only the successfully started processor got onEnd + expect(successfulProcessor.ends).toHaveLength(1); + expect(failingProcessor.ends).toHaveLength(0); + // The failed span never entered the active stack + expect(getCurrentSpan()).toBe(t.rootSpan); + expect(getActiveSpanStack()).not.toContain(failedSpan); + // The successful processor saw the ExceptionRaised event + expect( + successfulProcessor.events.some( + ([event, span]) => event instanceof ExceptionRaised && span === failedSpan, + ), + ).toBe(true); + // Restore so the root span end is clean + t.spanProcessors = [successfulProcessor]; + }); + }); + + it("end() notifies every started processor, rethrows the first error, and pops the stack", async () => { + const failingFirst = new FailingEndSpanProcessor("end failed first"); + const failingSecond = new FailingEndSpanProcessor("end failed second"); + const normal = new DummySpanProcessor(); + + const trace = new Trace(); + await trace.run(async (t) => { + // Register after the root span started, so only the inner span sees them + t.spanProcessors = [failingFirst, normal, failingSecond]; + const span = new Span(); + await span.start(); + await expect(span.end()).rejects.toThrow("end failed first"); + // Every started processor received onEnd despite the failures + expect(failingFirst.ends).toHaveLength(1); + expect(normal.ends).toHaveLength(1); + expect(failingSecond.ends).toHaveLength(1); + // The span was still popped from the active stack + expect(getCurrentSpan()).toBe(t.rootSpan); + expect(getActiveSpanStack()).not.toContain(span); + }); + }); + + it("addEvent only notifies processors that were started with the span", async () => { + const earlyProcessor = new DummySpanProcessor(); + const lateProcessor = new DummySpanProcessor(); + const trace = new Trace({ spanProcessors: [earlyProcessor] }); + await trace.run(async (t) => { + const span = new Span(); + await span.start(); + // Registered after the span started: receives no events from it + t.spanProcessors = [earlyProcessor, lateProcessor]; + await span.addEvent(new Event({ name: "custom_event" })); + expect(earlyProcessor.events).toHaveLength(1); + expect(lateProcessor.events).toHaveLength(0); + await span.end(); + t.spanProcessors = [earlyProcessor]; + }); + }); + + it("trace.run returns the callback result and ends the trace on error", async () => { + const processor = new DummySpanProcessor(); + const trace = new Trace({ spanProcessors: [processor] }); + const result = await trace.run(async () => 42); + expect(result).toBe(42); + expect(processor.shutDown).toBe(true); + + const failingTraceProcessor = new DummySpanProcessor(); + const failingTrace = new Trace({ spanProcessors: [failingTraceProcessor] }); + await expect( + failingTrace.run(async () => { + throw new Error("trace body failed"); + }), + ).rejects.toThrow("trace body failed"); + // The trace still ended: root span closed, processors shut down, context clear + expect(failingTrace.rootSpan.endTime).not.toBeNull(); + expect(failingTraceProcessor.shutDown).toBe(true); + expect(getTrace()).toBeUndefined(); + }); + + it("shutdownOnExit=false skips processor shutdown", async () => { + const processor = new DummySpanProcessor(); + const trace = new Trace({ spanProcessors: [processor], shutdownOnExit: false }); + await trace.run(async () => undefined); + expect(processor.startedUp).toBe(true); + expect(processor.shutDown).toBe(false); + }); + + it("keeps parallel async branches isolated", async () => { + const processor = new DummySpanProcessor(); + const rootSpan = new RootSpan(); + const trace = new Trace({ spanProcessors: [processor], rootSpan }); + + await trace.run(async () => { + const branch = async (label: string, delayMs: number): Promise => { + const outer = new Span({ name: `${label}-outer` }); + await outer.run(async () => { + await sleep(delayMs); + // Only this branch's span is visible on top of the root span + expect(getCurrentSpan()).toBe(outer); + expect(outer.parentSpan).toBe(rootSpan); + const inner = new Span({ name: `${label}-inner` }); + await inner.run(async () => { + await sleep(delayMs); + expect(getCurrentSpan()).toBe(inner); + expect(inner.parentSpan).toBe(outer); + expect(getActiveSpanStack().map((s) => s.name)).toEqual([ + "RootSpan", + `${label}-outer`, + `${label}-inner`, + ]); + }); + await sleep(delayMs); + expect(getCurrentSpan()).toBe(outer); + }); + return outer; + }; + + const [spanA, spanB] = await Promise.all([branch("a", 15), branch("b", 5)]); + // Both branches are gone; the root span is current again + expect(getCurrentSpan()).toBe(rootSpan); + expect(getActiveSpanStack()).toEqual([rootSpan]); + // Both branch spans started and ended, with the root as parent + expect(spanA.parentSpan).toBe(rootSpan); + expect(spanB.parentSpan).toBe(rootSpan); + expect(spanA.endTime).not.toBeNull(); + expect(spanB.endTime).not.toBeNull(); + }); + + // All 5 spans (root + 2 per branch) were started and ended exactly once + expect(processor.starts).toHaveLength(5); + expect(processor.ends).toHaveLength(5); + expect(getTrace()).toBeUndefined(); + }); +}); From 12e79d6b3f34057dd98a57a9384417ff2d96f131 Mon Sep 17 00:00:00 2001 From: Salah Date: Sat, 5 Sep 2026 12:22:14 +0400 Subject: [PATCH 09/14] feat(tsagentspec/adapters): wire retry policies, URL allow-lists, and bare LlmConfig into the LangGraph adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- tsagentspec/README.md | 9 +- tsagentspec/src/adapters/common/index.ts | 7 + .../src/adapters/common/tools-common.ts | 431 ++++++++++++++++-- .../adapters/langgraph/agentspec-converter.ts | 59 ++- tsagentspec/src/adapters/langgraph/llm.ts | 146 +++++- tsagentspec/src/adapters/langgraph/mcp.ts | 7 + .../langgraph/node-execution/api-node.ts | 41 +- .../adapters/common/tools-common.test.ts | 247 +++++++++- .../tests/adapters/langgraph/exporter.test.ts | 68 +++ .../langgraph/flow-nodes/api-node.test.ts | 112 ++++- .../tests/adapters/langgraph/llm.test.ts | 252 +++++++++- .../tests/adapters/langgraph/mcp.test.ts | 60 +++ .../adapters/langgraph/remote-tools.test.ts | 308 ++++++++++++- 13 files changed, 1658 insertions(+), 89 deletions(-) diff --git a/tsagentspec/README.md b/tsagentspec/README.md index d467ab1b..f32d71f4 100644 --- a/tsagentspec/README.md +++ b/tsagentspec/README.md @@ -113,17 +113,18 @@ const yaml = exporter.toYaml(compiledGraph) as string; // also: toJson, toDict, | `ClientTool` | LangGraph interrupt (`client_tool_request` payload) | | `RemoteTool` | `fetch`-based HTTP tool | | `MCPTool`, `MCPToolBox` | `@langchain/mcp-adapters` tools (SSE and Streamable HTTP transports) | -| `OpenAiConfig`, `OpenAiCompatibleConfig`, `VllmConfig` | `ChatOpenAI` | -| `OllamaConfig` | `ChatOllama` | +| `OpenAiConfig`, `OpenAiCompatibleConfig`, `VllmConfig` | `ChatOpenAI` (with `retryPolicy` mapped to retries/timeout) | +| `LlmConfig` (bare, `api_provider: "openai"`) | `ChatOpenAI` | +| `OllamaConfig` | `ChatOllama` (rejects `retryPolicy`, like Python) | `ParallelMapNode` and `ParallelFlowNode` are not supported and raise an error. ### Divergences from the Python adapter - The loader and converter APIs are async (`Promise`-based); Python is sync-first. -- The TypeScript SDK has no `RetryPolicy` component yet, so `RemoteTool` performs a single `fetch` without the Python retry machinery. `RemoteTool`/`ApiNode` requests do not follow redirects and time out after a fixed 5 seconds (`DEFAULT_HTTP_REQUEST_TIMEOUT_MS`), matching httpx's defaults; there is no per-tool timeout override yet. +- `RemoteTool` and `ApiNode` requests honor the spec's `RetryPolicy` (attempts, backoff with all four jitter modes, `Retry-After` with the 30s cap, recoverable statuses with response-body code matching, per-request `requestTimeout` override, no retry on TLS failures) and enforce `urlAllowList` on every rendered URL; a configured allow list suppresses the templated-URL warning, like Python. `ApiNode` retries diverge from Python, whose executor performs a single plain request. Requests do not follow redirects and default to a 5-second timeout (`DEFAULT_HTTP_REQUEST_TIMEOUT_MS`), matching httpx's defaults. - When exporting a LangGraph graph whose conditional edge collides with a real node literally named `condition`, the synthetic conditional/branching node names are suffixed (`condition_1`, ...) so the real node keeps its edges; the Python-style names are used otherwise. -- The TypeScript SDK has no `urlAllowList` field on `RemoteTool`/`ApiNode` yet, so URL allow-list enforcement is not available (the warning about templated URLs without an allow list still fires). +- MCP transport `auth` and `retryPolicy` are representation-only (as in Python): they survive load → export untouched but are not wired into the MCP connection. - `OciGenAiConfig` is not supported (no `langchain-oci` package for JS). - The MCP mTLS transports (`SSEmTLSTransport`, `StreamableHTTPmTLSTransport`) are not supported. - Tracing is a no-op seam only; no execution spans or events are emitted yet. diff --git a/tsagentspec/src/adapters/common/index.ts b/tsagentspec/src/adapters/common/index.ts index 6c03a29f..b48f4cf8 100644 --- a/tsagentspec/src/adapters/common/index.ts +++ b/tsagentspec/src/adapters/common/index.ts @@ -32,10 +32,17 @@ export { jsonSchemasHaveSameType } from "../../property.js"; export { buildJsonSchemaFromProperties } from "./json-schema.js"; export { DEFAULT_HTTP_REQUEST_TIMEOUT_MS, + DEFAULT_TOTAL_ELAPSED_TIME_SECONDS, + MAX_RETRY_AFTER_SECONDS, buildTemplatedHttpRequest, + computeWaitSeconds, createRemoteToolFunc, fetchWithAdapterDefaults, + getRetryAfterSeconds, + isTlsOrCertError, + raiseForStatusWhenPolicySet, renderRecord, + requestWithRetry, type TemplatedHttpRequestSpec, } from "./tools-common.js"; export type { diff --git a/tsagentspec/src/adapters/common/tools-common.ts b/tsagentspec/src/adapters/common/tools-common.ts index e3a41736..10bde6e1 100644 --- a/tsagentspec/src/adapters/common/tools-common.ts +++ b/tsagentspec/src/adapters/common/tools-common.ts @@ -1,26 +1,26 @@ /** - * Shared templated-HTTP-request assembly and RemoteTool execution helpers. - * Port of `pyagentspec.adapters._tools_common._create_remote_tool_func`; the + * Shared templated-HTTP-request assembly, retry engine, and RemoteTool + * execution helpers. Port of `pyagentspec.adapters._tools_common` + * (`_create_remote_tool_func` and the `_request_with_retry` engine); the * request assembly (`buildTemplatedHttpRequest`) is also the one Python * spells out a second time in `ApiNodeExecutor` (`_node_execution.py`) and is - * shared here with the LangGraph ApiNode executor. + * shared here with the LangGraph ApiNode executor, which also reuses the + * retry engine (Python's ApiNodeExecutor performs a single plain request). * * Divergences from Python (see the adapter README): - * - The TS SDK RemoteTool has no `retryPolicy`, so a single fetch attempt is - * performed (no retry/jitter/Retry-After machinery). Like Python without a - * retry policy, the response body is parsed and returned regardless of the - * HTTP status. - * - The TS SDK RemoteTool has no `urlAllowList` field, so the allow-list - * helpers are invoked with `undefined` (i.e. allow) and the templated-URL - * warning fires per the Python rules. * - `fetch` forbids request bodies on GET/HEAD, so no body is sent for those * methods (reported via `bodyDropped`). + * - Jitter randomness uses `Math.random()` (Python uses `SystemRandom`), and + * TLS-failure detection extends Python's message patterns with Node's TLS + * error texts (case-insensitively). * * Python-parity network behavior (NOT divergences): redirects are not - * followed and requests time out after `DEFAULT_HTTP_REQUEST_TIMEOUT_MS`, - * matching httpx's `follow_redirects=False` and 5s-timeout defaults — see + * followed and requests time out after `DEFAULT_HTTP_REQUEST_TIMEOUT_MS` + * unless the retry policy configures `requestTimeout`, matching httpx's + * `follow_redirects=False` and 5s-timeout defaults — see * `fetchWithAdapterDefaults`. */ +import type { RetryPolicy } from "../../retry-policy.js"; import type { RemoteTool } from "../../tools/remote-tool.js"; import { isPlainRecord } from "./guards.js"; import { @@ -37,12 +37,17 @@ import { * Default timeout for RemoteTool / ApiNode HTTP requests, in milliseconds. * * Mirrors the 5-second default timeout httpx applies to every request made by - * the Python adapter. The TS SDK has no `RetryPolicy.requestTimeout` field yet - * (Python reads a per-tool override from there), so this constant is the only - * knob for the request timeout. + * the Python adapter. A configured `RetryPolicy.requestTimeout` (seconds) + * overrides it per tool/node, like Python's `httpx.Timeout` override. */ export const DEFAULT_HTTP_REQUEST_TIMEOUT_MS = 5000; +/** Cap (seconds) on the total time spent across retry attempts. */ +export const DEFAULT_TOTAL_ELAPSED_TIME_SECONDS = 600; + +/** Cap (seconds) applied to server-provided `Retry-After` delays. */ +export const MAX_RETRY_AFTER_SECONDS = 30; + /** * Perform one `fetch` with the adapter's Python-parity network behavior: * @@ -51,20 +56,21 @@ export const DEFAULT_HTTP_REQUEST_TIMEOUT_MS = 5000; * httpx's `follow_redirects=False` default. Following redirects on * untrusted spec config would enable redirect-based egress and forward * custom auth headers to redirect targets. - * - The request aborts after `DEFAULT_HTTP_REQUEST_TIMEOUT_MS` (httpx's - * default timeout); the abort is rethrown as an Error naming the requester - * and the timeout. + * - The request aborts after `timeoutMs` (defaulting to httpx's 5s default + * timeout); the abort is rethrown as an Error naming the requester and the + * timeout. */ export async function fetchWithAdapterDefaults( url: string, init: RequestInit, requesterDescription: string, + timeoutMs: number = DEFAULT_HTTP_REQUEST_TIMEOUT_MS, ): Promise { try { return await fetch(url, { ...init, redirect: "manual", - signal: AbortSignal.timeout(DEFAULT_HTTP_REQUEST_TIMEOUT_MS), + signal: AbortSignal.timeout(timeoutMs), }); } catch (error) { // AbortSignal.timeout aborts with a DOMException named "TimeoutError". @@ -74,14 +80,355 @@ export async function fetchWithAdapterDefaults( (error as { name?: unknown }).name === "TimeoutError" ) { throw new Error( - `${requesterDescription} HTTP request timed out after ` + - `${DEFAULT_HTTP_REQUEST_TIMEOUT_MS}ms.`, + `${requesterDescription} HTTP request timed out after ${timeoutMs}ms.`, ); } throw error; } } +/** + * Message-text patterns identifying TLS/certificate validation failures + * (matched case-insensitively over each error's name, code and message). + * + * Ports Python's `_is_tls_or_cert_error` pattern list and extends it with + * the texts Node/undici produce for the same failures. + */ +const TLS_ERROR_PATTERNS: readonly string[] = [ + "certificate_verify_failed", + "certificate verify failed", + "hostname", + "self signed certificate", + "self-signed certificate", + "unable to verify the first certificate", + "unable to get local issuer certificate", + "certificate has expired", + "altname", +]; + +/** + * Return whether an error chain represents a TLS/certificate validation + * failure. Such failures are never retried: retrying cannot fix a bad + * certificate, and hammering a possibly-MITMed endpoint is undesirable. + * + * Like Python (which unwraps `__cause__`), the JS `cause` chain is walked and + * each error's name, code and message are matched against + * `TLS_ERROR_PATTERNS` (undici wraps the TLS error in a generic + * `TypeError: fetch failed` whose `cause` carries the real failure). + */ +export function isTlsOrCertError(error: unknown): boolean { + let current: unknown = error; + const seen = new Set(); + while (typeof current === "object" && current !== null && !seen.has(current)) { + seen.add(current); + const { message, code, name } = current as { + message?: unknown; + code?: unknown; + name?: unknown; + }; + const haystack = [name, code, message] + .filter((part): part is string => typeof part === "string") + .join(" ") + .toLowerCase(); + if (TLS_ERROR_PATTERNS.some((pattern) => haystack.includes(pattern))) { + return true; + } + current = (current as { cause?: unknown }).cause; + } + return false; +} + +/** HTTP statuses never retried, regardless of `recoverableStatuses`. */ +const NON_RETRYABLE_STATUSES = new Set([400, 401, 403, 422]); + +/** + * Return a response body string suitable for retry-code matching (Python's + * `_get_response_error_text`). Reads from a clone so the original body stays + * readable in case the response is returned to the caller after a + * "not retryable" decision. + */ +async function getResponseErrorText(response: Response): Promise { + try { + return await response.clone().text(); + } catch { + return ""; + } +} + +/** + * Return whether an HTTP error response should be retried under the policy. + * Port of Python's `_is_retryable_http_error`. + */ +async function isRetryableHttpError( + retryPolicy: RetryPolicy, + response: Response, +): Promise { + const statusCode = response.status; + // Agent Spec says runtimes SHOULD NOT retry auth/authz or validation + // errors, but does not explicitly define precedence against + // `recoverable_statuses`. We interpret that non-retryable guidance as + // taking precedence (matching Python). + if (NON_RETRYABLE_STATUSES.has(statusCode)) { + return false; + } + // Agent Spec defines `service_error_retry_on_any_5xx` as excluding HTTP 501. + if (statusCode === 501) { + return false; + } + + const statusKey = String(statusCode); + const retryCodes = Object.hasOwn(retryPolicy.recoverableStatuses, statusKey) + ? retryPolicy.recoverableStatuses[statusKey] + : undefined; + if (retryCodes !== undefined) { + if (retryCodes.length === 0) { + return true; + } + const loweredResponseText = ( + await getResponseErrorText(response) + ).toLowerCase(); + return retryCodes.some((code) => + loweredResponseText.includes(code.toLowerCase()), + ); + } + + if ( + retryPolicy.serviceErrorRetryOnAny5xx && + statusCode >= 500 && + statusCode < 600 + ) { + return true; + } + return false; +} + +/** + * Parse and cap a `Retry-After` header value (Python's + * `_get_retry_after_seconds`): a numeric value is taken as seconds, an + * HTTP-date as the seconds until that instant (never negative); both are + * capped at `MAX_RETRY_AFTER_SECONDS`. Returns null for absent or unparsable + * values. + */ +export function getRetryAfterSeconds( + retryAfterValue: string | null, + nowMs?: number, +): number | null { + if (retryAfterValue === null) { + return null; + } + const trimmed = retryAfterValue.trim(); + if (trimmed !== "") { + const numericValue = Number(trimmed); + if (Number.isFinite(numericValue)) { + return Math.min(numericValue, MAX_RETRY_AFTER_SECONDS); + } + } + const retryAfterDateMs = Date.parse(retryAfterValue); + if (Number.isNaN(retryAfterDateMs)) { + return null; + } + const currentMs = nowMs ?? Date.now(); + return Math.min( + Math.max(0, (retryAfterDateMs - currentMs) / 1000), + MAX_RETRY_AFTER_SECONDS, + ); +} + +/** + * Compute exponential backoff with the configured jitter strategy (Python's + * `_compute_wait_seconds`). `full_and_equal_for_throttle` applies equal + * jitter to 4xx throttling responses and full jitter otherwise. + */ +export function computeWaitSeconds( + retryPolicy: RetryPolicy, + attemptNum: number, + statusCode: number | null, +): number { + const base = Math.min( + retryPolicy.initialRetryDelay * retryPolicy.backoffFactor ** attemptNum, + retryPolicy.maxRetryDelay, + ); + + const jitter = retryPolicy.jitter; + if (jitter == null) { + return base; + } + if (jitter === "equal") { + return base / 2 + Math.random() * (base / 2); + } + if (jitter === "full") { + return Math.random() * base; + } + if ( + jitter === "full_and_equal_for_throttle" && + statusCode !== null && + statusCode >= 400 && + statusCode < 500 + ) { + return base / 2 + Math.random() * (base / 2); + } + if (jitter === "full_and_equal_for_throttle") { + return Math.random() * base; + } + if (jitter === "decorrelated") { + return Math.min(base + Math.random(), retryPolicy.maxRetryDelay); + } + return base; +} + +/** + * Compute the bounded delay before the next retry attempt (Python's + * `_compute_wait_before_next_attempt`): a parsable `Retry-After` wins over + * the backoff computation, and the result is clamped to the time remaining + * under `DEFAULT_TOTAL_ELAPSED_TIME_SECONDS`. Returns null when the elapsed + * budget is exhausted (the caller then gives up retrying). + */ +function computeWaitBeforeNextAttempt( + retryPolicy: RetryPolicy, + attemptNum: number, + statusCode: number | null, + retryAfterValue: string | null, + timeStartedMs: number, +): number | null { + const waitTimeSeconds = + getRetryAfterSeconds(retryAfterValue) ?? + computeWaitSeconds(retryPolicy, attemptNum, statusCode); + + const remainingSeconds = + DEFAULT_TOTAL_ELAPSED_TIME_SECONDS - (Date.now() - timeStartedMs) / 1000; + if (remainingSeconds <= 0) { + return null; + } + return Math.min(waitTimeSeconds, remainingSeconds); +} + +function sleepSeconds(seconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, seconds * 1000)); +} + +/** + * Execute an HTTP request with retry-policy handling. Port of Python's + * `_request_with_retry`. + * + * Without a policy, a single `fetch` is performed with the default timeout. + * With a policy: up to `maxAttempts` retries are attempted after the initial + * request, transport errors are retried unless they are TLS/certificate + * failures, error responses are retried per `recoverableStatuses` / + * `serviceErrorRetryOnAny5xx` (with response-body error-code matching), + * delays honor `Retry-After` (numeric or HTTP-date, capped at + * `MAX_RETRY_AFTER_SECONDS`) or exponential backoff with the configured + * jitter, the total retry time is capped at + * `DEFAULT_TOTAL_ELAPSED_TIME_SECONDS`, and `requestTimeout` (seconds) + * overrides the per-attempt timeout. + * + * Like Python, an error response that is out of retries (or not retryable) + * is RETURNED, not thrown — `raiseForStatusWhenPolicySet` restores the + * with-policy error behavior at the call sites. + */ +export async function requestWithRetry( + retryPolicy: RetryPolicy | undefined, + url: string, + init: RequestInit, + requesterDescription: string, +): Promise { + if (retryPolicy == null) { + return fetchWithAdapterDefaults(url, init, requesterDescription); + } + + const timeoutMs = + retryPolicy.requestTimeout != null + ? retryPolicy.requestTimeout * 1000 + : DEFAULT_HTTP_REQUEST_TIMEOUT_MS; + const totalAttempts = retryPolicy.maxAttempts + 1; + const timeStartedMs = Date.now(); + + for (let attemptNum = 0; attemptNum < totalAttempts; attemptNum += 1) { + let response: Response; + try { + response = await fetchWithAdapterDefaults( + url, + init, + requesterDescription, + timeoutMs, + ); + } catch (error) { + if (isTlsOrCertError(error) || attemptNum >= totalAttempts - 1) { + throw error; + } + const waitTimeSeconds = computeWaitBeforeNextAttempt( + retryPolicy, + attemptNum, + null, + null, + timeStartedMs, + ); + if (waitTimeSeconds === null) { + throw error; + } + await sleepSeconds(waitTimeSeconds); + continue; + } + + if (response.ok) { + return response; + } + + // The last attempt's response is returned before any retryability check + // so its body is never consumed by error-text matching (Python computes + // the text first; httpx responses are re-readable, fetch bodies are not). + if (attemptNum >= totalAttempts - 1) { + return response; + } + if (!(await isRetryableHttpError(retryPolicy, response))) { + return response; + } + + const waitTimeSeconds = computeWaitBeforeNextAttempt( + retryPolicy, + attemptNum, + response.status, + response.headers.get("retry-after"), + timeStartedMs, + ); + if (waitTimeSeconds === null) { + return response; + } + // Python closes the response before sleeping; cancel the unread body so + // the connection is released. + try { + await response.body?.cancel(); + } catch { + // The body may already be disturbed (e.g. by a consumed clone). + } + await sleepSeconds(waitTimeSeconds); + } + + throw new Error("Request failed after retry attempts were exhausted."); +} + +/** + * Throw for a non-2xx response when a retry policy is configured, mirroring + * Python's `response.raise_for_status()` call that runs only with a policy + * set. Without a policy the caller keeps the parse-any-status behavior + * (error payloads flow back as the tool/node result). + */ +export function raiseForStatusWhenPolicySet( + retryPolicy: RetryPolicy | undefined, + response: Response, + requesterDescription: string, + url: string, +): void { + if (retryPolicy == null || response.ok) { + return; + } + const statusText = + response.statusText.length > 0 ? ` ${response.statusText}` : ""; + throw new Error( + `${requesterDescription} HTTP request failed with status ` + + `'${response.status}${statusText}' for url '${url}'.`, + ); +} + /** * Render `{{placeholder}}` templates in both the keys and the values of a * record (header/query-param maps), like Python's dict comprehensions over @@ -103,7 +450,8 @@ export function renderRecord( /** * The structural surface shared by the AgentSpec `RemoteTool` and `ApiNode` - * components: a templated HTTP request specification. + * components: a templated HTTP request specification with an optional URL + * allow list. */ export interface TemplatedHttpRequestSpec { url: string; @@ -111,17 +459,18 @@ export interface TemplatedHttpRequestSpec { data?: unknown; headers: Record; queryParams: Record; + urlAllowList?: string[] | undefined; } /** * Assemble one HTTP request from a templated spec and the call inputs: * renders `{{placeholder}}` templates in the URL, data, headers and query * parameters, stringifies header values, validates the rendered URL against - * the allow list (a seam — the TS SDK has no `urlAllowList` field yet, so - * this always allows), encodes the body (an urlencoded form for dict data - * under an urlencoded content type, raw strings/bytes verbatim, JSON - * otherwise — adding the JSON content type unless the caller set one), and - * appends the rendered query parameters to the URL. + * the spec's `urlAllowList` (throwing the Python rejection error when the + * rendered URL matches no entry), encodes the body (an urlencoded form for + * dict data under an urlencoded content type, raw strings/bytes verbatim, + * JSON otherwise — adding the JSON content type unless the caller set one), + * and appends the rendered query parameters to the URL. * * Mirrors the request assembly Python spells out identically in * `_create_remote_tool_func` (`_tools_common.py`) and `ApiNodeExecutor` @@ -204,10 +553,9 @@ export function buildTemplatedHttpRequest( } } - // Kept as the seam for allow-list enforcement: neither the TS SDK - // RemoteTool nor the ApiNode has a urlAllowList field yet, so this always - // allows. - validateUrlAgainstAllowList(renderedUrl, undefined); + // Enforced on the fully rendered URL, before the request goes out (query + // parameters are appended below but do not participate in matching). + validateUrlAgainstAllowList(renderedUrl, spec.urlAllowList); const searchParams = new URLSearchParams(); for (const [key, value] of Object.entries(renderedQueryParams)) { @@ -247,7 +595,9 @@ export function buildTemplatedHttpRequest( * * The returned function renders `{{placeholder}}` templates in the URL, data, * headers and query parameters using the call kwargs, validates the rendered - * URL, performs a single `fetch`, and returns the parsed JSON response body. + * URL against the tool's `urlAllowList`, performs the request under the + * tool's `retryPolicy` (a single `fetch` without one), and returns the + * parsed JSON response body. * * Note: `requiresConfirmation` wrapping is applied by the framework-specific * adapter layer (e.g. the LangGraph adapter), not here. @@ -255,9 +605,11 @@ export function buildTemplatedHttpRequest( export function createRemoteToolFunc( remoteTool: RemoteTool, ): (kwargs: Record) => Promise { + // A configured allow list suppresses the templated-destination warning, + // exactly like Python. maybeWarnAboutUnrestrictedTemplatedUrl( remoteTool.url, - undefined, + remoteTool.urlAllowList, `RemoteTool \`${remoteTool.name}\``, ); @@ -265,15 +617,22 @@ export function createRemoteToolFunc( kwargs: Record, ): Promise { const { url, init } = buildTemplatedHttpRequest(remoteTool, kwargs); - const response = await fetchWithAdapterDefaults( + const response = await requestWithRetry( + remoteTool.retryPolicy, url, init, `RemoteTool \`${remoteTool.name}\``, ); - // Python (with no retry policy — the only state the TS RemoteTool can - // express) parses and returns the JSON body for every status, so error + // With a retry policy Python raises for a final error status; without + // one it parses and returns the JSON body for every status, so error // responses flow back to the agent as the tool result. Redirects are not // followed (see fetchWithAdapterDefaults), so a 3xx body parses here too. + raiseForStatusWhenPolicySet( + remoteTool.retryPolicy, + response, + `RemoteTool \`${remoteTool.name}\``, + url, + ); return (await response.json()) as unknown; }; } diff --git a/tsagentspec/src/adapters/langgraph/agentspec-converter.ts b/tsagentspec/src/adapters/langgraph/agentspec-converter.ts index 0a020d14..5252f956 100644 --- a/tsagentspec/src/adapters/langgraph/agentspec-converter.ts +++ b/tsagentspec/src/adapters/langgraph/agentspec-converter.ts @@ -16,8 +16,10 @@ * their models/prompts, so a faithful Swarm export is unreachable in JS. * - MCP tools load as ServerTools: the MCP connection lives in a JS closure * that cannot be introspected, so Python's MCPTool recovery is skipped. - * - The TS SDK LlmConfigs have no `retryPolicy`, so ChatOpenAI retry/timeout - * settings are not exported. + * - ChatOpenAI retry/timeout settings export as `retryPolicy` like Python, + * but the JS model retains an explicit `maxRetries` only in its + * constructor kwargs (`lc_kwargs`), and its `timeout` is in milliseconds + * (converted to the spec's seconds). * - OciGenAiConfig export is not supported (no langchain-oci JS package). */ import { BaseChatModel } from "@langchain/core/language_models/chat_models"; @@ -326,13 +328,13 @@ export class LangGraphToAgentSpecConverter openAiModel.clientConfig?.baseURL ?? openAiModel.fields?.configuration?.baseURL ?? ""; - // Note: the TS SDK LlmConfigs have no retryPolicy, so ChatOpenAI - // maxRetries/timeout are not exported (documented divergence). + const retryPolicy = this.chatOpenAiRetryPolicyToAgentSpec(model); if (baseUrl.startsWith("https://api.openai.com")) { return createOpenAiConfig({ name: modelName, modelId: modelName, apiType, + ...(retryPolicy !== undefined ? { retryPolicy } : {}), }); } return createOpenAiCompatibleConfig({ @@ -340,6 +342,7 @@ export class LangGraphToAgentSpecConverter url: baseUrl, modelId: modelName, apiType, + ...(retryPolicy !== undefined ? { retryPolicy } : {}), }); } throw new Error( @@ -347,6 +350,54 @@ export class LangGraphToAgentSpecConverter ); } + /** + * Convert ChatOpenAI retry and timeout settings into an Agent Spec retry + * policy input, or undefined when both sit at their defaults (Python's + * `_chat_openai_retry_policy_convert_to_agentspec`). + * + * The JS ChatOpenAI does not retain `maxRetries` as an own field (it flows + * into the async caller, whose default of 6 is unrelated to an explicit + * setting), so the explicitly-passed value is read from the constructor + * kwargs (`lc_kwargs`). The `timeout` field is in milliseconds and maps to + * the spec's `requestTimeout` seconds. + */ + protected chatOpenAiRetryPolicyToAgentSpec( + model: BaseChatModel, + ): { maxAttempts: number; requestTimeout?: number } | undefined { + const DEFAULT_MAX_ATTEMPTS = 2; // RetryPolicy default + const constructorKwargs = + (model as unknown as { lc_kwargs?: Record }).lc_kwargs ?? + {}; + const maxRetriesRaw = constructorKwargs["maxRetries"]; + const maxRetries = + typeof maxRetriesRaw === "number" ? maxRetriesRaw : undefined; + + const rawTimeout = (model as unknown as { timeout?: unknown }).timeout; + let requestTimeout: number | undefined; + if (rawTimeout == null) { + requestTimeout = undefined; + } else if (typeof rawTimeout === "number") { + requestTimeout = rawTimeout / 1000; + } else { + throw new Error( + "LangGraph ChatOpenAI timeout conversion supports only a single timeout value " + + "because Agent Spec `RetryPolicy.request_timeout` exposes one per-request timeout.", + ); + } + + const hasCustomRetryCount = + maxRetries !== undefined && maxRetries !== DEFAULT_MAX_ATTEMPTS; + const hasCustomTimeout = requestTimeout !== undefined; + if (!hasCustomRetryCount && !hasCustomTimeout) { + return undefined; + } + + return { + maxAttempts: maxRetries ?? DEFAULT_MAX_ATTEMPTS, + ...(requestTimeout !== undefined ? { requestTimeout } : {}), + }; + } + /** * Convert a langchain `ReactAgent` into an Agent Spec Agent using its * public `options` (the JS-native replacement for Python's closure diff --git a/tsagentspec/src/adapters/langgraph/llm.ts b/tsagentspec/src/adapters/langgraph/llm.ts index b8db7a8a..eb61a95a 100644 --- a/tsagentspec/src/adapters/langgraph/llm.ts +++ b/tsagentspec/src/adapters/langgraph/llm.ts @@ -7,14 +7,16 @@ * Divergences from Python (see the adapter README): * - Conversion is async (chat-model packages are loaded via dynamic import so * they stay optional peer dependencies). - * - The TS SDK LlmConfig components have no `retryPolicy` field, so the - * Python retry-policy-to-ChatOpenAI mapping is not ported. + * - The JS ChatOpenAI takes its request timeout in milliseconds (Python's + * client takes seconds), so `RetryPolicy.requestTimeout` (seconds) is + * multiplied by 1000. * - OciGenAiConfig is not supported (no langchain-oci package for JS). * - No tracing callbacks are attached here (tracing is a no-op seam in v1). */ import type { BaseChatModel } from "@langchain/core/language_models/chat_models"; import type { LlmConfig, LlmGenerationConfig } from "../../llms/index.js"; import { OpenAIAPIType } from "../../llms/index.js"; +import { RetryPolicySchema, type RetryPolicy } from "../../retry-policy.js"; import { importOptionalPeer } from "../common/index.js"; function ensureUrlHasScheme(url: string): string { @@ -46,6 +48,96 @@ export function prepareOpenAiCompatibleUrl(url: string): string { return parsed.toString(); } +/** ChatOpenAI retry/timeout settings derived from an Agent Spec RetryPolicy. */ +interface ChatRetryConfig { + maxRetries?: number; + timeoutSeconds?: number; +} + +/** Default retry policy the supported/unsupported field split compares to. */ +const RETRY_POLICY_DEFAULTS: RetryPolicy = RetryPolicySchema.parse({}); + +/** + * RetryPolicy fields ChatOpenAI cannot express, with their Python wire names + * (kept in the error text for cross-SDK parity). + */ +const UNSUPPORTED_CHAT_OPENAI_RETRY_FIELDS: ReadonlyArray< + [field: keyof RetryPolicy, wireName: string] +> = [ + ["initialRetryDelay", "initial_retry_delay"], + ["maxRetryDelay", "max_retry_delay"], + ["backoffFactor", "backoff_factor"], + ["jitter", "jitter"], + ["serviceErrorRetryOnAny5xx", "service_error_retry_on_any_5xx"], + ["recoverableStatuses", "recoverable_statuses"], +]; + +/** + * Compare one retry-policy field against its default (Python's `!=`), with + * order-insensitive deep equality for the `recoverableStatuses` record. + */ +function retryFieldEqualsDefault(value: unknown, defaultValue: unknown): boolean { + if ( + typeof value === "object" && + value !== null && + typeof defaultValue === "object" && + defaultValue !== null + ) { + const actual = value as Record; + const expected = defaultValue as Record; + const actualKeys = Object.keys(actual).sort(); + const expectedKeys = Object.keys(expected).sort(); + return ( + actualKeys.length === expectedKeys.length && + actualKeys.every( + (key, index) => + key === expectedKeys[index] && + actual[key]!.length === expected[key]!.length && + actual[key]!.every((code, codeIndex) => code === expected[key]![codeIndex]), + ) + ); + } + return value === defaultValue; +} + +/** + * Convert Agent Spec retry policy settings into ChatOpenAI keyword arguments. + * + * Port of Python's `_retry_policy_convert_to_langgraph`: only `maxAttempts` + * and `requestTimeout` are representable (the underlying ChatOpenAI/OpenAI + * client only exposes retry count and timeout settings); any other field set + * away from its default raises the Python NotImplementedError text. + */ +export function retryPolicyConvertToLanggraph( + retryPolicy: RetryPolicy | undefined, +): ChatRetryConfig { + if (retryPolicy == null) { + return {}; + } + + const unsupportedFields = UNSUPPORTED_CHAT_OPENAI_RETRY_FIELDS.filter( + ([field]) => + !retryFieldEqualsDefault(retryPolicy[field], RETRY_POLICY_DEFAULTS[field]), + ).map(([, wireName]) => wireName); + if (unsupportedFields.length > 0) { + throw new Error( + "LangGraph ChatOpenAI conversion supports only " + + "`RetryPolicy.max_attempts` and `RetryPolicy.request_timeout`. " + + "This is because the underlying ChatOpenAI/OpenAI client only exposes " + + "retry count and timeout settings. " + + "Unsupported retry policy fields: " + + unsupportedFields.join(", "), + ); + } + + return { + maxRetries: retryPolicy.maxAttempts, + ...(retryPolicy.requestTimeout != null + ? { timeoutSeconds: retryPolicy.requestTimeout } + : {}), + }; +} + /** * Create a ChatOpenAI model without overriding env-based defaults. * @@ -56,6 +148,7 @@ async function createChatOpenAiModel(options: { modelId: string; useResponsesApi: boolean; generationConfig: LlmGenerationConfig; + retryConfig: ChatRetryConfig; baseUrl?: string; apiKey?: string; }): Promise { @@ -78,6 +171,14 @@ async function createChatOpenAiModel(options: { temperature: options.generationConfig.temperature, maxTokens: options.generationConfig.maxTokens, topP: options.generationConfig.topP, + ...(options.retryConfig.maxRetries !== undefined + ? { maxRetries: options.retryConfig.maxRetries } + : {}), + // The JS ChatOpenAI request timeout is in milliseconds (Python's client + // takes seconds). + ...(options.retryConfig.timeoutSeconds !== undefined + ? { timeout: options.retryConfig.timeoutSeconds * 1000 } + : {}), ...(options.baseUrl !== undefined ? { configuration: { baseURL: options.baseUrl } } : {}), @@ -89,7 +190,10 @@ async function createChatOpenAiModel(options: { * * VllmConfig / OpenAiCompatibleConfig map to ChatOpenAI with a normalized * OpenAI-compatible base URL; OpenAiConfig maps to ChatOpenAI without a base - * URL; OllamaConfig maps to ChatOllama. OciGenAiConfig is not supported yet. + * URL; OllamaConfig maps to ChatOllama (rejecting a retry policy, like + * Python); the bare LlmConfig dispatches on its `apiProvider` string + * ("openai" maps to ChatOpenAI with a scheme-ensured base URL). + * OciGenAiConfig is not supported yet. */ export async function convertLlmConfig( llmConfig: LlmConfig, @@ -107,8 +211,14 @@ export async function convertLlmConfig( apiKey: llmConfig.apiKey, useResponsesApi: llmConfig.apiType === OpenAIAPIType.RESPONSES, generationConfig, + retryConfig: retryPolicyConvertToLanggraph(llmConfig.retryPolicy), }); case "OllamaConfig": { + if (llmConfig.retryPolicy != null) { + throw new Error( + "LangGraph ChatOllama conversion does not support `RetryPolicy`.", + ); + } const { ChatOllama } = await importOptionalPeer( () => import("@langchain/ollama"), "@langchain/ollama", @@ -129,8 +239,38 @@ export async function convertLlmConfig( apiKey: llmConfig.apiKey, useResponsesApi: llmConfig.apiType === OpenAIAPIType.RESPONSES, generationConfig, + retryConfig: retryPolicyConvertToLanggraph(llmConfig.retryPolicy), }); + case "LlmConfig": { + // Bare LlmConfig — dispatch on the api_provider string, like Python. + if (llmConfig.apiProvider === "openai") { + return createChatOpenAiModel({ + modelId: llmConfig.modelId, + // Scheme-ensured only: unlike the OpenAI-compatible configs, the + // bare config's URL path is used verbatim (no /v1 normalization). + ...(llmConfig.url !== undefined + ? { baseUrl: ensureUrlHasScheme(llmConfig.url) } + : {}), + apiKey: llmConfig.apiKey, + useResponsesApi: llmConfig.apiType === "responses", + generationConfig, + retryConfig: retryPolicyConvertToLanggraph(llmConfig.retryPolicy), + }); + } + throw new Error( + `LlmConfig with api_provider='${llmConfig.apiProvider}' is not yet ` + + "supported in langgraph. Consider using a specific LlmConfig " + + "subclass instead.", + ); + } case "OciGenAiConfig": + // Python rejects the retry policy before attempting the (here + // unavailable) langchain-oci import, so keep that error precedence. + if (llmConfig.retryPolicy != null) { + throw new Error( + "LangGraph OCI GenAI conversion does not support `RetryPolicy`.", + ); + } throw new Error( "The Agent Spec type 'OciGenAiConfig' is not supported by the LangGraph TypeScript adapter yet.", ); diff --git a/tsagentspec/src/adapters/langgraph/mcp.ts b/tsagentspec/src/adapters/langgraph/mcp.ts index 44746a24..0ba84a1f 100644 --- a/tsagentspec/src/adapters/langgraph/mcp.ts +++ b/tsagentspec/src/adapters/langgraph/mcp.ts @@ -35,6 +35,13 @@ import type { ToolRegistry } from "./types.js"; * StdioTransport maps to a stdio connection, SSETransport to an "sse" * connection and StreamableHTTPTransport to an "http" connection (static * headers included). mTLS transports are not supported yet. + * + * A remote transport's `auth` and `retryPolicy` are representation-only, in + * both SDKs: Python's converter builds its connections from url/headers + * alone and wires no runtime OAuth flow or MCP-session retry either, so + * these fields are intentionally NOT mapped into the connection here — they + * ride along on the Agent Spec component untouched (load → export preserves + * them; only the runtime connection ignores them). */ export function convertClientTransport( agentspecTransport: ClientTransport, diff --git a/tsagentspec/src/adapters/langgraph/node-execution/api-node.ts b/tsagentspec/src/adapters/langgraph/node-execution/api-node.ts index 56eaecec..9cde4010 100644 --- a/tsagentspec/src/adapters/langgraph/node-execution/api-node.ts +++ b/tsagentspec/src/adapters/langgraph/node-execution/api-node.ts @@ -7,24 +7,28 @@ * sites). * * Divergences from Python (see the adapter README): - * - The TS SDK ApiNode has no `urlAllowList` field, so the allow-list helpers - * are invoked with `undefined` (i.e. allow) and the templated-URL warning - * fires per the Python rules. * - `fetch` forbids request bodies on GET/HEAD (Python's httpx sends them): * the declared body is not sent for those methods and a warning is emitted * instead of silently dropping it. + * - The node's `retryPolicy` drives the shared retry engine (and raises for + * a final error status): Python's ApiNodeExecutor performs a single plain + * request, leaving the ApiNode retry policy representation-only. * * Python-parity network behavior (NOT divergences): redirects are not - * followed and requests time out after the shared httpx-parity default — see - * `fetchWithAdapterDefaults`. + * followed and requests time out after the shared httpx-parity default + * (overridden by `retryPolicy.requestTimeout`) — see + * `fetchWithAdapterDefaults`. The node's `urlAllowList` is enforced on the + * rendered URL and suppresses the templated-destination warning, like + * Python. */ import type { BaseMessage } from "@langchain/core/messages"; import type { ApiNode } from "../../../flows/index.js"; import { buildTemplatedHttpRequest, - fetchWithAdapterDefaults, isRecordLike, maybeWarnAboutUnrestrictedTemplatedUrl, + raiseForStatusWhenPolicySet, + requestWithRetry, } from "../../common/index.js"; import type { ExecuteOutput, NodeOutputs } from "../types.js"; import { NodeExecutor } from "./executor.js"; @@ -37,12 +41,11 @@ import { NodeExecutor } from "./executor.js"; export class ApiNodeExecutor extends NodeExecutor { constructor(node: ApiNode) { super(node); - // The TS SDK ApiNode has no urlAllowList field yet: the helpers are - // invoked with `undefined` (i.e. allow), matching the documented - // divergence, so the templated-URL warning fires per the Python rules. + // A configured allow list suppresses the templated-destination warning, + // exactly like Python. maybeWarnAboutUnrestrictedTemplatedUrl( node.url, - undefined, + node.urlAllowList, `ApiNode \`${node.name}\``, ); } @@ -70,14 +73,24 @@ export class ApiNodeExecutor extends NodeExecutor { ); } // Redirects are not followed and the request times out after the shared - // default, matching Python's httpx defaults (see fetchWithAdapterDefaults). - const response = await fetchWithAdapterDefaults( + // default unless retryPolicy.requestTimeout overrides it, matching + // Python's httpx defaults (see fetchWithAdapterDefaults). The node's + // retryPolicy drives the shared retry engine. + const response = await requestWithRetry( + this.node.retryPolicy, url, init, `ApiNode \`${this.node.name}\``, ); - // Python parses the JSON body regardless of the HTTP status (a 3xx - // response returned without following included). + // With a retry policy a final error status raises; without one Python + // parses the JSON body regardless of the HTTP status (a 3xx response + // returned without following included). + raiseForStatusWhenPolicySet( + this.node.retryPolicy, + response, + `ApiNode \`${this.node.name}\``, + url, + ); const responseJson = (await response.json()) as unknown; return [responseJson as NodeOutputs, {}]; } diff --git a/tsagentspec/tests/adapters/common/tools-common.test.ts b/tsagentspec/tests/adapters/common/tools-common.test.ts index 9d364a65..aa41cb1a 100644 --- a/tsagentspec/tests/adapters/common/tools-common.test.ts +++ b/tsagentspec/tests/adapters/common/tools-common.test.ts @@ -1,16 +1,29 @@ /** - * Tests for the shared templated-HTTP-request assembly. + * Tests for the shared templated-HTTP-request assembly and retry engine. * * The request-body matrix (JSON / urlencoded form / raw string / GET-HEAD * drop) is exercised end-to-end through the ApiNode flow tests; this file - * pins the per-caller record-guard contract of `buildTemplatedHttpRequest`: - * the strict default (the RemoteTool path) versus the loose `isRecordLike` + * pins the per-caller record-guard contract of `buildTemplatedHttpRequest` + * (the strict default of the RemoteTool path versus the loose `isRecordLike` * guard the ApiNode executor passes, preserving each call site's - * pre-unification behavior for non-plain data objects. + * pre-unification behavior for non-plain data objects) and the retry-engine + * internals ported from `pyagentspec.adapters._tools_common`: Retry-After + * parsing, the four jitter modes, TLS-failure detection and the + * exponential-backoff / total-elapsed-cap behavior of `requestWithRetry` + * (the adapter-level retry matrix lives in the LangGraph remote-tools and + * api-node suites). */ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { RetryPolicySchema, type RetryPolicy } from "../../../src/index.js"; import { isRecordLike } from "../../../src/adapters/common/guards.js"; -import { buildTemplatedHttpRequest } from "../../../src/adapters/common/tools-common.js"; +import { + MAX_RETRY_AFTER_SECONDS, + buildTemplatedHttpRequest, + computeWaitSeconds, + getRetryAfterSeconds, + isTlsOrCertError, + requestWithRetry, +} from "../../../src/adapters/common/tools-common.js"; class InstancePayload { a = "1"; @@ -56,3 +69,225 @@ describe("buildTemplatedHttpRequest record guard", () => { ).toBe(false); }); }); + +describe("buildTemplatedHttpRequest url allow list", () => { + const spec = { + url: "https://{{host}}/api/value", + httpMethod: "GET", + headers: {}, + queryParams: {}, + urlAllowList: ["https://allowed.example.com/api/"], + }; + + it("validates the rendered URL against the spec's allow list", () => { + expect(() => + buildTemplatedHttpRequest(spec, { host: "blocked.example.com" }), + ).toThrow("Requested URL is not in allowed list"); + expect( + buildTemplatedHttpRequest(spec, { host: "allowed.example.com" }).url, + ).toBe("https://allowed.example.com/api/value"); + }); +}); + +function makeRetryPolicy( + overrides: Parameters[0] = {}, +): RetryPolicy { + return RetryPolicySchema.parse(overrides); +} + +describe("getRetryAfterSeconds", () => { + it("parses numeric values as seconds, capped at 30", () => { + expect(getRetryAfterSeconds("5")).toBe(5); + expect(getRetryAfterSeconds("0.5")).toBe(0.5); + expect(getRetryAfterSeconds("45")).toBe(MAX_RETRY_AFTER_SECONDS); + }); + + it("parses HTTP-dates as the (never negative) seconds until then, capped at 30", () => { + const nowMs = Date.parse("Wed, 21 Oct 2015 07:28:00 GMT"); + expect( + getRetryAfterSeconds("Wed, 21 Oct 2015 07:28:10 GMT", nowMs), + ).toBe(10); + // A date in the past clamps to 0 rather than a negative wait. + expect( + getRetryAfterSeconds("Wed, 21 Oct 2015 07:27:00 GMT", nowMs), + ).toBe(0); + expect( + getRetryAfterSeconds("Wed, 21 Oct 2015 07:38:00 GMT", nowMs), + ).toBe(MAX_RETRY_AFTER_SECONDS); + }); + + it("returns null for absent or unparsable values", () => { + expect(getRetryAfterSeconds(null)).toBeNull(); + expect(getRetryAfterSeconds("soon")).toBeNull(); + }); +}); + +describe("computeWaitSeconds jitter modes", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("null jitter returns the bare exponential backoff, capped at maxRetryDelay", () => { + const policy = makeRetryPolicy({ + initialRetryDelay: 1, + backoffFactor: 2, + maxRetryDelay: 8, + jitter: null, + }); + expect(computeWaitSeconds(policy, 0, null)).toBe(1); + expect(computeWaitSeconds(policy, 1, null)).toBe(2); + expect(computeWaitSeconds(policy, 2, null)).toBe(4); + expect(computeWaitSeconds(policy, 3, null)).toBe(8); + expect(computeWaitSeconds(policy, 10, null)).toBe(8); + }); + + it("equal jitter randomizes the upper half of the backoff", () => { + vi.spyOn(Math, "random").mockReturnValue(0.5); + const policy = makeRetryPolicy({ jitter: "equal", initialRetryDelay: 4 }); + // base = 4; equal jitter = base/2 + rand * base/2 = 2 + 0.5 * 2. + expect(computeWaitSeconds(policy, 0, null)).toBe(3); + }); + + it("full jitter randomizes the whole backoff", () => { + vi.spyOn(Math, "random").mockReturnValue(0.25); + const policy = makeRetryPolicy({ jitter: "full", initialRetryDelay: 4 }); + expect(computeWaitSeconds(policy, 0, null)).toBe(1); + }); + + it("full_and_equal_for_throttle applies equal jitter to 4xx and full jitter otherwise", () => { + vi.spyOn(Math, "random").mockReturnValue(0.5); + const policy = makeRetryPolicy({ initialRetryDelay: 4 }); + // 429 (throttle): equal jitter — 2 + 0.5 * 2. + expect(computeWaitSeconds(policy, 0, 429)).toBe(3); + // 503 / transport error (no status): full jitter — 0.5 * 4. + expect(computeWaitSeconds(policy, 0, 503)).toBe(2); + expect(computeWaitSeconds(policy, 0, null)).toBe(2); + }); + + it("decorrelated jitter adds up to one second, capped at maxRetryDelay", () => { + vi.spyOn(Math, "random").mockReturnValue(0.5); + const policy = makeRetryPolicy({ + jitter: "decorrelated", + initialRetryDelay: 4, + }); + expect(computeWaitSeconds(policy, 0, null)).toBe(4.5); + const capped = makeRetryPolicy({ + jitter: "decorrelated", + initialRetryDelay: 8, + maxRetryDelay: 8, + }); + expect(computeWaitSeconds(capped, 0, null)).toBe(8); + }); +}); + +describe("isTlsOrCertError", () => { + it("detects TLS failures on the error itself and through the cause chain", () => { + expect( + isTlsOrCertError(new Error("certificate verify failed: self signed")), + ).toBe(true); + // undici wraps the TLS failure in `TypeError: fetch failed` with the + // real error on `cause` (Python walks `__cause__` the same way). + expect( + isTlsOrCertError( + new TypeError("fetch failed", { + cause: Object.assign(new Error("self-signed certificate"), { + code: "DEPTH_ZERO_SELF_SIGNED_CERT", + }), + }), + ), + ).toBe(true); + expect( + isTlsOrCertError( + new TypeError("fetch failed", { + cause: new Error( + "Hostname/IP does not match certificate's altnames", + ), + }), + ), + ).toBe(true); + }); + + it("does not flag ordinary transport errors", () => { + expect(isTlsOrCertError(new TypeError("fetch failed"))).toBe(false); + expect( + isTlsOrCertError( + new TypeError("fetch failed", { + cause: Object.assign(new Error("connect ECONNREFUSED 127.0.0.1"), { + code: "ECONNREFUSED", + }), + }), + ), + ).toBe(false); + }); +}); + +describe("requestWithRetry backoff and elapsed cap", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + function install503Fetch(): { calls: number[] } { + const calls: number[] = []; + globalThis.fetch = (async () => { + calls.push(Date.now()); + return new Response('{"error": "busy"}', { + status: 503, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch; + return { calls }; + } + + it("sleeps the exponential backoff between attempts (fake timers)", async () => { + vi.useFakeTimers(); + const { calls } = install503Fetch(); + const policy = makeRetryPolicy({ + maxAttempts: 3, + initialRetryDelay: 1, + backoffFactor: 2, + maxRetryDelay: 8, + jitter: null, + }); + + const started = Date.now(); + const responsePromise = requestWithRetry(policy, "https://x/", {}, "T"); + await vi.advanceTimersByTimeAsync(1000 + 2000 + 4000); + const response = await responsePromise; + + expect(response.status).toBe(503); + // Attempts at t=0, +1s, +3s, +7s: backoff of 1s, 2s, 4s between them. + expect(calls.map((timestampMs) => timestampMs - started)).toEqual([ + 0, 1000, 3000, 7000, + ]); + }); + + it("stops retrying once the 600s elapsed budget is exhausted", async () => { + vi.useFakeTimers(); + const calls: number[] = []; + globalThis.fetch = (async () => { + calls.push(Date.now()); + // Simulate a slow failing service: by the time the response arrives, + // the total elapsed budget is spent, so no further retry is scheduled + // even though attempts remain. + vi.setSystemTime(Date.now() + 601_000); + return new Response('{"error": "busy"}', { + status: 503, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch; + const policy = makeRetryPolicy({ + maxAttempts: 5, + initialRetryDelay: 0, + maxRetryDelay: 0, + }); + + const response = await requestWithRetry(policy, "https://x/", {}, "T"); + + expect(response.status).toBe(503); + expect(calls).toHaveLength(1); + }); +}); diff --git a/tsagentspec/tests/adapters/langgraph/exporter.test.ts b/tsagentspec/tests/adapters/langgraph/exporter.test.ts index 9cba2df0..a6b2259d 100644 --- a/tsagentspec/tests/adapters/langgraph/exporter.test.ts +++ b/tsagentspec/tests/adapters/langgraph/exporter.test.ts @@ -222,6 +222,74 @@ describe("AgentSpecExporter: chat models", () => { expect(config.apiType).toBe(OpenAIAPIType.RESPONSES); }); + it("exports explicit ChatOpenAI maxRetries and (ms) timeout as a retryPolicy", () => { + const exporter = new AgentSpecExporter(); + const model = new ChatOpenAI({ + model: MODEL_ID, + apiKey: "EMPTY", + maxRetries: 5, + timeout: 45_000, + configuration: { baseURL: LLAMA_URL }, + }); + + const config = exporter.toComponent(model) as OpenAiCompatibleConfig; + + expect(config.retryPolicy?.maxAttempts).toBe(5); + // The JS ChatOpenAI timeout is milliseconds; the spec's requestTimeout + // is seconds. + expect(config.retryPolicy?.requestTimeout).toBe(45); + }); + + it("exports a timeout-only ChatOpenAI with the default retry count", () => { + const exporter = new AgentSpecExporter(); + const model = new ChatOpenAI({ + model: "gpt-4o-mini", + apiKey: "sk-test", + timeout: 30_000, + configuration: { baseURL: "https://api.openai.com/v1" }, + }); + + const config = exporter.toComponent(model) as OpenAiConfig; + + expect(config.componentType).toBe("OpenAiConfig"); + expect(config.retryPolicy?.maxAttempts).toBe(2); + expect(config.retryPolicy?.requestTimeout).toBe(30); + }); + + it("omits the retryPolicy when retries and timeout sit at their defaults", () => { + const exporter = new AgentSpecExporter(); + + const defaultConfig = exporter.toComponent( + makeChatOpenAI(), + ) as OpenAiCompatibleConfig; + expect("retryPolicy" in defaultConfig).toBe(false); + + // An explicit maxRetries equal to the RetryPolicy default (2) is not a + // customization, like Python's default-comparison. + const explicitDefault = exporter.toComponent( + new ChatOpenAI({ + model: MODEL_ID, + apiKey: "EMPTY", + maxRetries: 2, + configuration: { baseURL: LLAMA_URL }, + }), + ) as OpenAiCompatibleConfig; + expect("retryPolicy" in explicitDefault).toBe(false); + }); + + it("rejects a non-scalar ChatOpenAI timeout with the Python text", () => { + const exporter = new AgentSpecExporter(); + const model = makeChatOpenAI(); + // The JS field is typed number, so a non-scalar can only arrive through + // an unsound cast — mirror Python's httpx.Timeout rejection anyway. + (model as unknown as { timeout: unknown }).timeout = { read: 10 }; + + expect(() => exporter.toComponent(model)).toThrow( + "LangGraph ChatOpenAI timeout conversion supports only a single timeout value " + + "because Agent Spec `RetryPolicy.request_timeout` exposes one per-request timeout.", + ); + }); + it("converts ChatOllama to OllamaConfig with base url and model id", () => { const exporter = new AgentSpecExporter(); const model = new ChatOllama({ diff --git a/tsagentspec/tests/adapters/langgraph/flow-nodes/api-node.test.ts b/tsagentspec/tests/adapters/langgraph/flow-nodes/api-node.test.ts index cf160dc6..5afb3bd3 100644 --- a/tsagentspec/tests/adapters/langgraph/flow-nodes/api-node.test.ts +++ b/tsagentspec/tests/adapters/langgraph/flow-nodes/api-node.test.ts @@ -1,13 +1,14 @@ /** * ApiNode flow execution tests for the LangGraph adapter. * - * Mirrors `pyagentspec/tests/adapters/langgraph/flows/test_apinode.py` with a - * mocked fetch so every test runs offline. + * Mirrors `pyagentspec/tests/adapters/langgraph/flows/test_apinode.py` + * (allow-list enforcement included) with a mocked fetch so every test runs + * offline. * - * Documented divergence exercised here: the TS SDK ApiNode has no - * `urlAllowList` field yet, so the Python allow-list rejection test has no TS - * equivalent (the adapter always calls the validation helper with - * `undefined`). + * Documented divergence exercised here: the node's `retryPolicy` drives the + * shared retry engine and raises for a final error status (Python's + * ApiNodeExecutor performs a single plain request, keeping the field + * representation-only). */ import { afterEach, describe, expect, it, vi } from "vitest"; import { @@ -316,6 +317,105 @@ describe("ApiNode", () => { expect(mockFetch.calls[0]!.init!.signal).toBeInstanceOf(AbortSignal); }); + it("enforces the url allow list on the rendered URL and suppresses the templated warning", async () => { + // Ports test_apinode_rejects_rendered_url_outside_allow_list (and the + // allowed-URL half of test_apinode_can_be_imported_and_executed). + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const inputProps = [ + stringProperty({ title: "host" }), + stringProperty({ title: "order_id" }), + ]; + const status = stringProperty({ title: "status" }); + const apiNode = createApiNode({ + name: "api", + url: "https://{{host}}/orders/{{order_id}}", + httpMethod: "GET", + urlAllowList: ["https://allowed.example.com/orders/"], + inputs: inputProps, + outputs: [status], + }); + const flow = buildApiFlow(apiNode, inputProps, [status]); + const graph = await loadFlow(flow); + // The configured allow list suppresses the templated-destination warning. + expect(warnSpy).not.toHaveBeenCalled(); + + mockFetch = installMockFetch(() => ({ status: "ok" })); + const result = await graph.invoke({ + inputs: { host: "allowed.example.com", order_id: "123" }, + }); + expect(outputsOf(result)).toEqual({ status: "ok" }); + expect(mockFetch.calls[0]!.url).toBe( + "https://allowed.example.com/orders/123", + ); + + await expect( + graph.invoke({ inputs: { host: "blocked.example.com", order_id: "123" } }), + ).rejects.toThrow("Requested URL is not in allowed list"); + expect(mockFetch.calls).toHaveLength(1); + }); + + it("retries per the node's retryPolicy and overrides the request timeout", async () => { + const timeoutSpy = vi.spyOn(AbortSignal, "timeout"); + const status = stringProperty({ title: "status" }); + const apiNode = createApiNode({ + name: "api", + url: "https://api.example.com/orders", + httpMethod: "GET", + retryPolicy: { + maxAttempts: 1, + requestTimeout: 0.25, + initialRetryDelay: 0, + maxRetryDelay: 0, + }, + outputs: [status], + }); + const flow = buildApiFlow(apiNode, [], [status]); + const graph = await loadFlow(flow); + + let call = 0; + mockFetch = installMockFetch(() => { + call += 1; + return call === 1 + ? new Response('{"error": "busy"}', { + status: 503, + headers: { "Content-Type": "application/json" }, + }) + : { status: "ok" }; + }); + const result = await graph.invoke({ inputs: {} }); + + expect(outputsOf(result)).toEqual({ status: "ok" }); + expect(mockFetch.calls).toHaveLength(2); + expect(timeoutSpy.mock.calls.map(([ms]) => ms)).toEqual([250, 250]); + }); + + it("raises for a final error status when a retryPolicy is configured", async () => { + const status = stringProperty({ title: "status" }); + const apiNode = createApiNode({ + name: "api", + url: "https://api.example.com/orders", + httpMethod: "GET", + retryPolicy: { maxAttempts: 0 }, + outputs: [status], + }); + const flow = buildApiFlow(apiNode, [], [status]); + const graph = await loadFlow(flow); + + mockFetch = installMockFetch( + () => + new Response('{"error": "busy"}', { + status: 503, + headers: { "Content-Type": "application/json" }, + }), + ); + + await expect(graph.invoke({ inputs: {} })).rejects.toThrow( + "ApiNode `api` HTTP request failed with status '503' " + + "for url 'https://api.example.com/orders'.", + ); + expect(mockFetch.calls).toHaveLength(1); + }); + it("POST: string data is sent as a raw body without forcing a content type", async () => { const inputProps = [stringProperty({ title: "val" })]; const echo = stringProperty({ title: "echo" }); diff --git a/tsagentspec/tests/adapters/langgraph/llm.test.ts b/tsagentspec/tests/adapters/langgraph/llm.test.ts index f9454d3f..9ebfe0c4 100644 --- a/tsagentspec/tests/adapters/langgraph/llm.test.ts +++ b/tsagentspec/tests/adapters/langgraph/llm.test.ts @@ -3,13 +3,16 @@ * * Mirrors `pyagentspec/tests/adapters/langgraph/llms/test_llm_conversion.py` * (URL normalization matrix, ChatOpenAI/ChatOllama mapping, responses-API - * flag, generation parameter forwarding) plus the OciGenAiConfig rejection. + * flag, generation parameter forwarding, the retryPolicy-to-ChatOpenAI + * mapping and its NotImplementedError paths) and + * `pyagentspec/tests/adapters/langgraph/test_bare_llmconfig_dispatch.py` + * (bare LlmConfig api_provider dispatch), plus the OciGenAiConfig rejection. * All tests run offline: models are constructed, never invoked. * * Documented divergences (see the adapter README / llm.ts header): * - conversion is async; - * - the TS SDK LlmConfig has no retryPolicy, so the Python retry mapping and - * its NotImplementedError paths have no TS equivalent; + * - the JS ChatOpenAI takes its request timeout in milliseconds, so the + * spec's requestTimeout seconds are multiplied by 1000; * - OciGenAiConfig is rejected outright (no langchain-oci JS package). */ import { afterEach, describe, expect, it, vi } from "vitest"; @@ -17,6 +20,7 @@ import { ChatOllama } from "@langchain/ollama"; import { ChatOpenAI } from "@langchain/openai"; import { OpenAIAPIType, + createLlmConfig, createOciClientConfigWithApiKey, createOciGenAiConfig, createOllamaConfig, @@ -42,6 +46,10 @@ interface ChatOpenAiProbe { useResponsesApi: boolean; modelKwargs?: Record; clientConfig: { baseURL?: string }; + /** Explicit retry count flows into the async caller. */ + caller: { maxRetries: number }; + /** JS ChatOpenAI request timeout, in milliseconds. */ + timeout?: number; } async function convertToChatOpenAi(config: LlmConfig): Promise { @@ -276,6 +284,244 @@ describe("convertLlmConfig for OllamaConfig", () => { }); }); +describe("convertLlmConfig retry policy mapping", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("maps maxAttempts and requestTimeout onto ChatOpenAI retries and (ms) timeout", async () => { + vi.stubEnv("OPENAI_API_KEY", "DUMMY_KEY"); + const model = await convertToChatOpenAi( + createOpenAiConfig({ + name: "openai", + modelId: "gpt-4o-mini", + retryPolicy: { maxAttempts: 3, requestTimeout: 45 }, + }), + ); + expect(model.caller.maxRetries).toBe(3); + // The spec's requestTimeout is seconds; the JS ChatOpenAI timeout is ms. + expect(model.timeout).toBe(45_000); + }); + + it("leaves the timeout unset when the policy has no requestTimeout", async () => { + vi.stubEnv("OPENAI_API_KEY", "DUMMY_KEY"); + const model = await convertToChatOpenAi( + createOpenAiConfig({ + name: "openai", + modelId: "gpt-4o-mini", + retryPolicy: { maxAttempts: 4 }, + }), + ); + expect(model.caller.maxRetries).toBe(4); + expect(model.timeout).toBeUndefined(); + }); + + it("applies the retry policy on VllmConfig and OpenAiCompatibleConfig too", async () => { + vi.stubEnv("OPENAI_API_KEY", "DUMMY_KEY"); + const vllm = await convertToChatOpenAi( + createVllmConfig({ + name: "llm", + modelId: "m", + url: "localhost:8000", + retryPolicy: { maxAttempts: 7 }, + }), + ); + expect(vllm.caller.maxRetries).toBe(7); + const compatible = await convertToChatOpenAi( + createOpenAiCompatibleConfig({ + name: "oaic", + modelId: "m", + url: "https://api.compatible", + retryPolicy: { maxAttempts: 0, requestTimeout: 0.5 }, + }), + ); + expect(compatible.caller.maxRetries).toBe(0); + expect(compatible.timeout).toBe(500); + }); + + it("rejects a policy customizing fields ChatOpenAI cannot express, with the Python text", async () => { + await expect( + convertLlmConfig( + createOpenAiConfig({ + name: "openai", + modelId: "gpt-4o-mini", + retryPolicy: { initialRetryDelay: 5 }, + }), + ), + ).rejects.toThrow( + "LangGraph ChatOpenAI conversion supports only " + + "`RetryPolicy.max_attempts` and `RetryPolicy.request_timeout`. " + + "This is because the underlying ChatOpenAI/OpenAI client only exposes " + + "retry count and timeout settings. " + + "Unsupported retry policy fields: initial_retry_delay", + ); + }); + + it("lists every customized unsupported field, jitter: null included", async () => { + await expect( + convertLlmConfig( + createOpenAiConfig({ + name: "openai", + modelId: "gpt-4o-mini", + retryPolicy: { + initialRetryDelay: 2, + maxRetryDelay: 16, + backoffFactor: 3, + jitter: null, + serviceErrorRetryOnAny5xx: false, + recoverableStatuses: { "429": [] }, + }, + }), + ), + ).rejects.toThrow( + "Unsupported retry policy fields: initial_retry_delay, max_retry_delay, " + + "backoff_factor, jitter, service_error_retry_on_any_5xx, " + + "recoverable_statuses", + ); + }); + + it("treats a reordered default recoverableStatuses record as the default", async () => { + // Python compares dicts order-insensitively; the TS deep comparison + // must match ({"429": [], "409": []} equals the {"409": [], "429": []} + // default). + vi.stubEnv("OPENAI_API_KEY", "DUMMY_KEY"); + const model = await convertToChatOpenAi( + createOpenAiConfig({ + name: "openai", + modelId: "gpt-4o-mini", + retryPolicy: { + maxAttempts: 5, + recoverableStatuses: { "429": [], "409": [] }, + }, + }), + ); + expect(model.caller.maxRetries).toBe(5); + }); + + it("rejects a retry policy on OllamaConfig with the Python text", async () => { + await expect( + convertLlmConfig( + createOllamaConfig({ + name: "oll", + modelId: "llama3.1", + url: "http://localhost:11434", + retryPolicy: { maxAttempts: 1 }, + }), + ), + ).rejects.toThrow( + "LangGraph ChatOllama conversion does not support `RetryPolicy`.", + ); + }); + + it("rejects a retry policy on OciGenAiConfig before the unsupported-type error", async () => { + await expect( + convertLlmConfig( + createOciGenAiConfig({ + name: "oci", + modelId: "meta.llama-3.1-70b-instruct", + compartmentId: "ocid1.compartment.oc1..x", + clientConfig: createOciClientConfigWithApiKey({ + name: "client", + serviceEndpoint: "https://inference.generativeai.example.com", + authProfile: "DEFAULT", + authFileLocation: "~/.oci/config", + }), + retryPolicy: { maxAttempts: 1 }, + }), + ), + ).rejects.toThrow( + "LangGraph OCI GenAI conversion does not support `RetryPolicy`.", + ); + }); +}); + +describe("convertLlmConfig for the bare LlmConfig", () => { + // Ports pyagentspec/tests/adapters/langgraph/test_bare_llmconfig_dispatch.py. + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("openai provider respects api_type responses", async () => { + vi.stubEnv("OPENAI_API_KEY", "DUMMY_KEY"); + const model = await convertToChatOpenAi( + createLlmConfig({ + name: "test", + modelId: "gpt-4o", + apiProvider: "openai", + apiType: "responses", + }), + ); + expect(model.useResponsesApi).toBe(true); + }); + + it("openai provider defaults to chat completions", async () => { + vi.stubEnv("OPENAI_API_KEY", "DUMMY_KEY"); + const model = await convertToChatOpenAi( + createLlmConfig({ name: "test", modelId: "gpt-4o", apiProvider: "openai" }), + ); + expect(model.model).toBe("gpt-4o"); + expect(model.useResponsesApi).toBe(false); + expect(model.clientConfig.baseURL).toBeUndefined(); + }); + + it("openai provider forwards the base url verbatim (no /v1 normalization) and the api key", async () => { + const model = await convertToChatOpenAi( + createLlmConfig({ + name: "test", + modelId: "gpt-4o", + apiProvider: "openai", + url: "https://my-proxy.example.com/v1", + apiKey: "sk-test-key", + }), + ); + expect(model.clientConfig.baseURL).toBe("https://my-proxy.example.com/v1"); + expect(model.apiKey).toBe("sk-test-key"); + }); + + it("openai provider adds a scheme to a raw base url", async () => { + vi.stubEnv("OPENAI_API_KEY", "DUMMY_KEY"); + const model = await convertToChatOpenAi( + createLlmConfig({ + name: "test", + modelId: "gpt-4o", + apiProvider: "openai", + url: "localhost:8000", + }), + ); + expect(model.clientConfig.baseURL).toBe("http://localhost:8000"); + }); + + it("openai provider maps the retry policy like the dedicated configs", async () => { + vi.stubEnv("OPENAI_API_KEY", "DUMMY_KEY"); + const model = await convertToChatOpenAi( + createLlmConfig({ + name: "test", + modelId: "gpt-4o", + apiProvider: "openai", + retryPolicy: { maxAttempts: 3, requestTimeout: 45 }, + }), + ); + expect(model.caller.maxRetries).toBe(3); + expect(model.timeout).toBe(45_000); + }); + + it("rejects unsupported api providers with the Python text", async () => { + await expect( + convertLlmConfig( + createLlmConfig({ + name: "test", + modelId: "some-model", + apiProvider: "unsupported_provider", + }), + ), + ).rejects.toThrow( + "LlmConfig with api_provider='unsupported_provider' is not yet " + + "supported in langgraph. Consider using a specific LlmConfig " + + "subclass instead.", + ); + }); +}); + describe("convertLlmConfig rejections", () => { it("rejects OciGenAiConfig (no langchain-oci package for JS)", async () => { const ociConfig = createOciGenAiConfig({ diff --git a/tsagentspec/tests/adapters/langgraph/mcp.test.ts b/tsagentspec/tests/adapters/langgraph/mcp.test.ts index 25284a1b..47fb2786 100644 --- a/tsagentspec/tests/adapters/langgraph/mcp.test.ts +++ b/tsagentspec/tests/adapters/langgraph/mcp.test.ts @@ -18,9 +18,13 @@ */ import { beforeEach, describe, expect, it, vi } from "vitest"; import { + AgentSpecDeserializer, + AgentSpecSerializer, createMCPTool, createMCPToolBox, createMCPToolSpec, + createOAuthClientConfig, + createOAuthConfig, createRemoteTransport, createSSETransport, createSSEmTLSTransport, @@ -30,6 +34,7 @@ import { integerProperty, stringProperty, type ClientTransport, + type SSETransport, } from "../../../src/index.js"; import { convertClientTransport, @@ -184,6 +189,61 @@ describe("convertClientTransport", () => { }); }); +describe("transport auth and retryPolicy (representation-only)", () => { + function makeAuthedSseTransport(): SSETransport { + return createSSETransport({ + name: "my server", + url: "https://example.com/sse", + headers: { "X-Static": "1" }, + auth: createOAuthConfig({ + name: "oauth", + client: createOAuthClientConfig({ + name: "client", + type: "pre_registered", + clientId: "client-id", + clientSecret: "client-secret", + }), + redirectUri: "https://app.example.com/callback", + scopes: ["mcp.read"], + }), + retryPolicy: { maxAttempts: 3, initialRetryDelay: 0.25 }, + }); + } + + it("builds the MCP connection without wiring auth or retryPolicy, like Python", () => { + // Python's converter builds its connections from url/headers alone and + // wires no runtime OAuth flow or MCP-session retry either — both fields + // are representation-only in both SDKs. + expect(convertClientTransport(makeAuthedSseTransport())).toEqual({ + transport: "sse", + url: "https://example.com/sse", + headers: { "X-Static": "1" }, + }); + }); + + it("keeps transport auth and retryPolicy untouched through load -> export", () => { + // The adapter loader/exporter delegate (de)serialization to these SDK + // classes, and the runtime conversion above never mutates the spec + // component, so both fields survive a load -> export round trip + // byte-identically (client secrets stay redacted on both sides). + const transport = makeAuthedSseTransport(); + const serializer = new AgentSpecSerializer(); + const deserializer = new AgentSpecDeserializer(); + + const json = serializer.toJson(transport) as string; + const loaded = deserializer.fromJson(json) as SSETransport; + + expect(loaded.auth?.componentType).toBe("OAuthConfig"); + expect(loaded.auth?.client.type).toBe("pre_registered"); + expect(loaded.auth?.redirectUri).toBe("https://app.example.com/callback"); + expect(loaded.retryPolicy?.maxAttempts).toBe(3); + expect(loaded.retryPolicy?.initialRetryDelay).toBe(0.25); + + const reserialized = serializer.toJson(loaded) as string; + expect(JSON.parse(reserialized)).toEqual(JSON.parse(json)); + }); +}); + describe("getOrCreateMcpTools registry cache", () => { it("loads tools once and caches them under `${transport.id}::${toolName}`", async () => { const transport = makeSseTransport(); diff --git a/tsagentspec/tests/adapters/langgraph/remote-tools.test.ts b/tsagentspec/tests/adapters/langgraph/remote-tools.test.ts index 439676f1..98044da4 100644 --- a/tsagentspec/tests/adapters/langgraph/remote-tools.test.ts +++ b/tsagentspec/tests/adapters/langgraph/remote-tools.test.ts @@ -2,18 +2,20 @@ * RemoteTool / ClientTool conversion tests for the LangGraph adapter. * * Mirrors the RemoteTool sections of - * `pyagentspec/tests/adapters/langgraph/test_tools.py` with a mocked global - * fetch (JS equivalent of patching `httpx.request`): template rendering in - * url/data/headers/queryParams, body routing (urlencoded form vs raw string - * vs JSON), the confirmation-interrupt machinery and the ClientTool - * interrupt protocol. + * `pyagentspec/tests/adapters/langgraph/test_tools.py` and the retry-policy + * matrix of `pyagentspec/tests/adapters/test_remote_tool_retry_policy_cases.py` + * with a mocked global fetch (JS equivalent of patching `httpx.request`): + * template rendering in url/data/headers/queryParams, body routing + * (urlencoded form vs raw string vs JSON), retry-policy behavior (per-tool + * timeouts, transport-error and recoverable-status retries, Retry-After, + * TLS no-retry, raise-for-status), URL allow-list enforcement, the + * confirmation-interrupt machinery and the ClientTool interrupt protocol. * * Documented divergences exercised here (see tools.ts / tools-common.ts): - * - a single fetch attempt, no retry engine (TS SDK has no RetryPolicy); * - fetch forbids GET/HEAD bodies, so none is sent for those methods; * - confirmation `Args:` strings use JSON.stringify (Python uses str(dict)). */ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { StructuredToolInterface } from "@langchain/core/tools"; import { Annotation, @@ -51,6 +53,8 @@ let mockFetch: MockFetchController | undefined; afterEach(() => { mockFetch?.restore(); mockFetch = undefined; + vi.restoreAllMocks(); + vi.useRealTimers(); }); function headersOf(init: RequestInit | undefined): Record { @@ -291,9 +295,10 @@ describe("convertRemoteTool responses", () => { }); it("parses and returns the JSON body of non-2xx responses like Python", async () => { - // Python without a retry policy (the only state the TS RemoteTool can - // express) returns response.json() for every status, so the agent sees - // error payloads as the tool result instead of an aborted run. + // Python without a retry policy returns response.json() for every + // status, so the agent sees error payloads as the tool result instead of + // an aborted run (with a policy, a final error status raises — see the + // retry-policy suite below). mockFetch = installMockFetch( () => new Response('{"error": "bad date range"}', { @@ -345,9 +350,9 @@ describe("convertRemoteTool responses", () => { }); it("attaches the default httpx-parity timeout and names the tool on a timeout abort", async () => { - // Python's httpx applies a 5s default timeout; the TS SDK RemoteTool has - // no RetryPolicy.requestTimeout yet, so the exported constant is the only - // knob and a timeout abort maps to an Error naming the tool. + // Python's httpx applies a 5s default timeout; without a + // RetryPolicy.requestTimeout override the exported constant is the knob + // and a timeout abort maps to an Error naming the tool. expect(DEFAULT_HTTP_REQUEST_TIMEOUT_MS).toBe(5000); mockFetch = installMockFetch(() => { throw new DOMException( @@ -408,6 +413,283 @@ describe("convertRemoteTool responses", () => { }); }); +describe("convertRemoteTool retry policy", () => { + // Ports pyagentspec/tests/adapters/test_remote_tool_retry_policy_cases.py + // through the LangGraph wrapper (Python's adapters subclass the shared + // case class the same way). + function makeRetryTool( + retryPolicy?: Parameters[0]["retryPolicy"], + ) { + return createRemoteTool({ + name: "retry_service", + description: "A remote service with retry policy", + url: "https://example.com/api", + httpMethod: "GET", + ...(retryPolicy !== undefined ? { retryPolicy } : {}), + }); + } + + function jsonResponse( + body: unknown, + status: number, + headers: Record = {}, + ): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json", ...headers }, + }); + } + + it.each([ + ["a policy with requestTimeout", { maxAttempts: 0, requestTimeout: 300 }, 300_000], + ["a policy without requestTimeout", { maxAttempts: 0 }, 5000], + ["no policy", undefined, 5000], + ] as const)( + "passes the per-tool request timeout for %s", + async (_label, retryPolicy, expectedTimeoutMs) => { + const timeoutSpy = vi.spyOn(AbortSignal, "timeout"); + mockFetch = installMockFetch(() => ({ result: "ok" })); + + await expect( + convertRemoteTool(makeRetryTool(retryPolicy)).invoke({}), + ).resolves.toEqual({ result: "ok" }); + + expect(timeoutSpy).toHaveBeenCalledTimes(1); + expect(timeoutSpy).toHaveBeenCalledWith(expectedTimeoutMs); + }, + ); + + it("retries transport errors and applies the timeout override to every attempt", async () => { + const timeoutSpy = vi.spyOn(AbortSignal, "timeout"); + let call = 0; + mockFetch = installMockFetch(() => { + call += 1; + if (call <= 2) { + throw new TypeError("temporary failure"); + } + return { result: "ok" }; + }); + const remoteTool = makeRetryTool({ + maxAttempts: 2, + requestTimeout: 0.5, + initialRetryDelay: 0, + maxRetryDelay: 0, + }); + + await expect(convertRemoteTool(remoteTool).invoke({})).resolves.toEqual({ + result: "ok", + }); + expect(mockFetch.calls).toHaveLength(3); + expect(timeoutSpy.mock.calls.map(([ms]) => ms)).toEqual([500, 500, 500]); + }); + + it("retries a 5xx service error under the default recoverable rules", async () => { + let call = 0; + mockFetch = installMockFetch(() => { + call += 1; + return call === 1 + ? jsonResponse({ error: "busy" }, 503) + : { result: "ok" }; + }); + const remoteTool = makeRetryTool({ + maxAttempts: 1, + initialRetryDelay: 0, + maxRetryDelay: 0, + }); + + await expect(convertRemoteTool(remoteTool).invoke({})).resolves.toEqual({ + result: "ok", + }); + expect(mockFetch.calls).toHaveLength(2); + }); + + it("retries a configured status only when the body carries a recoverable error code", async () => { + let call = 0; + mockFetch = installMockFetch(() => { + call += 1; + return call === 1 + ? jsonResponse({ code: "TooManyRequests", message: "throttled" }, 429) + : { result: "ok" }; + }); + const remoteTool = makeRetryTool({ + maxAttempts: 1, + initialRetryDelay: 0, + maxRetryDelay: 0, + serviceErrorRetryOnAny5xx: false, + recoverableStatuses: { "429": ["TooManyRequests"] }, + }); + + await expect(convertRemoteTool(remoteTool).invoke({})).resolves.toEqual({ + result: "ok", + }); + expect(mockFetch.calls).toHaveLength(2); + }); + + it("honors Retry-After delays, capped at 30 seconds", async () => { + vi.useFakeTimers(); + let call = 0; + mockFetch = installMockFetch(() => { + call += 1; + return call === 1 + ? jsonResponse({ error: "throttled" }, 429, { "Retry-After": "45" }) + : { result: "ok" }; + }); + const remoteTool = makeRetryTool({ + maxAttempts: 1, + initialRetryDelay: 0, + maxRetryDelay: 0, + recoverableStatuses: { "429": [] }, + }); + + let settled = false; + const resultPromise = convertRemoteTool(remoteTool) + .invoke({}) + .finally(() => { + settled = true; + }); + // The 45s Retry-After is capped at 30s: one millisecond earlier the + // retry has not fired yet. + await vi.advanceTimersByTimeAsync(29_999); + expect(settled).toBe(false); + expect(mockFetch.calls).toHaveLength(1); + await vi.advanceTimersByTimeAsync(1); + await expect(resultPromise).resolves.toEqual({ result: "ok" }); + expect(mockFetch.calls).toHaveLength(2); + }); + + it("raises for the final status after recoverable retries are exhausted", async () => { + let call = 0; + mockFetch = installMockFetch(() => { + call += 1; + return call === 1 + ? jsonResponse({ error: "busy" }, 503) + : jsonResponse({ error: "still busy" }, 503); + }); + const remoteTool = makeRetryTool({ + maxAttempts: 1, + initialRetryDelay: 0, + maxRetryDelay: 0, + }); + + await expect(convertRemoteTool(remoteTool).invoke({})).rejects.toThrow( + "RemoteTool `retry_service` HTTP request failed with status '503' " + + "for url 'https://example.com/api'.", + ); + expect(mockFetch.calls).toHaveLength(2); + }); + + it("raises immediately for a non-retryable status when a policy is set", async () => { + // 400/401/403/422 (and 501) are never retried, and with a policy the + // error status raises instead of flowing back as the tool result. + mockFetch = installMockFetch(() => jsonResponse({ error: "bad" }, 400)); + const remoteTool = makeRetryTool({ + maxAttempts: 2, + initialRetryDelay: 0, + maxRetryDelay: 0, + }); + + await expect(convertRemoteTool(remoteTool).invoke({})).rejects.toThrow( + /400/, + ); + expect(mockFetch.calls).toHaveLength(1); + }); + + it("does not retry TLS certificate failures", async () => { + // undici wraps the TLS failure in `TypeError: fetch failed` with the + // real error on `cause` (Python walks `__cause__` the same way). + mockFetch = installMockFetch(() => { + throw new TypeError("fetch failed", { + cause: Object.assign( + new Error("unable to verify the first certificate"), + { code: "UNABLE_TO_VERIFY_LEAF_SIGNATURE" }, + ), + }); + }); + const remoteTool = makeRetryTool({ + maxAttempts: 2, + initialRetryDelay: 0, + maxRetryDelay: 0, + }); + + await expect(convertRemoteTool(remoteTool).invoke({})).rejects.toThrow( + "fetch failed", + ); + expect(mockFetch.calls).toHaveLength(1); + }); + + it("rethrows the transport error once retries are exhausted", async () => { + mockFetch = installMockFetch(() => { + throw new TypeError("temporary failure"); + }); + const remoteTool = makeRetryTool({ + maxAttempts: 1, + initialRetryDelay: 0, + maxRetryDelay: 0, + }); + + await expect(convertRemoteTool(remoteTool).invoke({})).rejects.toThrow( + "temporary failure", + ); + expect(mockFetch.calls).toHaveLength(2); + }); +}); + +describe("convertRemoteTool url allow list", () => { + // Ports the allow-list tests of + // pyagentspec/tests/adapters/langgraph/test_tools.py. + function makeAllowListTool(urlAllowList?: string[]) { + return createRemoteTool({ + name: "lookup", + description: "Looks up remote data", + url: "https://{{host}}/api/value", + httpMethod: "GET", + inputs: [stringProperty({ title: "host" })], + ...(urlAllowList !== undefined ? { urlAllowList } : {}), + }); + } + + it("rejects a rendered URL outside the allow list without calling fetch", async () => { + mockFetch = installMockFetch(() => ({ ok: true })); + const langchainTool = convertRemoteTool( + makeAllowListTool(["https://allowed.example.com/api/"]), + ); + + await expect( + langchainTool.invoke({ host: "blocked.example.com" }), + ).rejects.toThrow( + "Requested URL is not in allowed list. Please contact the application " + + "administrator to help adding your URL to the list.", + ); + expect(mockFetch.calls).toHaveLength(0); + }); + + it("allows a rendered URL matching an allow-list entry", async () => { + mockFetch = installMockFetch(() => ({ ok: true })); + const langchainTool = convertRemoteTool( + makeAllowListTool(["https://allowed.example.com/api/"]), + ); + + await expect( + langchainTool.invoke({ host: "allowed.example.com" }), + ).resolves.toEqual({ ok: true }); + expect(mockFetch.calls[0]!.url).toBe("https://allowed.example.com/api/value"); + }); + + it("suppresses the templated-destination warning when an allow list is configured", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + convertRemoteTool(makeAllowListTool(["https://allowed.example.com/api/"])); + expect(warnSpy).not.toHaveBeenCalled(); + + convertRemoteTool(makeAllowListTool()); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + "RemoteTool `lookup` uses placeholders in the URL destination", + ), + ); + }); +}); + describe("requiresConfirmation interrupt machinery", () => { function makeConfirmedRemoteTool() { return createRemoteTool({ From 4b8d05d36653af9a4870ea65f03f9a0561d8382d Mon Sep 17 00:00:00 2001 From: Salah Date: Sat, 5 Sep 2026 12:52:59 +0400 Subject: [PATCH 10/14] feat(tsagentspec/adapters): emit Agent Spec traces from the LangGraph 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. --- .../adapters/langgraph/langgraph-converter.ts | 8 +- tsagentspec/src/adapters/langgraph/llm.ts | 16 +- tsagentspec/src/adapters/langgraph/mcp.ts | 70 +- .../langgraph/node-execution/executor.ts | 52 +- .../langgraph/node-execution/subflow-nodes.ts | 18 +- tsagentspec/src/adapters/langgraph/tools.ts | 70 +- tsagentspec/src/adapters/langgraph/tracing.ts | 780 ++++++++++++- .../tests/adapters/langgraph/tracing.test.ts | 1040 +++++++++++++++++ 8 files changed, 1992 insertions(+), 62 deletions(-) create mode 100644 tsagentspec/tests/adapters/langgraph/tracing.test.ts diff --git a/tsagentspec/src/adapters/langgraph/langgraph-converter.ts b/tsagentspec/src/adapters/langgraph/langgraph-converter.ts index 3cfb16ca..517fb283 100644 --- a/tsagentspec/src/adapters/langgraph/langgraph-converter.ts +++ b/tsagentspec/src/adapters/langgraph/langgraph-converter.ts @@ -20,10 +20,10 @@ * schema (`Annotation.Root` is silently ignored by the JS `createAgent`); * the langchain JS agent state has no `remaining_steps` channel, so no such * key is added. - * - No tracing callbacks/spans are attached; `patchWithExecutionSpan` is an - * identity seam invoked at the same graph-compilation sites as Python with - * the compiled-from component, while LLM/tool callback attachment has no - * seam at all (see `tracing.ts`). + * - `patchWithExecutionSpan` wraps the compiled graph through a Proxy at the + * same graph-compilation sites as Python (which monkey-patches + * stream/astream in place); LLM/tool tracing callbacks are attached where + * Python attaches them (see `tracing.ts` for the divergences). * - Python's "async interrupts on Python < 3.11" load-time warning has no JS * equivalent and is not ported. */ diff --git a/tsagentspec/src/adapters/langgraph/llm.ts b/tsagentspec/src/adapters/langgraph/llm.ts index eb61a95a..cf302b44 100644 --- a/tsagentspec/src/adapters/langgraph/llm.ts +++ b/tsagentspec/src/adapters/langgraph/llm.ts @@ -11,13 +11,14 @@ * client takes seconds), so `RetryPolicy.requestTimeout` (seconds) is * multiplied by 1000. * - OciGenAiConfig is not supported (no langchain-oci package for JS). - * - No tracing callbacks are attached here (tracing is a no-op seam in v1). */ +import type { BaseCallbackHandler } from "@langchain/core/callbacks/base"; import type { BaseChatModel } from "@langchain/core/language_models/chat_models"; import type { LlmConfig, LlmGenerationConfig } from "../../llms/index.js"; import { OpenAIAPIType } from "../../llms/index.js"; import { RetryPolicySchema, type RetryPolicy } from "../../retry-policy.js"; import { importOptionalPeer } from "../common/index.js"; +import { AgentSpecLlmCallbackHandler } from "./tracing.js"; function ensureUrlHasScheme(url: string): string { const trimmed = url.trim(); @@ -149,6 +150,7 @@ async function createChatOpenAiModel(options: { useResponsesApi: boolean; generationConfig: LlmGenerationConfig; retryConfig: ChatRetryConfig; + callbacks: BaseCallbackHandler[]; baseUrl?: string; apiKey?: string; }): Promise { @@ -168,6 +170,7 @@ async function createChatOpenAiModel(options: { model: options.modelId, useResponsesApi: options.useResponsesApi, apiKey, + callbacks: options.callbacks, temperature: options.generationConfig.temperature, maxTokens: options.generationConfig.maxTokens, topP: options.generationConfig.topP, @@ -202,6 +205,13 @@ export async function convertLlmConfig( // the fields individually, so unset ones simply stay undefined. const generationConfig = llmConfig.defaultGenerationParameters ?? {}; + // Every chat model the converter creates carries the Agent Spec LLM + // tracing handler (Python parity; the unsupported OCI branch is the one + // Python site without callbacks). + const callbacks: BaseCallbackHandler[] = [ + new AgentSpecLlmCallbackHandler(llmConfig), + ]; + switch (llmConfig.componentType) { case "VllmConfig": case "OpenAiCompatibleConfig": @@ -212,6 +222,7 @@ export async function convertLlmConfig( useResponsesApi: llmConfig.apiType === OpenAIAPIType.RESPONSES, generationConfig, retryConfig: retryPolicyConvertToLanggraph(llmConfig.retryPolicy), + callbacks, }); case "OllamaConfig": { if (llmConfig.retryPolicy != null) { @@ -228,6 +239,7 @@ export async function convertLlmConfig( return new ChatOllama({ baseUrl: llmConfig.url, model: llmConfig.modelId, + callbacks, temperature: generationConfig.temperature, numPredict: generationConfig.maxTokens, topP: generationConfig.topP, @@ -240,6 +252,7 @@ export async function convertLlmConfig( useResponsesApi: llmConfig.apiType === OpenAIAPIType.RESPONSES, generationConfig, retryConfig: retryPolicyConvertToLanggraph(llmConfig.retryPolicy), + callbacks, }); case "LlmConfig": { // Bare LlmConfig — dispatch on the api_provider string, like Python. @@ -255,6 +268,7 @@ export async function convertLlmConfig( useResponsesApi: llmConfig.apiType === "responses", generationConfig, retryConfig: retryPolicyConvertToLanggraph(llmConfig.retryPolicy), + callbacks, }); } throw new Error( diff --git a/tsagentspec/src/adapters/langgraph/mcp.ts b/tsagentspec/src/adapters/langgraph/mcp.ts index 0ba84a1f..ef206741 100644 --- a/tsagentspec/src/adapters/langgraph/mcp.ts +++ b/tsagentspec/src/adapters/langgraph/mcp.ts @@ -17,15 +17,24 @@ * - Tools are loaded through a `MultiServerMCPClient` that keeps its * connection open for the lifetime of the loaded tools (Python opens a * fresh MCP session per tool call). - * - No tracing callbacks are attached to loaded tools (tracing is a no-op - * seam in v1). + * - The tracing handler is attached after the loaded-tool names are + * validated for the registry (Python attaches first); the synthesized + * MCPTool needs a valid tool name either way. */ +import type { BaseCallbackHandler } from "@langchain/core/callbacks/base"; import type { StructuredToolInterface } from "@langchain/core/tools"; import type { Connection } from "@langchain/mcp-adapters"; +import { createMCPTool } from "../../mcp/index.js"; import type { ClientTransport, MCPTool, MCPToolSpec } from "../../mcp/index.js"; -import type { JsonSchemaValue } from "../../property.js"; +import { + propertyFromJsonSchema, + stringProperty, + type JsonSchemaValue, + type Property, +} from "../../property.js"; import type { MCPToolBox } from "../../tools/index.js"; import { importOptionalPeer, jsonSchemasHaveSameType } from "../common/index.js"; +import { AgentSpecToolCallbackHandler } from "./tracing.js"; import type { ToolRegistry } from "./types.js"; /** @@ -114,6 +123,58 @@ function getSessionToolsFromToolRegistry( return sessionTools; } +/** The declared input properties of a loaded LangChain MCP tool (its JSON-schema `properties`). */ +function loadedMcpToolInputs(loadedTool: StructuredToolInterface): Property[] { + const schema = (loadedTool as { schema?: unknown }).schema; + const properties = + typeof schema === "object" && + schema !== null && + !Array.isArray(schema) && + typeof (schema as JsonSchemaValue)["properties"] === "object" && + (schema as JsonSchemaValue)["properties"] !== null + ? ((schema as JsonSchemaValue)["properties"] as Record< + string, + JsonSchemaValue + >) + : {}; + return Object.entries(properties).map(([argName, argJsonSchema]) => + propertyFromJsonSchema({ ...argJsonSchema, title: argName }), + ); +} + +/** + * Attach the Agent Spec tool tracing handler to every loaded MCP tool, + * synthesizing an `MCPTool` definition on the fly (toolbox members may have + * no spec-side definition), mirroring Python's + * `_get_or_create_langgraph_mcp_tools` callback wiring. The synthesized tool + * declares the single `tool_output` string output like Python. + */ +function attachTracingCallbacks( + tools: StructuredToolInterface[], + clientTransport: ClientTransport, +): void { + for (const loadedTool of tools) { + const agentspecTool = createMCPTool({ + name: loadedTool.name, + description: loadedTool.description ?? "", + clientTransport, + inputs: loadedMcpToolInputs(loadedTool), + outputs: [stringProperty({ title: "tool_output" })], + }); + const mutableTool = loadedTool as { callbacks?: unknown }; + const existingCallbacks: unknown[] = + mutableTool.callbacks === undefined || mutableTool.callbacks === null + ? [] + : Array.isArray(mutableTool.callbacks) + ? mutableTool.callbacks + : [mutableTool.callbacks]; + existingCallbacks.push( + new AgentSpecToolCallbackHandler(agentspecTool) as BaseCallbackHandler, + ); + mutableTool.callbacks = existingCallbacks; + } +} + function addSessionToolsToRegistry( toolRegistry: ToolRegistry, tools: StructuredToolInterface[], @@ -174,6 +235,9 @@ export async function getOrCreateMcpTools( const tools = await client.getTools(serverName); addSessionToolsToRegistry(toolRegistry, tools, connPrefix); + // Add tracing callbacks to the loaded tools (after registry-name + // validation, so a nameless tool keeps its registry error). + attachTracingCallbacks(tools, clientTransport); return getSessionToolsFromToolRegistry(toolRegistry, connPrefix); } diff --git a/tsagentspec/src/adapters/langgraph/node-execution/executor.ts b/tsagentspec/src/adapters/langgraph/node-execution/executor.ts index 25441ee3..d62c8eb6 100644 --- a/tsagentspec/src/adapters/langgraph/node-execution/executor.ts +++ b/tsagentspec/src/adapters/langgraph/node-execution/executor.ts @@ -13,14 +13,21 @@ * - Executors receive their collaborators from the converter (converted * tools, chat models, compiled subgraphs, agent compile factories) instead * of importing the converter, so there are no module cycles. - * - Node execution spans/events are not emitted (tracing is a no-op seam). + * - The node execution span runs in a forked ambient context (`Span.run`), + * so parallel graph branches keep isolated span stacks where Python + * relies on `copy_context` snapshots. */ import type { BaseMessage } from "@langchain/core/messages"; import type { RunnableConfig } from "@langchain/core/runnables"; import { addMessages } from "@langchain/langgraph"; -import type { DataFlowEdge } from "../../../flows/index.js"; +import type { DataFlowEdge, Node } from "../../../flows/index.js"; import { DEFAULT_NEXT_BRANCH } from "../../../flows/index.js"; import type { Property } from "../../../property.js"; +import { + NodeExecutionEnd, + NodeExecutionSpan, + NodeExecutionStart, +} from "../../../tracing/index.js"; import { isRecordLike } from "../../common/index.js"; import type { ExecuteOutput, @@ -35,6 +42,8 @@ import { castValuesAndAddDefaults } from "./python-parity.js"; export interface FlowNodeLike { id: string; name: string; + /** The node's Agent Spec type (Python's `type(node).__name__`), used in span names. */ + componentType: string; inputs?: Property[]; outputs?: Property[]; } @@ -62,14 +71,41 @@ export abstract class NodeExecutor< this.edges.push(edge); } - /** Execute this node against the current flow state (LangGraph node fn). */ + /** + * Execute this node against the current flow state (LangGraph node fn), + * inside a `NodeExecutionSpan` with start/end events (Python's + * `NodeExecutor.__acall__`). An error thrown by the node records an + * ExceptionRaised event on the span before propagating. + */ async call(state: FlowState, _config?: RunnableConfig): Promise { const inputs = this.getInputs(state); - const [outputs, executionDetails] = await this._execute( - inputs, - state.messages ?? [], - ); - return this.updateStatus(outputs, executionDetails, state); + const spanName = `${this.node.componentType}Execution[${this.node.name}]`; + const span = new NodeExecutionSpan({ + name: spanName, + node: this.node as unknown as Node, + }); + return span.run(async () => { + await span.addEvent( + new NodeExecutionStart({ + node: this.node as unknown as Node, + inputs, + }), + ); + const [outputs, executionDetails] = await this._execute( + inputs, + state.messages ?? [], + ); + const updatedStatus = this.updateStatus(outputs, executionDetails, state); + await span.addEvent( + new NodeExecutionEnd({ + node: this.node as unknown as Node, + outputs: updatedStatus.outputs, + branchSelected: + updatedStatus.node_execution_details.branch ?? DEFAULT_NEXT_BRANCH, + }), + ); + return updatedStatus; + }); } /** Execute the node with the given cast inputs; returns outputs + details. */ diff --git a/tsagentspec/src/adapters/langgraph/node-execution/subflow-nodes.ts b/tsagentspec/src/adapters/langgraph/node-execution/subflow-nodes.ts index c5f71419..7b72d0d7 100644 --- a/tsagentspec/src/adapters/langgraph/node-execution/subflow-nodes.ts +++ b/tsagentspec/src/adapters/langgraph/node-execution/subflow-nodes.ts @@ -6,10 +6,6 @@ * `pyagentspec.adapters.langgraph._node_execution`. Runtime contracts (state * keys, branch names, error-message text) mirror the Python adapter exactly * so specs behave the same across both SDKs. - * - * Divergence from Python (see the adapter README): node execution - * spans/events are not emitted (tracing is a no-op seam), so the - * CatchExceptionNode emits no ExceptionRaised event on error. */ import type { BaseMessage } from "@langchain/core/messages"; import type { RunnableConfig } from "@langchain/core/runnables"; @@ -23,6 +19,10 @@ import { DEFAULT_NEXT_BRANCH, } from "../../../flows/index.js"; import type { Property } from "../../../property.js"; +import { + exceptionRaisedFromError, + getCurrentSpan, +} from "../../../tracing/index.js"; import { isRecordLike, stringifyTemplateValue } from "../../common/index.js"; import type { ExecuteOutput, @@ -105,8 +105,14 @@ export class CatchExceptionNodeExecutor extends NodeExecutor | undefined; return [outputs, { branch: details?.branch ?? DEFAULT_NEXT_BRANCH }]; } catch (error) { - // Python emits an ExceptionRaised event on the current node span here; - // tracing is a no-op seam in the TS adapter, so nothing is emitted. + // On exception: record it on the ambient span (this node's + // NodeExecutionSpan) and return the default subflow outputs with the + // exception message on the caught_exception_branch, mirroring Python. + const currentSpan = getCurrentSpan(); + if (currentSpan !== undefined) { + await currentSpan.addEvent(exceptionRaisedFromError(error)); + } + // Python logs a debug message when no span is active; nothing to do. const defaultOutputs: NodeOutputs = {}; const subflowOutputs = (this.node.subflow["outputs"] as Property[] | undefined) ?? []; diff --git a/tsagentspec/src/adapters/langgraph/tools.ts b/tsagentspec/src/adapters/langgraph/tools.ts index f2a06455..84c5243d 100644 --- a/tsagentspec/src/adapters/langgraph/tools.ts +++ b/tsagentspec/src/adapters/langgraph/tools.ts @@ -18,8 +18,10 @@ * (JS is async-native). * - Interpolated values in mirrored error/interrupt messages are rendered with * `JSON.stringify` instead of Python's `repr`. - * - No tracing callbacks are attached (tracing is a no-op seam in v1). + * - The `tool()` factory's runtime `callbacks` field is untyped, so the + * tracing-handler attachment goes through a small typed helper. */ +import type { BaseCallbackHandler } from "@langchain/core/callbacks/base"; import type { StructuredToolInterface } from "@langchain/core/tools"; import { isStructuredTool, tool } from "@langchain/core/tools"; import type { BaseCheckpointSaver } from "@langchain/langgraph"; @@ -36,10 +38,34 @@ import { createRemoteToolFunc, isRecordLike, } from "../common/index.js"; +import { AgentSpecToolCallbackHandler } from "./tracing.js"; import type { ToolImplementation, ToolRegistry } from "./types.js"; const ALLOWED_DECISIONS = ["approve", "reject"]; +/** + * Create a LangChain structured tool carrying the Agent Spec tool tracing + * handler. The `tool()` factory accepts `callbacks` at runtime but its + * current typings do not declare the field, hence the options cast. + */ +function toolWithTracingCallback( + func: (input: unknown) => unknown, + options: { + name: string; + description?: string; + schema: unknown; + }, + agentspecTool: Tool, +): StructuredToolInterface { + const callbacks: BaseCallbackHandler[] = [ + new AgentSpecToolCallbackHandler(agentspecTool), + ]; + return tool(func, { + ...options, + callbacks, + } as unknown as Parameters[1]) as StructuredToolInterface; +} + /** A tool implementation function: receives the parsed input object. */ export type ToolFunction = ToolImplementation; @@ -259,11 +285,15 @@ export function convertServerTool( toolName, requiresConfirmation, ); - return tool(wrapped as (input: unknown) => unknown, { - name: registeredTool.name, - description: registeredTool.description, - schema: registeredTool.schema, - }) as StructuredToolInterface; + return toolWithTracingCallback( + wrapped as (input: unknown) => unknown, + { + name: registeredTool.name, + description: registeredTool.description, + schema: registeredTool.schema, + }, + agentspecServerTool, + ); } if (typeof toolObj === "function") { const toolInputs = agentspecServerTool.inputs ?? []; @@ -273,11 +303,15 @@ export function convertServerTool( isRecordLike(input) ? applyInputDefaults(input, toolInputs) : input, config, ); - return tool(withDefaults as (input: unknown) => unknown, { - name: toolName, - description: toolDescription, - schema: buildArgsSchema(toolName, toolInputs), - }) as StructuredToolInterface; + return toolWithTracingCallback( + withDefaults as (input: unknown) => unknown, + { + name: toolName, + description: toolDescription, + schema: buildArgsSchema(toolName, toolInputs), + }, + agentspecServerTool, + ); } throw new Error( `Unsupported tool type for '${toolName}': ${typeof toolObj}. ` + @@ -354,9 +388,13 @@ export function convertRemoteTool( applyInputDefaults(isRecordLike(input) ? input : {}, toolInputs), config, ); - return tool(withDefaults as (input: unknown) => unknown, { - name: toolName, - description: toolDescription, - schema: buildArgsSchema(toolName, toolInputs), - }) as StructuredToolInterface; + return toolWithTracingCallback( + withDefaults as (input: unknown) => unknown, + { + name: toolName, + description: toolDescription, + schema: buildArgsSchema(toolName, toolInputs), + }, + agentspecRemoteTool, + ); } diff --git a/tsagentspec/src/adapters/langgraph/tracing.ts b/tsagentspec/src/adapters/langgraph/tracing.ts index e0457f5d..0b73a365 100644 --- a/tsagentspec/src/adapters/langgraph/tracing.ts +++ b/tsagentspec/src/adapters/langgraph/tracing.ts @@ -1,27 +1,578 @@ /** - * Tracing seam for the LangGraph adapter. + * Tracing for the LangGraph adapter. * - * The Python adapter wraps every compiled agent / flow / manager-workers - * graph in an execution span (patching `stream`/`astream`). The TypeScript - * SDK has no tracing package yet, so `patchWithExecutionSpan` is an identity - * seam: it is invoked from the same graph-compilation sites as Python, and - * receives the Agent Spec component the graph was compiled from, so a future - * port of `pyagentspec.tracing` only needs to fill in the implementation here - * without touching the converter. + * Port of `pyagentspec.adapters.langgraph.tracing` (the LLM and tool callback + * handlers) and `pyagentspec.adapters.langgraph._execution_span` (the + * graph-level execution-span wrapper). Span and event payloads match the + * Python adapter so span processors work across both SDKs. * - * Python's LLM and tool callback handlers are NOT seamed here: no callbacks - * are attached in this adapter (see the divergence notes in `llm.ts`, - * `tools.ts` and `mcp.ts`), so a tracing port must add those attachment - * sites itself. + * Divergences from Python (see the adapter README): + * - Async-only: Python's sync/async twin callbacks and the + * `NotImplementedError` fallback chains collapse into single async methods. + * - Python fights `copy_context()` snapshots with a run_id-keyed span-stack + * singleton (`_SpanStack`); LangChain JS callbacks run inline in the + * emitting async context once `awaitHandlers` is set, so the handlers keep + * only a run_id -> span registry and call the span APIs directly. + * - `patchWithExecutionSpan` wraps `invoke`/`stream` through a Proxy (Python + * monkey-patches `stream`/`astream` in place, which `invoke` uses + * internally). A consequence: the raw compiled graph unwrapped from a + * patched react agent (swarm assembly, the ManagerWorkers `__manager__` + * node) is NOT patched, so those embedded sub-agent runs emit no + * AgentExecutionSpan of their own, while ManagerWorkers workers (invoked + * through the patched agent) do. + * - The `invoke` wrapper builds the end event from the invoke result (Python + * folds streamed state chunks, which yields `{}` on the invoke path); the + * `stream` wrapper folds `[namespace, state]`-style array chunks exactly + * like Python. + * - Non-string payloads are coerced with `JSON.stringify` where Python uses + * `str(...)` (content blocks, tool-call argument objects). */ +import { BaseCallbackHandler } from "@langchain/core/callbacks/base"; +import type { + HandleLLMNewTokenCallbackFields, + NewTokenIndices, +} from "@langchain/core/callbacks/base"; +import type { Serialized } from "@langchain/core/load/serializable"; +import type { AIMessage, AIMessageChunk, BaseMessage } from "@langchain/core/messages"; +import { isToolMessage } from "@langchain/core/messages"; +import type { ChatGenerationChunk, LLMResult } from "@langchain/core/outputs"; import type { Agent, ManagerWorkers } from "../../agents/index.js"; import type { Flow } from "../../flows/index.js"; +import type { LlmConfig } from "../../llms/index.js"; +import { createClientTool, type Tool } from "../../tools/index.js"; +import { propertyFromJsonSchema, type JsonSchemaValue } from "../../property.js"; +import { + AgentExecutionEnd, + AgentExecutionSpan, + AgentExecutionStart, + FlowExecutionEnd, + FlowExecutionSpan, + FlowExecutionStart, + LlmGenerationChunkReceived, + LlmGenerationRequest, + LlmGenerationResponse, + LlmGenerationSpan, + ManagerWorkersExecutionEnd, + ManagerWorkersExecutionSpan, + ManagerWorkersExecutionStart, + Message as TracingMessage, + Span, + ToolCall as TracingToolCall, + ToolExecutionRequest, + ToolExecutionResponse, + ToolExecutionSpan, + type Event as TracingEvent, +} from "../../tracing/index.js"; +import { isRecordLike } from "../common/index.js"; +import { extractOutputsFromInvokeResult } from "./node-execution/agent-node.js"; + +/** LangChain message types mapped onto OpenAI-style tracing roles. */ +const LANGCHAIN_ROLES_TO_OPENAI_ROLES: Readonly> = { + human: "user", + ai: "assistant", + tool: "tool", + system: "system", +}; + +/** + * Coerce a payload to a string (port of Python's `_ensure_string`). + * + * Strings pass through; `null`/`undefined` raise like Python's `None` check; + * everything else is JSON-stringified (Python uses `str(...)`, so the exact + * text of coerced non-string payloads differs across SDKs). + */ +function ensureString(obj: unknown): string { + if (obj === null || obj === undefined) { + throw new Error("can only coerce non-string objects to string"); + } + if (typeof obj === "string") { + return obj; + } + try { + return JSON.stringify(obj) ?? String(obj); + } catch { + throw new Error(`obj is not a valid JSON dict: ${String(obj)}`); + } +} + +/** + * Python coerces falsy chunk content (`None`, `""`, `[]`) to `""` with + * `content or ""`; JS truthiness differs for arrays, so the falsy cases are + * spelled out. + */ +function chunkContentToString(rawContent: unknown): string { + if ( + rawContent === null || + rawContent === undefined || + rawContent === "" || + (Array.isArray(rawContent) && rawContent.length === 0) + ) { + return ""; + } + return ensureString(rawContent); +} /** - * Which execution span Python opens around a compiled graph, and the Agent - * Spec component that span reports on. + * Normalize LangChain callback tool inputs into the mapping expected by trace + * events (port of Python's `_normalize_tool_inputs`). * - * Python opens an `AgentExecutionSpan` for react agents, a + * LangChain JS passes the structured input JSON-stringified where Python + * receives the structured dict in the `inputs` kwarg, so parsing the string + * back is the JS equivalent of Python's structured-inputs priority branch; + * the remaining branches mirror Python's fallbacks for non-dict inputs. + */ +function normalizeToolInputs( + tool: Tool, + inputValue: string, +): Record { + try { + const parsed: unknown = JSON.parse(inputValue); + if (isRecordLike(parsed)) { + return parsed; + } + } catch { + // Not JSON — fall through to the positional fallbacks. + } + if (tool.inputs !== undefined && tool.inputs.length === 1) { + return { [tool.inputs[0]!.title]: inputValue }; + } + return { value: inputValue }; +} + +/** + * Synthesize the Agent Spec tools reported in an `LlmGenerationRequest` from + * the OpenAI function-format tool schemas in the model's invocation params + * (`ClientTool` is used as a generic `Tool` carrier here, like Python). + */ +function toolsFromInvocationParams( + extraParams: Record | undefined, +): Tool[] { + const invocationParamsRaw = extraParams?.["invocation_params"]; + const invocationParams = isRecordLike(invocationParamsRaw) + ? invocationParamsRaw + : {}; + const toolSchemas = invocationParams["tools"]; + if (!Array.isArray(toolSchemas)) { + return []; + } + return toolSchemas.map((toolSchema) => { + // Python indexes tool_schema["function"]["name"] etc. directly and lets + // a malformed entry raise; mirror with explicit errors. + const fn = isRecordLike(toolSchema) + ? toolSchema["function"] + : undefined; + if (!isRecordLike(fn) || typeof fn["name"] !== "string") { + throw new Error( + "[on_chat_model_start] invocation_params tools entries must be " + + `OpenAI function-format tool schemas, got: ${JSON.stringify(toolSchema)}`, + ); + } + const parameters = isRecordLike(fn["parameters"]) ? fn["parameters"] : {}; + const properties = isRecordLike(parameters["properties"]) + ? (parameters["properties"] as Record) + : {}; + return createClientTool({ + name: fn["name"], + ...(typeof fn["description"] === "string" + ? { description: fn["description"] } + : {}), + inputs: Object.entries(properties).map(([propertyTitle, propertySchema]) => + // Python's Property(title=..., json_schema=...) merges the explicit + // title into the schema. + propertyFromJsonSchema({ ...propertySchema, title: propertyTitle }), + ), + }); + }); +} + +/** Build an Agent Spec ToolCall from a LangChain tool-call dict (raw OpenAI form included). */ +function buildAgentSpecToolCall(toolCall: Record): TracingToolCall { + const callId = toolCall["id"]; + if (typeof callId !== "string") { + throw new Error( + `Expected tool call to carry a string id, got: ${JSON.stringify(toolCall)}`, + ); + } + let payload = toolCall; + let argsKey = "args"; + if ("function" in toolCall && isRecordLike(toolCall["function"])) { + payload = toolCall["function"]; + argsKey = "arguments"; + } + return new TracingToolCall({ + callId, + toolName: String(payload["name"]), + arguments: ensureString(payload[argsKey]), + }); +} + +/** + * Extract completion id, content and tool calls from an LLM result (port of + * Python's `_extract_message_content_and_tool_calls`). + */ +function extractMessageContentAndToolCalls(response: LLMResult): { + messageId: string | null; + content: string; + toolCalls: TracingToolCall[]; +} { + const generations = response.generations ?? []; + if (generations.length !== 1 || generations[0]!.length !== 1) { + throw new Error( + "Expected response to contain one generation and one chat_generation", + ); + } + const message = (generations[0]![0] as { message?: AIMessage }).message; + if (message === undefined) { + throw new Error( + "Expected response to contain one generation and one chat_generation", + ); + } + const rawContent: unknown = message.content; + const messageToolCalls = message.tool_calls ?? []; + const additionalToolCalls = message.additional_kwargs?.["tool_calls"]; + const toolCallsRaw: unknown[] = + messageToolCalls.length > 0 + ? messageToolCalls + : Array.isArray(additionalToolCalls) + ? additionalToolCalls + : []; + // NOTE: content can be empty (empty string ""); in that case tool_calls + // should not be empty. + if (rawContent === "" && toolCallsRaw.length === 0) { + throw new Error( + "Expected tool_calls to not be empty when content is empty. " + + "This issue is LLM-specific depending on their tool-calling capabilities; " + + "you may want to try again or switch to another LLM.", + ); + } + const content = ensureString(rawContent); + const toolCalls = toolCallsRaw.map((toolCall) => + buildAgentSpecToolCall(toolCall as Record), + ); + // If streaming, response_id is not provided; rely on the message id. + const responseMetadataId = ( + message.response_metadata as Record | undefined + )?.["id"]; + const messageId = message.id + ? message.id + : typeof responseMetadataId === "string" + ? responseMetadataId + : null; + return { messageId, content, toolCalls }; +} + +/** + * Base of the adapter callback handlers: a run_id -> span registry plus + * Python's `raise_error = True`. `awaitHandlers` is forced on so handlers run + * inline in the emitting async context — the AsyncLocalStorage span stack + * stays correct across parallel runs, and every event is delivered before the + * surrounding `invoke` resolves. + */ +abstract class AgentSpecCallbackHandler extends BaseCallbackHandler { + /** Spans opened by this handler, keyed by LangChain run id. */ + protected readonly agentspecSpansRegistry = new Map(); + + constructor() { + super(); + this.raiseError = true; + this.awaitHandlers = true; + } +} + +/** Tool-call-chunk carry-forward record for one streamed message. */ +interface MessageInProgress { + /** The streamed chunk message id. */ + id: string; + toolCallId?: string; + toolCallName?: string; +} + +/** + * LangChain callback handler emitting `LlmGenerationSpan`s with + * request / streamed-chunk / response events for one Agent Spec LLM config + * (port of Python's `AgentSpecLlmCallbackHandler`). Attached to every chat + * model the converter creates. + */ +export class AgentSpecLlmCallbackHandler extends AgentSpecCallbackHandler { + override readonly name = "AgentSpecLlmCallbackHandler"; + readonly llmConfig: LlmConfig; + /** + * Tool-call streaming state keyed by run id, used to associate streamed + * argument deltas with the tool_call_id announced by the first chunk + * (tool_call_id is not available mid-stream). + */ + readonly messagesInProcess = new Map(); + + constructor(llmConfig: LlmConfig) { + super(); + this.llmConfig = llmConfig; + } + + override async handleChatModelStart( + _llm: Serialized, + messages: BaseMessage[][], + runId: string, + _parentRunId?: string, + extraParams?: Record, + ): Promise { + // Create and start the LLM span for this run. + const span = new LlmGenerationSpan({ llmConfig: this.llmConfig }); + this.agentspecSpansRegistry.set(runId, span); + await span.start(); + + // This is a list of lists because it can be batched, but we assume it to + // be a batch of size 1. + if (messages.length !== 1) { + throw new Error( + "[on_chat_model_start] langchain messages is a nested list of list of " + + "BaseMessage, expected the outer list to have size one but got size " + + `${messages.length}`, + ); + } + const prompt = messages[0]!.map((message) => { + const messageType = message.getType(); + const role = LANGCHAIN_ROLES_TO_OPENAI_ROLES[messageType]; + if (role === undefined) { + // Python raises a bare KeyError from the role map here. + throw new Error( + `Unsupported LangChain message type '${messageType}' for the tracing role map.`, + ); + } + return new TracingMessage({ + content: ensureString(message.content), + sender: "", + role, + }); + }); + + const event = new LlmGenerationRequest({ + requestId: runId, + llmConfig: this.llmConfig, + llmGenerationConfig: this.llmConfig.defaultGenerationParameters ?? null, + prompt, + tools: toolsFromInvocationParams(extraParams), + }); + await span.addEvent(event); + } + + override async handleLLMNewToken( + _token: string, + _idx: NewTokenIndices, + runId: string, + _parentRunId?: string, + _tags?: string[], + fields?: HandleLLMNewTokenCallbackFields, + ): Promise { + // Streaming only: text chunks and/or tool-call chunks. The first chunk of + // a tool call carries id and name (empty args); the following chunks + // carry only argument deltas. + const chunk = fields?.chunk; + if (chunk === undefined || chunk === null) { + throw new Error("[on_llm_new_token] Expected chunk to not be None"); + } + const span = this.agentspecSpansRegistry.get(runId); + if (!(span instanceof LlmGenerationSpan)) { + throw new Error( + "LLM span not started; on_chat_model_start must run first", + ); + } + const chunkMessage = (chunk as ChatGenerationChunk).message as AIMessageChunk; + // Note: chunk_message.response_metadata.id is not populated mid-stream. + if (typeof chunkMessage.id !== "string") { + throw new Error( + "[on_llm_new_token] Expected chunk_message.id to be a string but got: " + + typeof chunkMessage.id, + ); + } + const messageId = chunkMessage.id; + + let agentspecToolCalls: TracingToolCall[] = []; + const toolCallChunks = chunkMessage.tool_call_chunks ?? []; + if (toolCallChunks.length > 0) { + if (toolCallChunks.length !== 1) { + throw new Error( + "[on_llm_new_token] Expected exactly one tool call chunk " + + `if streaming tool calls, but got: ${JSON.stringify(toolCallChunks)}`, + ); + } + const toolCallChunk = toolCallChunks[0]!; + let toolName = toolCallChunk.name; + let callId = toolCallChunk.id; + const toolArgs = toolCallChunk.args; + if (callId === undefined || callId === null) { + const currentStream = this.messagesInProcess.get(runId); + if (currentStream === undefined) { + // Python raises a bare KeyError from messages_in_process here. + throw new Error( + `[on_llm_new_token] No tool call in progress for run_id=${runId}`, + ); + } + toolName = currentStream.toolCallName; + callId = currentStream.toolCallId; + } else { + this.messagesInProcess.set(runId, { + id: messageId, + toolCallId: callId, + ...(toolName !== undefined && toolName !== null + ? { toolCallName: toolName } + : {}), + }); + } + agentspecToolCalls = [ + new TracingToolCall({ + callId: callId ?? "", + toolName: toolName ?? "", + // Argument DELTAS, not the accumulated arguments (Python parity). + arguments: toolArgs || "", + }), + ]; + } + + const event = new LlmGenerationChunkReceived({ + requestId: runId, + completionId: messageId, + content: chunkContentToString(chunkMessage.content), + llmConfig: this.llmConfig, + toolCalls: agentspecToolCalls, + }); + await span.addEvent(event); + } + + override async handleLLMEnd(output: LLMResult, runId: string): Promise { + const span = this.agentspecSpansRegistry.get(runId); + if (!(span instanceof LlmGenerationSpan)) { + throw new Error( + "LLM span not started; on_chat_model_start must run first", + ); + } + const { messageId, content, toolCalls } = + extractMessageContentAndToolCalls(output); + const event = new LlmGenerationResponse({ + llmConfig: this.llmConfig, + requestId: runId, + completionId: messageId, + content, + toolCalls, + }); + await span.addEvent(event); + await span.end(); + this.agentspecSpansRegistry.delete(runId); + this.messagesInProcess.delete(runId); + } +} + +/** + * LangChain callback handler emitting `ToolExecutionSpan`s with + * request/response events for one Agent Spec tool (port of Python's + * `AgentSpecToolCallbackHandler`). Attached to server tools, remote tools and + * loaded MCP tools — NOT to client tools, whose request/response events are + * the runtime's human-in-the-loop business (Python parity). + */ +export class AgentSpecToolCallbackHandler extends AgentSpecCallbackHandler { + override readonly name = "AgentSpecToolCallbackHandler"; + readonly tool: Tool; + + constructor(tool: Tool) { + super(); + this.tool = tool; + } + + override async handleToolStart( + _tool: Serialized, + input: string, + runId: string, + _parentRunId?: string, + _tags?: string[], + _metadata?: Record, + _runName?: string, + toolCallId?: string, + ): Promise { + // Instead of the real tool_call_id, the run_id correlates the tool + // request with the tool result. + const requestEvent = new ToolExecutionRequest({ + requestId: runId, + tool: this.tool, + inputs: normalizeToolInputs(this.tool, input), + }); + // Hack (Python parity): transmit the tool_call_id as the span's + // description so that tool results can be correlated with the streamed + // LLM tool-call chunks that announced them. + const tcidString = toolCallId !== undefined ? `tcid__${String(toolCallId)}` : ""; + const toolSpan = new ToolExecutionSpan({ + name: `ToolExecution[${this.tool.name}]`, + description: tcidString, + tool: this.tool, + }); + this.agentspecSpansRegistry.set(runId, toolSpan); + await toolSpan.start(); + await toolSpan.addEvent(requestEvent); + } + + /** + * Python's sync and async `on_tool_end` twins map outputs differently; the + * port follows the sync variant (declared-outputs title mapping, request_id + * always the run id), which the Python flow tests pin to exact payloads. + */ + override async handleToolEnd(output: unknown, runId: string): Promise { + const toolSpan = this.agentspecSpansRegistry.get(runId); + if (!(toolSpan instanceof ToolExecutionSpan)) { + throw new Error( + `Expected tool_span to be a ToolExecutionSpan but got ${typeof toolSpan}`, + ); + } + + let outputValue: unknown = output; + if (isToolMessage(output)) { + const content: unknown = output.content; + if (typeof content === "string") { + try { + outputValue = JSON.parse(content); + } catch { + outputValue = String(content); + } + } else { + outputValue = content; + } + } + + let outputs: Record; + const declaredOutputs = this.tool.outputs ?? []; + if (declaredOutputs.length === 1) { + // Exactly one declared output: use its title. + outputs = { [declaredOutputs[0]!.title]: outputValue }; + } else if (declaredOutputs.length > 1) { + // The output should already be a mapping with the right entries; when + // it is not, something went wrong and no output is reported. + outputs = isRecordLike(outputValue) ? outputValue : {}; + } else { + // No declared outputs: the tool has no entries to report. + outputs = {}; + } + + const responseEvent = new ToolExecutionResponse({ + requestId: runId, + tool: toolSpan.tool, + outputs, + }); + await toolSpan.addEvent(responseEvent); + await toolSpan.end(); + this.agentspecSpansRegistry.delete(runId); + } + + override async handleToolError(err: Error, runId: string): Promise { + try { + await this.handleToolEnd(null, runId); + } catch { + // Python's `finally: raise error` swallows secondary errors from + // on_tool_end so the original tool error always propagates. + } + throw err; + } +} + +/** + * Which execution span wraps a compiled graph, and the Agent Spec component + * that span reports on: an `AgentExecutionSpan` for react agents, a * `FlowExecutionSpan` for compiled flows and a `ManagerWorkersExecutionSpan` * for hierarchical manager-workers graphs. */ @@ -30,19 +581,200 @@ export type ExecutionSpanTarget = | { kind: "flow"; component: Flow } | { kind: "manager-workers"; component: ManagerWorkers }; +/** The span plus start/end event builders for one execution-span target. */ +interface ExecutionSpanFactories { + makeSpan(): Span; + makeStartEvent(inputs: Record): TracingEvent; + makeEndEvent(result: Record): TracingEvent; +} + +/** Build the span/event factories matching Python's three call sites (§ _execution_span). */ +function executionSpanFactories( + target: ExecutionSpanTarget, +): ExecutionSpanFactories { + switch (target.kind) { + case "agent": { + const agent = target.component; + return { + makeSpan: () => + new AgentExecutionSpan({ name: `AgentExecution[${agent.name}]`, agent }), + makeStartEvent: (inputs) => new AgentExecutionStart({ agent, inputs }), + makeEndEvent: (result) => + new AgentExecutionEnd({ + agent, + outputs: extractOutputsFromInvokeResult(result, agent.outputs ?? []), + }), + }; + } + case "flow": { + const flow = target.component; + return { + makeSpan: () => + new FlowExecutionSpan({ name: `FlowExecution[${flow.name}]`, flow }), + makeStartEvent: (inputs) => new FlowExecutionStart({ flow, inputs }), + makeEndEvent: (result) => { + const outputs = result["outputs"]; + const details = result["node_execution_details"]; + const branch = isRecordLike(details) ? details["branch"] : undefined; + return new FlowExecutionEnd({ + flow, + outputs: isRecordLike(outputs) ? outputs : {}, + branchSelected: typeof branch === "string" ? branch : "", + }); + }, + }; + } + case "manager-workers": { + const managerworkers = target.component; + return { + makeSpan: () => + new ManagerWorkersExecutionSpan({ + name: `ManagerWorkersExecution[${managerworkers.name}]`, + managerworkers, + }), + makeStartEvent: (inputs) => + new ManagerWorkersExecutionStart({ managerworkers, inputs }), + makeEndEvent: (result) => + new ManagerWorkersExecutionEnd({ + managerworkers, + outputs: { messages: result["messages"] ?? [] }, + }), + }; + } + } +} + +/** The invocation input state of a patched call, or `{}` when it isn't a record. */ +function invocationInputs(input: unknown): Record { + return isRecordLike(input) ? input : {}; +} + /** - * Wrap a compiled graph (or react agent) so each run is traced inside an - * execution span. + * Fold one streamed chunk into the running "last state seen". State arrives + * as `[namespace, state]`-style tuples (arrays in JS — subgraph, multi-mode + * and messages streams); other chunk shapes leave the fold untouched + * (Python parity). + */ +function foldFinalState(chunk: unknown, soFar: unknown): unknown { + return Array.isArray(chunk) ? chunk[1] : soFar; +} + +function isPromiseLike(value: unknown): value is PromiseLike { + return ( + value !== null && + (typeof value === "object" || typeof value === "function") && + typeof (value as { then?: unknown }).then === "function" + ); +} + +/** + * Wrap a compiled graph (or react agent) so each run is traced inside the + * execution span named by `target.kind` for `target.component`. * - * Python monkey-patches `stream`/`astream` to open the span named by - * `target.kind` for `target.component`, emit the start event with the - * invocation inputs, fold the streamed chunks into a final state and emit the - * end event with the run outputs. Returns the graph unchanged until the - * tracing package is ported. + * Mirrors Python's `patch_with_execution_span` semantics on the async path: + * the span starts when the run starts, a Start event carries the invocation + * inputs, streamed chunks are yielded through while the final state chunk is + * folded, the End event carries the run outputs, and the span always ends — + * consumer abandonment and mid-run errors included (no ExceptionRaised event, + * matching Python's `astream` path). A `stream()` promise that rejects before + * producing the iterable still emits the span with its Start event, like + * Python's first-`anext` failure. */ export function patchWithExecutionSpan( graph: T, - _target: ExecutionSpanTarget, + target: ExecutionSpanTarget, ): T { - return graph; + const factories = executionSpanFactories(target); + + async function* traceStream( + iterable: AsyncIterable, + inputs: Record, + ): AsyncGenerator { + const span = factories.makeSpan(); + await span.start(); + try { + await span.addEvent(factories.makeStartEvent(inputs)); + let state: unknown = {}; + for await (const chunk of iterable) { + yield chunk; + state = foldFinalState(chunk, state); + } + await span.addEvent( + factories.makeEndEvent(isRecordLike(state) ? state : {}), + ); + } finally { + await span.end(); + } + } + + async function traceFailedStreamStart( + inputs: Record, + ): Promise { + const span = factories.makeSpan(); + await span.start(); + try { + await span.addEvent(factories.makeStartEvent(inputs)); + } finally { + await span.end(); + } + } + + const wrapInvoke = + (original: (...args: unknown[]) => unknown, targetObject: object) => + async (...args: unknown[]): Promise => { + const span = factories.makeSpan(); + await span.start(); + try { + await span.addEvent(factories.makeStartEvent(invocationInputs(args[0]))); + const result: unknown = await original.apply(targetObject, args); + await span.addEvent( + factories.makeEndEvent(isRecordLike(result) ? result : {}), + ); + return result; + } finally { + await span.end(); + } + }; + + const wrapStream = + (original: (...args: unknown[]) => unknown, targetObject: object) => + (...args: unknown[]): unknown => { + const inputs = invocationInputs(args[0]); + const out = original.apply(targetObject, args); + if (isPromiseLike(out)) { + return (out as Promise>).then( + (iterable) => traceStream(iterable, inputs), + async (error: unknown) => { + await traceFailedStreamStart(inputs); + throw error; + }, + ); + } + return traceStream(out as AsyncIterable, inputs); + }; + + // Probe-verified wrapping: a Proxy intercepting invoke/stream and binding + // every other method to the target preserves the full graph surface + // (builder introspection, options, streamEvents, private-field methods). + return new Proxy(graph as object, { + get(targetObject, property) { + const original: unknown = Reflect.get(targetObject, property, targetObject); + if (typeof original !== "function") { + return original; + } + if (property === "invoke") { + return wrapInvoke( + original as (...args: unknown[]) => unknown, + targetObject, + ); + } + if (property === "stream") { + return wrapStream( + original as (...args: unknown[]) => unknown, + targetObject, + ); + } + return (original as (...args: unknown[]) => unknown).bind(targetObject); + }, + }) as T; } diff --git a/tsagentspec/tests/adapters/langgraph/tracing.test.ts b/tsagentspec/tests/adapters/langgraph/tracing.test.ts new file mode 100644 index 00000000..93974a80 --- /dev/null +++ b/tsagentspec/tests/adapters/langgraph/tracing.test.ts @@ -0,0 +1,1040 @@ +/** + * Tracing tests for the LangGraph adapter. + * + * Port of `pyagentspec/tests/adapters/langgraph/test_tracing_async.py` (the + * authoritative suite for the async-only TS API) plus the sync suite's + * extras: the `tcid__` correlation test and the exact tool-payload flow test. + * Python's sync-vs-async processor segregation and the NotImplementedError + * fallback tests are N/A with a single async API. All tests run offline: LLM + * calls go through fakes injected at the converter seam (carrying the + * adapter's LLM callback handler, as `convertLlmConfig` does for real + * models), and MCP loading is mocked like in `mcp.test.ts`. + */ +import { describe, expect, it, vi } from "vitest"; +import type { CallbackManagerForLLMRun } from "@langchain/core/callbacks/manager"; +import type { BaseChatModelParams } from "@langchain/core/language_models/chat_models"; +import { AIMessage, AIMessageChunk, HumanMessage } from "@langchain/core/messages"; +import type { BaseMessage, ToolCallChunk } from "@langchain/core/messages"; +import { ChatGenerationChunk } from "@langchain/core/outputs"; +import { + AgentExecutionEnd, + AgentExecutionSpan, + AgentExecutionStart, + Event, + ExceptionRaised, + FlowExecutionEnd, + FlowExecutionSpan, + FlowExecutionStart, + LlmGenerationChunkReceived, + LlmGenerationRequest, + LlmGenerationResponse, + LlmGenerationSpan, + ManagerWorkersExecutionEnd, + ManagerWorkersExecutionSpan, + ManagerWorkersExecutionStart, + NodeExecutionEnd, + NodeExecutionSpan, + NodeExecutionStart, + Span, + SpanProcessor, + ToolExecutionRequest, + ToolExecutionResponse, + ToolExecutionSpan, + Trace, + createAgent as createAgentSpecAgent, + createCatchExceptionNode, + createClientTool, + createFlow, + createManagerWorkers, + createSSETransport, + createServerTool, + createToolNode, + getCurrentSpan, + integerProperty, + objectProperty, + stringProperty, +} from "../../../src/index.js"; +import type { LlmConfig, MCPTool } from "../../../src/index.js"; +import { + convertClientTransport, + getOrCreateMcpTools, +} from "../../../src/adapters/langgraph/mcp.js"; +import { convertLlmConfig } from "../../../src/adapters/langgraph/llm.js"; +import { + convertClientTool, + convertServerTool, +} from "../../../src/adapters/langgraph/tools.js"; +import { + AgentSpecLlmCallbackHandler, + AgentSpecToolCallbackHandler, +} from "../../../src/adapters/langgraph/tracing.js"; +import { + FakeLlmAgentSpecLoader, + FakeToolCallingChatModel, + ctrl, + dataEdge, + detailsOf, + ioEndNode, + ioStartNode, + loadFlow, + loadWithFakeLlm, + makeAgent, + makeLlmConfig, + outputsOf, + threadConfig, + toolCallMessage, +} from "./test-helpers.js"; +import { MemorySaver } from "@langchain/langgraph"; + +const mcpMocks: { tools: unknown[] } = { tools: [] }; + +vi.mock("@langchain/mcp-adapters", () => ({ + MultiServerMCPClient: class { + constructor(_config: unknown) {} + async getTools(..._servers: string[]): Promise { + return mcpMocks.tools; + } + }, +})); + +/** Recording processor mirroring the Python tests' DummySpanProcessor. */ +class RecordingSpanProcessor extends SpanProcessor { + startedUp = false; + shutDown = false; + starts: Span[] = []; + ends: Span[] = []; + events: Array<[Event, Span]> = []; + + onStart(span: Span): void { + this.starts.push(span); + } + + onEnd(span: Span): void { + this.ends.push(span); + } + + onEvent(event: Event, span: Span): void { + this.events.push([event, span]); + } + + startup(): void { + this.startedUp = true; + } + + shutdown(): void { + this.shutDown = true; + } +} + +type SpanCtor = new (...args: never[]) => Span; +type EventCtor = new (...args: never[]) => T; + +function startedSpans(proc: RecordingSpanProcessor, ctor: SpanCtor): Span[] { + return proc.starts.filter((span) => span instanceof ctor); +} + +function endedSpans(proc: RecordingSpanProcessor, ctor: SpanCtor): Span[] { + return proc.ends.filter((span) => span instanceof ctor); +} + +function eventsOf( + proc: RecordingSpanProcessor, + ctor: EventCtor, +): T[] { + return proc.events + .map(([event]) => event) + .filter((event): event is T => event instanceof ctor); +} + +/** The invocable + streamable surface of a loaded graph. */ +interface RunnableGraph { + invoke(input: unknown, config?: unknown): Promise>; + stream(input: unknown, config?: unknown): Promise>; +} + +/** Port of `_assert_agent_llm_tool_async` (single-API: no sync/async split). */ +function assertAgentLlmTool(proc: RecordingSpanProcessor): void { + expect(proc.startedUp).toBe(true); + expect(proc.shutDown).toBe(true); + + expect(startedSpans(proc, AgentExecutionSpan).length).toBeGreaterThan(0); + expect(endedSpans(proc, AgentExecutionSpan).length).toBeGreaterThan(0); + expect(startedSpans(proc, LlmGenerationSpan).length).toBeGreaterThan(0); + expect(endedSpans(proc, LlmGenerationSpan).length).toBeGreaterThan(0); + expect(startedSpans(proc, ToolExecutionSpan).length).toBeGreaterThan(0); + expect(endedSpans(proc, ToolExecutionSpan).length).toBeGreaterThan(0); + + expect(eventsOf(proc, AgentExecutionStart).length).toBeGreaterThan(0); + expect(eventsOf(proc, AgentExecutionEnd).length).toBeGreaterThan(0); + expect(eventsOf(proc, LlmGenerationRequest).length).toBeGreaterThan(0); + expect(eventsOf(proc, LlmGenerationResponse).length).toBeGreaterThan(0); + expect(eventsOf(proc, ToolExecutionRequest).length).toBeGreaterThan(0); + expect(eventsOf(proc, ToolExecutionResponse).length).toBeGreaterThan(0); +} + +/** Port of `_assert_flow_async`. */ +function assertFlow( + proc: RecordingSpanProcessor, + options?: { + flowTracingHasLlm?: boolean; + expectedToolResponseOutputs?: Record; + }, +): void { + const hasLlm = options?.flowTracingHasLlm ?? true; + expect(proc.startedUp).toBe(true); + expect(proc.shutDown).toBe(true); + + expect(startedSpans(proc, FlowExecutionSpan).length).toBeGreaterThan(0); + expect(endedSpans(proc, FlowExecutionSpan).length).toBeGreaterThan(0); + expect(startedSpans(proc, NodeExecutionSpan).length).toBeGreaterThan(0); + expect(endedSpans(proc, NodeExecutionSpan).length).toBeGreaterThan(0); + expect(startedSpans(proc, ToolExecutionSpan).length).toBeGreaterThan(0); + expect(endedSpans(proc, ToolExecutionSpan).length).toBeGreaterThan(0); + expect(startedSpans(proc, LlmGenerationSpan).length > 0).toBe(hasLlm); + expect(endedSpans(proc, LlmGenerationSpan).length > 0).toBe(hasLlm); + + expect(eventsOf(proc, FlowExecutionStart).length).toBeGreaterThan(0); + expect(eventsOf(proc, FlowExecutionEnd).length).toBeGreaterThan(0); + expect(eventsOf(proc, NodeExecutionStart).length).toBeGreaterThan(0); + expect(eventsOf(proc, NodeExecutionEnd).length).toBeGreaterThan(0); + expect(eventsOf(proc, ToolExecutionRequest).length).toBeGreaterThan(0); + expect(eventsOf(proc, ToolExecutionResponse).length).toBeGreaterThan(0); + expect(eventsOf(proc, LlmGenerationRequest).length > 0).toBe(hasLlm); + expect(eventsOf(proc, LlmGenerationResponse).length > 0).toBe(hasLlm); + + if (options?.expectedToolResponseOutputs !== undefined) { + const toolResponseEvents = eventsOf(proc, ToolExecutionResponse); + expect(toolResponseEvents).toHaveLength(1); + expect(toolResponseEvents[0]!.outputs).toEqual( + options.expectedToolResponseOutputs, + ); + } +} + +/** The weather ServerTool spec used by the agent tests. */ +function weatherTool() { + return createServerTool({ + name: "get_weather", + description: "Retrieves the weather in a city", + inputs: [stringProperty({ title: "city" })], + outputs: [stringProperty({ title: "weather" })], + }); +} + +const WEATHER_TOOL_REGISTRY = { + get_weather: async (input: unknown) => + `The weather in ${(input as { city: string }).city} is sunny.`, +}; + +const WEATHER_QUESTION = { + messages: [{ role: "user", content: "What's the weather in Agadir?" }], +}; + +/** Fake responses driving one get_weather tool call then a final answer. */ +function weatherResponses(): AIMessage[] { + return [ + toolCallMessage("get_weather", { city: "Agadir" }), + new AIMessage("The weather in Agadir is sunny."), + ]; +} + +/** + * The converter seam substitutes whole fake models, so the tests attach the + * adapter's LLM handler exactly where `convertLlmConfig` attaches it for real + * models: on the chat model's constructor callbacks. + */ +function fakeModelWithLlmTracing(responses: AIMessage[]) { + return (llmConfig: LlmConfig): FakeToolCallingChatModel => + new FakeToolCallingChatModel({ + responses, + callbacks: [new AgentSpecLlmCallbackHandler(llmConfig)], + }); +} + +async function loadWeatherAgent(): Promise { + const { agent } = await loadWithFakeLlm( + makeAgent({ tools: [weatherTool()] }), + fakeModelWithLlmTracing(weatherResponses()), + { toolRegistry: WEATHER_TOOL_REGISTRY }, + ); + return agent as unknown as RunnableGraph; +} + +/** + * Streaming fake: one scripted list of AIMessageChunks per model turn, + * reported to the run manager chunk by chunk (`handleLLMNewToken`) exactly + * like a real provider model's `_streamResponseChunks`. + */ +class StreamingFakeChatModel extends FakeToolCallingChatModel { + private readonly turns: AIMessageChunk[][]; + private streamTurnIdx = 0; + + constructor( + fields: { turns: AIMessageChunk[][]; responses?: AIMessage[] } & BaseChatModelParams, + ) { + super({ ...fields, responses: fields.responses ?? [] }); + this.turns = fields.turns; + } + + override _llmType(): string { + return "streaming-fake-chat-model"; + } + + override async *_streamResponseChunks( + _messages: BaseMessage[], + _options: this["ParsedCallOptions"], + runManager?: CallbackManagerForLLMRun, + ): AsyncGenerator { + const turn = + this.turns[Math.min(this.streamTurnIdx, this.turns.length - 1)]!; + this.streamTurnIdx += 1; + for (const messageChunk of turn) { + const text = + typeof messageChunk.content === "string" ? messageChunk.content : ""; + const generationChunk = new ChatGenerationChunk({ + message: messageChunk, + text, + }); + await runManager?.handleLLMNewToken( + text, + { prompt: 0, completion: 0 }, + undefined, + undefined, + undefined, + { chunk: generationChunk }, + ); + yield generationChunk; + } + } +} + +function toolCallChunkMessage( + id: string, + toolCallChunk: Partial, +): AIMessageChunk { + return new AIMessageChunk({ + content: "", + id, + tool_call_chunks: [ + { ...toolCallChunk, index: 0, type: "tool_call_chunk" } as ToolCallChunk, + ], + }); +} + +function textChunkMessage(id: string, content: string): AIMessageChunk { + return new AIMessageChunk({ content, id }); +} + +/** Two streamed turns: a chunked get_weather tool call, then a text answer. */ +function streamingWeatherTurns(): AIMessageChunk[][] { + return [ + [ + toolCallChunkMessage("msg_1", { + name: "get_weather", + args: "", + id: "call_1", + }), + toolCallChunkMessage("msg_1", { args: '{"city":' }), + toolCallChunkMessage("msg_1", { args: '"Agadir"}' }), + ], + [ + textChunkMessage("msg_2", "The weather in Agadir "), + textChunkMessage("msg_2", "is sunny."), + ], + ]; +} + +/** The `double_tool` flow of the Python async server-tool flow test. */ +function doubleToolFlow() { + const xProp = integerProperty({ title: "x" }); + const resultProp = integerProperty({ title: "result" }); + const serverTool = createServerTool({ + name: "double_tool", + description: "Doubles the input number", + inputs: [xProp], + outputs: [resultProp], + }); + const startNode = ioStartNode("start", [xProp]); + const toolNode = createToolNode({ name: "tool", tool: serverTool }); + const endNode = ioEndNode("end", [resultProp]); + return createFlow({ + name: "flow", + startNode, + nodes: [startNode, toolNode, endNode], + controlFlowConnections: [ctrl(startNode, toolNode), ctrl(toolNode, endNode)], + dataFlowConnections: [ + dataEdge(startNode, toolNode, "x"), + dataEdge(toolNode, endNode, "result"), + ], + inputs: [xProp], + outputs: [resultProp], + }); +} + +const DOUBLE_TOOL_REGISTRY = { + double_tool: async (input: unknown) => (input as { x: number }).x * 2, +}; + +describe("langgraph adapter tracing", () => { + it("invoke emits agent, LLM and tool spans and events", async () => { + const agent = await loadWeatherAgent(); + + const proc = new RecordingSpanProcessor(); + let response: Record = {}; + await new Trace({ + name: "langgraph_tracing_async_test", + spanProcessors: [proc], + }).run(async () => { + response = await agent.invoke(WEATHER_QUESTION); + }); + + expect(JSON.stringify(response).toLowerCase()).toContain("sunny"); + assertAgentLlmTool(proc); + }); + + it("stream emits agent, LLM and tool spans and events", async () => { + const agent = await loadWeatherAgent(); + + const proc = new RecordingSpanProcessor(); + let response = ""; + await new Trace({ + name: "langgraph_tracing_async_test", + spanProcessors: [proc], + }).run(async () => { + const stream = await agent.stream(WEATHER_QUESTION, { + streamMode: "messages", + }); + for await (const chunk of stream) { + const [messageChunk] = chunk as [{ content?: unknown }, unknown]; + if (typeof messageChunk?.content === "string") { + response += messageChunk.content; + } + } + }); + + expect(response.toLowerCase()).toContain("sunny"); + assertAgentLlmTool(proc); + }); + + it("agent execution span carries the agent name and start/end payloads", async () => { + const agent = await loadWeatherAgent(); + + const proc = new RecordingSpanProcessor(); + await new Trace({ spanProcessors: [proc] }).run(async () => { + await agent.invoke(WEATHER_QUESTION); + }); + + const agentSpans = startedSpans(proc, AgentExecutionSpan); + expect(agentSpans).toHaveLength(1); + expect(agentSpans[0]!.name).toBe("AgentExecution[test_agent]"); + const startEvents = eventsOf(proc, AgentExecutionStart); + expect(startEvents).toHaveLength(1); + // The invocation input state is reported as the start-event inputs. + expect(startEvents[0]!.inputs).toEqual(WEATHER_QUESTION); + // No declared agent outputs: the end event reports an empty mapping. + const endEvents = eventsOf(proc, AgentExecutionEnd); + expect(endEvents).toHaveLength(1); + expect(endEvents[0]!.outputs).toEqual({}); + }); + + it("maps LangChain roles onto OpenAI roles in the request prompt", async () => { + const agent = await loadWeatherAgent(); + + const proc = new RecordingSpanProcessor(); + await new Trace({ spanProcessors: [proc] }).run(async () => { + await agent.invoke(WEATHER_QUESTION); + }); + + const requests = eventsOf(proc, LlmGenerationRequest); + expect(requests).toHaveLength(2); + // Turn 1: system prompt + user question. + expect(requests[0]!.prompt.map((m) => m.role)).toEqual(["system", "user"]); + // Turn 2: the tool loop appended the assistant tool call and tool result. + expect(requests[1]!.prompt.map((m) => m.role)).toEqual([ + "system", + "user", + "assistant", + "tool", + ]); + for (const request of requests) { + expect(request.llmConfig.name).toBe("test-llm"); + for (const message of request.prompt) { + expect(message.sender).toBe(""); + expect(typeof message.content).toBe("string"); + } + } + }); + + it("synthesizes request tools from the model's invocation params", async () => { + class InvocationParamsFakeModel extends FakeToolCallingChatModel { + override invocationParams(): Record { + return { + tools: [ + { + type: "function", + function: { + name: "get_weather", + description: "Retrieves the weather in a city", + parameters: { + type: "object", + properties: { city: { type: "string" } }, + }, + }, + }, + ], + }; + } + } + const { agent } = await loadWithFakeLlm( + makeAgent({ tools: [weatherTool()] }), + (llmConfig) => + new InvocationParamsFakeModel({ + responses: weatherResponses(), + callbacks: [new AgentSpecLlmCallbackHandler(llmConfig)], + }), + { toolRegistry: WEATHER_TOOL_REGISTRY }, + ); + + const proc = new RecordingSpanProcessor(); + await new Trace({ spanProcessors: [proc] }).run(async () => { + await (agent as unknown as RunnableGraph).invoke(WEATHER_QUESTION); + }); + + const request = eventsOf(proc, LlmGenerationRequest)[0]!; + expect(request.tools).toHaveLength(1); + const requestTool = request.tools[0]!; + // ClientTool is the generic Tool carrier for invocation-params tools. + expect(requestTool.componentType).toBe("ClientTool"); + expect(requestTool.name).toBe("get_weather"); + expect(requestTool.description).toBe("Retrieves the weather in a city"); + expect(requestTool.inputs?.map((input) => input.title)).toEqual(["city"]); + expect(requestTool.inputs?.[0]?.jsonSchema).toEqual({ + type: "string", + title: "city", + }); + }); + + it("emits chunk events with tool-call carry-forward while streaming", async () => { + const { agent } = await loadWithFakeLlm( + makeAgent({ tools: [weatherTool()] }), + (llmConfig) => + new StreamingFakeChatModel({ + turns: streamingWeatherTurns(), + callbacks: [new AgentSpecLlmCallbackHandler(llmConfig)], + }), + { toolRegistry: WEATHER_TOOL_REGISTRY }, + ); + + const proc = new RecordingSpanProcessor(); + await new Trace({ spanProcessors: [proc] }).run(async () => { + const stream = await (agent as unknown as RunnableGraph).stream( + WEATHER_QUESTION, + { streamMode: "messages" }, + ); + for await (const _chunk of stream) { + // Drain the stream; the assertions read the recorded events. + } + }); + + const chunkEvents = eventsOf(proc, LlmGenerationChunkReceived); + const toolCallChunks = chunkEvents.filter( + (event) => event.toolCalls.length === 1, + ); + expect(toolCallChunks).toHaveLength(3); + // The id+name announced by the first chunk carry forward to the + // args-delta chunks; arguments stay deltas, not accumulations. + for (const event of toolCallChunks) { + expect(event.toolCalls[0]!.callId).toBe("call_1"); + expect(event.toolCalls[0]!.toolName).toBe("get_weather"); + expect(event.completionId).toBe("msg_1"); + } + expect(toolCallChunks.map((event) => event.toolCalls[0]!.arguments)).toEqual( + ["", '{"city":', '"Agadir"}'], + ); + + // Text chunks of the final turn carry content and no tool calls. + const textChunks = chunkEvents.filter( + (event) => event.completionId === "msg_2", + ); + expect(textChunks.map((event) => event.content)).toEqual([ + "The weather in Agadir ", + "is sunny.", + ]); + for (const event of textChunks) { + expect(event.toolCalls).toEqual([]); + } + + // Chunk events share their turn's request id, and the streamed response + // aggregates the tool call with the full JSON arguments. + const requests = eventsOf(proc, LlmGenerationRequest); + expect(requests).toHaveLength(2); + const firstTurnRequestId = requests[0]!.requestId; + for (const event of toolCallChunks) { + expect(event.requestId).toBe(firstTurnRequestId); + } + const responses = eventsOf(proc, LlmGenerationResponse); + expect(responses).toHaveLength(2); + expect(responses[0]!.requestId).toBe(firstTurnRequestId); + expect(responses[0]!.completionId).toBe("msg_1"); + expect(responses[0]!.toolCalls).toHaveLength(1); + expect(responses[0]!.toolCalls[0]!.callId).toBe("call_1"); + expect(responses[0]!.toolCalls[0]!.toolName).toBe("get_weather"); + expect(responses[0]!.toolCalls[0]!.arguments).toBe('{"city":"Agadir"}'); + }); + + it("streams tool_call_ids consistent with the executed tool spans", async () => { + // Port of the sync suite's + // test_langgraph_agent_emits_tool_calls_and_results_with_consistent_ids. + const { agent } = await loadWithFakeLlm( + makeAgent({ tools: [weatherTool()] }), + (llmConfig) => + new StreamingFakeChatModel({ + turns: streamingWeatherTurns(), + callbacks: [new AgentSpecLlmCallbackHandler(llmConfig)], + }), + { toolRegistry: WEATHER_TOOL_REGISTRY }, + ); + + const proc = new RecordingSpanProcessor(); + await new Trace({ spanProcessors: [proc] }).run(async () => { + const stream = await (agent as unknown as RunnableGraph).stream( + WEATHER_QUESTION, + { streamMode: "messages" }, + ); + for await (const _chunk of stream) { + // Drain the stream. + } + }); + + const streamedToolCallIds = new Set( + eventsOf(proc, LlmGenerationChunkReceived) + .filter((event) => event.toolCalls.length === 1) + .map((event) => event.toolCalls[0]!.callId), + ); + // LangChain can stream provisional tool_call_ids that get abandoned + // before execution, so executed ids must be a subset of streamed ids. + const executedToolCallIds = new Set( + proc.events + .map(([, span]) => span) + .filter((span): span is ToolExecutionSpan => span instanceof ToolExecutionSpan) + .filter((span) => span.description !== "") + .map((span) => span.description.replace("tcid__", "")), + ); + expect(executedToolCallIds.size).toBeGreaterThan(0); + for (const executedId of executedToolCallIds) { + expect(streamedToolCallIds.has(executedId)).toBe(true); + } + }); + + it("tool spans map the ToolMessage output onto the declared outputs", async () => { + const agent = await loadWeatherAgent(); + + const proc = new RecordingSpanProcessor(); + await new Trace({ spanProcessors: [proc] }).run(async () => { + await agent.invoke(WEATHER_QUESTION); + }); + + const toolSpans = startedSpans(proc, ToolExecutionSpan) as ToolExecutionSpan[]; + expect(toolSpans).toHaveLength(1); + expect(toolSpans[0]!.name).toBe("ToolExecution[get_weather]"); + // The react-agent tool node runs tools with their tool_call_id, smuggled + // through the span description for correlation. + expect(toolSpans[0]!.description).toBe("tcid__call_1"); + expect(toolSpans[0]!.tool.componentType).toBe("ServerTool"); + + const requests = eventsOf(proc, ToolExecutionRequest); + expect(requests).toHaveLength(1); + expect(requests[0]!.inputs).toEqual({ city: "Agadir" }); + const responses = eventsOf(proc, ToolExecutionResponse); + expect(responses).toHaveLength(1); + expect(responses[0]!.outputs).toEqual({ + weather: "The weather in Agadir is sunny.", + }); + expect(responses[0]!.requestId).toBe(requests[0]!.requestId); + }); + + it("invoke emits flow, node and async server tool events", async () => { + // Port of test_langgraph_ainvoke_tracing_emits_async_server_tool_events_for_flow. + const graph = (await loadFlow(doubleToolFlow(), { + toolRegistry: DOUBLE_TOOL_REGISTRY, + })) as unknown as RunnableGraph; + + const proc = new RecordingSpanProcessor(); + let response: Record = {}; + await new Trace({ + name: "langgraph_tracing_async_server_tool_test", + spanProcessors: [proc], + }).run(async () => { + response = await graph.invoke({ inputs: { x: 5 } }); + }); + + expect(outputsOf(response)).toEqual({ result: 10 }); + assertFlow(proc, { + flowTracingHasLlm: false, + expectedToolResponseOutputs: { result: 10 }, + }); + + // Node spans wrap every flow node, named `Execution[]`. + const nodeSpanNames = startedSpans(proc, NodeExecutionSpan).map( + (span) => span.name, + ); + expect(nodeSpanNames).toEqual([ + "StartNodeExecution[start]", + "ToolNodeExecution[tool]", + "EndNodeExecution[end]", + ]); + expect(endedSpans(proc, NodeExecutionSpan)).toHaveLength(3); + + // The flow execution span and the end event's payload. + const flowSpans = startedSpans(proc, FlowExecutionSpan); + expect(flowSpans).toHaveLength(1); + expect(flowSpans[0]!.name).toBe("FlowExecution[flow]"); + const flowStart = eventsOf(proc, FlowExecutionStart)[0]!; + expect(flowStart.inputs).toEqual({ inputs: { x: 5 } }); + const flowEnd = eventsOf(proc, FlowExecutionEnd)[0]!; + expect(flowEnd.outputs).toEqual({ result: 10 }); + expect(flowEnd.branchSelected).toBe(String(detailsOf(response)["branch"])); + + // The tool node's end event carries the mapped outputs and branch. + const toolNodeEnd = eventsOf(proc, NodeExecutionEnd).find( + (event) => event.node.name === "tool", + )!; + expect(toolNodeEnd.outputs).toEqual({ result: 10 }); + }); + + it("stream emits flow events while yielding value chunks", async () => { + // Port of test_langgraph_astream_tracing_emits_flow_events. + const graph = (await loadFlow(doubleToolFlow(), { + toolRegistry: DOUBLE_TOOL_REGISTRY, + })) as unknown as RunnableGraph; + + const proc = new RecordingSpanProcessor(); + let lastChunk: Record = {}; + await new Trace({ + name: "langgraph_tracing_async_test", + spanProcessors: [proc], + }).run(async () => { + const stream = await graph.stream( + { inputs: { x: 5 } }, + { streamMode: "values" }, + ); + for await (const chunk of stream) { + if (chunk) { + lastChunk = chunk as Record; + } + } + }); + + expect(outputsOf(lastChunk)).toEqual({ result: 10 }); + assertFlow(proc, { + flowTracingHasLlm: false, + expectedToolResponseOutputs: { result: 10 }, + }); + }); + + it("collects exact tool inputs and outputs in a flow", async () => { + // Port of the sync suite's test_langgraph_flow_tracing_collects_tool_inputs. + const cityProperty = stringProperty({ title: "city" }); + const forecastProperty = objectProperty({ + title: "forecast", + properties: { + city: stringProperty({ title: "city" }), + condition: stringProperty({ title: "condition" }), + }, + }); + const weatherFlowTool = createServerTool({ + name: "get_weather", + description: "Retrieves the weather in a city", + inputs: [cityProperty], + outputs: [forecastProperty], + }); + const startNode = ioStartNode("start", [cityProperty]); + const toolNode = createToolNode({ name: "tool", tool: weatherFlowTool }); + const endNode = ioEndNode("end", [forecastProperty]); + const flow = createFlow({ + name: "weather_flow", + startNode, + nodes: [startNode, toolNode, endNode], + controlFlowConnections: [ + ctrl(startNode, toolNode), + ctrl(toolNode, endNode), + ], + dataFlowConnections: [ + dataEdge(startNode, toolNode, "city"), + dataEdge(toolNode, endNode, "forecast"), + ], + inputs: [cityProperty], + outputs: [forecastProperty], + }); + const graph = (await loadFlow(flow, { + toolRegistry: { + get_weather: (input: unknown) => ({ + city: (input as { city: string }).city, + condition: "sunny", + }), + }, + })) as unknown as RunnableGraph; + + const proc = new RecordingSpanProcessor(); + let response: Record = {}; + await new Trace({ + name: "langgraph_tool_input_trace_test", + spanProcessors: [proc], + }).run(async () => { + response = await graph.invoke({ inputs: { city: "Agadir" } }); + }); + + expect(outputsOf(response)).toEqual({ + forecast: { city: "Agadir", condition: "sunny" }, + }); + const toolRequestEvents = eventsOf(proc, ToolExecutionRequest); + expect(toolRequestEvents).toHaveLength(1); + expect(toolRequestEvents[0]!.inputs).toEqual({ city: "Agadir" }); + const toolResponseEvents = eventsOf(proc, ToolExecutionResponse); + expect(toolResponseEvents).toHaveLength(1); + expect(toolResponseEvents[0]!.outputs).toEqual({ + forecast: { city: "Agadir", condition: "sunny" }, + }); + }); + + it("wraps manager-workers runs in an execution span with worker agent spans", async () => { + const spec = createManagerWorkers({ + name: "Team", + groupManager: createAgentSpecAgent({ + name: "Coordinator", + llmConfig: makeLlmConfig({ name: "manager_llm" }), + systemPrompt: "You coordinate.", + }), + workers: [ + createAgentSpecAgent({ + name: "Research Helper", + llmConfig: makeLlmConfig({ name: "worker_llm" }), + systemPrompt: "You research.", + description: "Handles research", + }), + ], + }); + const queues: Record = { + manager_llm: [ + new AIMessage({ + content: "", + tool_calls: [ + { + name: "__delegate_to__research_helper", + args: { task: "Look up Saturn" }, + id: "call_1", + type: "tool_call", + }, + ], + }), + new AIMessage("The worker reports: Saturn has rings."), + ], + worker_llm: [new AIMessage("Saturn has rings.")], + }; + const loader = new FakeLlmAgentSpecLoader( + (llmConfig) => + new FakeToolCallingChatModel({ + responses: queues[llmConfig.name]!, + callbacks: [new AgentSpecLlmCallbackHandler(llmConfig)], + }), + { checkpointer: new MemorySaver() }, + ); + const graph = (await loader.loadComponent(spec)) as unknown as RunnableGraph; + + const proc = new RecordingSpanProcessor(); + let response: Record = {}; + await new Trace({ spanProcessors: [proc] }).run(async () => { + response = await graph.invoke( + { messages: [new HumanMessage("Tell me about Saturn.")] }, + threadConfig("mw-tracing-1"), + ); + }); + + const mwSpans = startedSpans(proc, ManagerWorkersExecutionSpan); + expect(mwSpans).toHaveLength(1); + expect(mwSpans[0]!.name).toBe("ManagerWorkersExecution[Team]"); + expect(endedSpans(proc, ManagerWorkersExecutionSpan)).toHaveLength(1); + const mwStart = eventsOf(proc, ManagerWorkersExecutionStart); + expect(mwStart).toHaveLength(1); + const mwEnd = eventsOf(proc, ManagerWorkersExecutionEnd); + expect(mwEnd).toHaveLength(1); + expect(mwEnd[0]!.outputs).toEqual({ messages: response["messages"] }); + + // Workers are invoked through their patched react agents, so the + // delegated turn is wrapped in an AgentExecutionSpan; every model turn + // (manager and worker) carries an LlmGenerationSpan. + const workerAgentSpans = startedSpans(proc, AgentExecutionSpan); + expect(workerAgentSpans.length).toBeGreaterThan(0); + expect(workerAgentSpans.map((span) => span.name)).toContain( + "AgentExecution[Research Helper]", + ); + expect(startedSpans(proc, LlmGenerationSpan).length).toBeGreaterThan(1); + expect(eventsOf(proc, LlmGenerationRequest).length).toBeGreaterThan(1); + }); + + it("records ExceptionRaised on the CatchExceptionNode span", async () => { + const xProp = integerProperty({ title: "x" }); + const yProp = stringProperty({ title: "y", default: "" }); + const flakyTool = createServerTool({ + name: "flaky_tool", + description: "Raises for negative inputs", + inputs: [xProp], + outputs: [yProp], + }); + const subStart = ioStartNode("sub_start", [xProp]); + const flakyNode = createToolNode({ name: "flaky_node", tool: flakyTool }); + const subEnd = ioEndNode("sub_end", [yProp]); + const subflow = createFlow({ + name: "subflow", + startNode: subStart, + nodes: [subStart, flakyNode, subEnd], + controlFlowConnections: [ctrl(subStart, flakyNode), ctrl(flakyNode, subEnd)], + dataFlowConnections: [ + dataEdge(subStart, flakyNode, "x"), + dataEdge(flakyNode, subEnd, "y"), + ], + inputs: [xProp], + outputs: [yProp], + }); + const catchNode = createCatchExceptionNode({ name: "catch", subflow }); + const start = ioStartNode("start", [xProp]); + const end = ioEndNode("end", [yProp]); + const errorEnd = ioEndNode("error_end", [], "ERROR"); + const flow = createFlow({ + name: "outer", + startNode: start, + nodes: [start, catchNode, end, errorEnd], + controlFlowConnections: [ + ctrl(start, catchNode), + ctrl(catchNode, end), + ctrl(catchNode, errorEnd, "caught_exception_branch"), + ], + dataFlowConnections: [ + dataEdge(start, catchNode, "x"), + dataEdge(catchNode, end, "y"), + ], + inputs: [xProp], + outputs: [yProp], + }); + const graph = (await loadFlow(flow, { + toolRegistry: { + flaky_tool: (input: unknown) => { + if ((input as { x: number }).x < 0) { + throw new Error("x must be non-negative"); + } + return "ok"; + }, + }, + })) as unknown as RunnableGraph; + + const proc = new RecordingSpanProcessor(); + let response: Record = {}; + await new Trace({ spanProcessors: [proc] }).run(async () => { + response = await graph.invoke({ inputs: { x: -1 } }); + }); + + expect(detailsOf(response)["branch"]).toBe("ERROR"); + // Two ExceptionRaised events: the failing ToolNode span records the + // propagating error, and the CatchExceptionNode span records the caught + // one (Python parity for the async path). + const exceptionPairs = proc.events.filter( + ([event]) => event instanceof ExceptionRaised, + ) as Array<[ExceptionRaised, Span]>; + expect(exceptionPairs).toHaveLength(2); + for (const [event, span] of exceptionPairs) { + expect(event.exceptionMessage).toContain("x must be non-negative"); + expect(span).toBeInstanceOf(NodeExecutionSpan); + } + const spanNames = exceptionPairs.map(([, span]) => span.name); + expect(spanNames).toContain("ToolNodeExecution[flaky_node]"); + expect(spanNames).toContain("CatchExceptionNodeExecution[catch]"); + // The outer flow still completes: every started span ended. + expect(proc.ends).toHaveLength(proc.starts.length); + }); + + it("runs without a Trace: no processors, no events, unchanged behavior", async () => { + const agent = await loadWeatherAgent(); + const response = await agent.invoke(WEATHER_QUESTION); + expect(JSON.stringify(response).toLowerCase()).toContain("sunny"); + expect(getCurrentSpan()).toBeUndefined(); + }); + + it("attaches the LLM handler to converter-built chat models", async () => { + const llmConfig = makeLlmConfig({ name: "traced-llm" }); + const model = await convertLlmConfig(llmConfig); + const callbacks = (model as { callbacks?: unknown }).callbacks; + expect(Array.isArray(callbacks)).toBe(true); + const handler = (callbacks as unknown[]).find( + (callback) => callback instanceof AgentSpecLlmCallbackHandler, + ) as AgentSpecLlmCallbackHandler | undefined; + expect(handler).toBeDefined(); + expect(handler!.llmConfig).toBe(llmConfig); + }); + + it("attaches the tool handler to server tools but not client tools", async () => { + const serverTool = weatherTool(); + const structuredTool = convertServerTool(serverTool, { + get_weather: WEATHER_TOOL_REGISTRY.get_weather, + }); + const serverCallbacks = (structuredTool as { callbacks?: unknown[] }) + .callbacks; + const serverHandler = serverCallbacks?.find( + (callback) => callback instanceof AgentSpecToolCallbackHandler, + ) as AgentSpecToolCallbackHandler | undefined; + expect(serverHandler).toBeDefined(); + expect(serverHandler!.tool).toBe(serverTool); + + const clientTool = createClientTool({ + name: "ask_user", + inputs: [stringProperty({ title: "question" })], + }); + const structuredClientTool = convertClientTool(clientTool); + const clientCallbacks = (structuredClientTool as { callbacks?: unknown[] }) + .callbacks; + const clientHandler = clientCallbacks?.find( + (callback) => callback instanceof AgentSpecToolCallbackHandler, + ); + expect(clientHandler).toBeUndefined(); + }); + + it("attaches the tool handler to loaded MCP tools with a synthesized MCPTool", async () => { + const transport = createSSETransport({ + name: "my server", + url: "https://example.com/sse", + }); + mcpMocks.tools = [ + { + name: "fooza_tool", + description: "fooza_tool description", + schema: { + title: "fooza_tool", + type: "object", + properties: { q: { type: "string" } }, + }, + }, + ]; + + const tools = await getOrCreateMcpTools( + transport, + convertClientTransport(transport), + {}, + ); + + const callbacks = (tools["fooza_tool"] as { callbacks?: unknown[] }) + .callbacks; + expect(Array.isArray(callbacks)).toBe(true); + const handler = callbacks!.find( + (callback) => callback instanceof AgentSpecToolCallbackHandler, + ) as AgentSpecToolCallbackHandler | undefined; + expect(handler).toBeDefined(); + const synthesized = handler!.tool as MCPTool; + expect(synthesized.componentType).toBe("MCPTool"); + expect(synthesized.name).toBe("fooza_tool"); + expect(synthesized.description).toBe("fooza_tool description"); + expect(synthesized.clientTransport).toEqual(transport); + expect(synthesized.inputs?.map((input) => input.title)).toEqual(["q"]); + expect(synthesized.outputs?.map((output) => output.title)).toEqual([ + "tool_output", + ]); + }); +}); From 6615fe8ed707c5a52d731db01fca38788f05d8a5 Mon Sep 17 00:00:00 2001 From: Salah Date: Sun, 6 Sep 2026 02:36:57 +0400 Subject: [PATCH 11/14] fix(tsagentspec): address parity and security review findings for the new SDK surfaces --- tsagentspec/README.md | 14 +- .../src/adapters/common/tools-common.ts | 18 ++- tsagentspec/src/adapters/langgraph/tracing.ts | 86 ++++++++--- tsagentspec/src/sensitive-field.ts | 6 + tsagentspec/src/tracing/context.ts | 21 ++- .../adapters/common/tools-common.test.ts | 20 +++ .../tests/adapters/langgraph/tracing.test.ts | 145 ++++++++++++++++++ 7 files changed, 284 insertions(+), 26 deletions(-) diff --git a/tsagentspec/README.md b/tsagentspec/README.md index f32d71f4..85146f23 100644 --- a/tsagentspec/README.md +++ b/tsagentspec/README.md @@ -18,6 +18,10 @@ These types round-trip correctly between JSON/YAML and TypeScript objects: `tests/repo-fixtures.test.ts` is the CI contract: a curated list of 61 configs known to round-trip with the current SDK. Not every example or historical config file in the repo is covered here. Files using types outside the list above (e.g. `howto_swarm`, `howto_a2aagent`) are not yet supported. +### Divergences from the Python SDK + +- `OAuthClientConfig` secrets (`client_id`, `client_secret`, `client_id_metadata_url`) are redacted from serialized output like other sensitive fields. pyagentspec declares them as `SensitiveField`s, but an annotation bug (`Optional[SensitiveField[str]]` buries the marker inside the Union, so pydantic never lifts it into the field metadata) means Python currently exports the plain values; this SDK follows the declared intent. + ## Installation This package is not published to npm. Install from source: @@ -122,12 +126,18 @@ const yaml = exporter.toYaml(compiledGraph) as string; // also: toJson, toDict, ### Divergences from the Python adapter - The loader and converter APIs are async (`Promise`-based); Python is sync-first. -- `RemoteTool` and `ApiNode` requests honor the spec's `RetryPolicy` (attempts, backoff with all four jitter modes, `Retry-After` with the 30s cap, recoverable statuses with response-body code matching, per-request `requestTimeout` override, no retry on TLS failures) and enforce `urlAllowList` on every rendered URL; a configured allow list suppresses the templated-URL warning, like Python. `ApiNode` retries diverge from Python, whose executor performs a single plain request. Requests do not follow redirects and default to a 5-second timeout (`DEFAULT_HTTP_REQUEST_TIMEOUT_MS`), matching httpx's defaults. +- `RemoteTool` and `ApiNode` requests honor the spec's `RetryPolicy` (attempts, backoff with all four jitter modes, `Retry-After` with the 30s cap, recoverable statuses with response-body code matching, per-request `requestTimeout` override, no retry on TLS failures) and enforce `urlAllowList` on every rendered URL; a configured allow list suppresses the templated-URL warning, like Python. `ApiNode` retries diverge from Python, whose executor performs a single plain request. Requests do not follow redirects and default to a 5-second timeout (`DEFAULT_HTTP_REQUEST_TIMEOUT_MS`), matching httpx's defaults. Jitter randomness uses `Math.random()` (Python uses `SystemRandom`), and malformed `Retry-After` headers are handled defensively rather than byte-matching Python's edge behavior: a negative numeric value (invalid per RFC 9110) clamps to an immediate retry where Python lets it crash the call, `inf`/`Infinity` fall back to jittered backoff where Python caps the wait, and some date formats Python rejects (e.g. ISO 8601) are accepted. - When exporting a LangGraph graph whose conditional edge collides with a real node literally named `condition`, the synthetic conditional/branching node names are suffixed (`condition_1`, ...) so the real node keeps its edges; the Python-style names are used otherwise. - MCP transport `auth` and `retryPolicy` are representation-only (as in Python): they survive load → export untouched but are not wired into the MCP connection. - `OciGenAiConfig` is not supported (no `langchain-oci` package for JS). - The MCP mTLS transports (`SSEmTLSTransport`, `StreamableHTTPmTLSTransport`) are not supported. -- Tracing is a no-op seam only; no execution spans or events are emitted yet. +- Tracing is emitted with Python-parity span/event payloads: loaded graphs are wrapped in execution spans (`AgentExecutionSpan`, `FlowExecutionSpan`, `ManagerWorkersExecutionSpan`), and converter-built chat models and tools emit LLM-generation and tool-execution spans and events. Divergences: + - The tracing API is async-only; Python's sync/async twin callbacks collapse into single async handlers. + - A raw compiled graph unwrapped from a patched react agent (swarm assembly, the ManagerWorkers `__manager__` node) is not patched, so those embedded sub-agent runs emit no `AgentExecutionSpan` of their own; ManagerWorkers workers, invoked through the patched agent, do. + - Tool-end events follow Python's sync `on_tool_end` payload mapping (declared-outputs title mapping, `request_id` always the LangChain run id) — the variant Python's flow tests pin to exact payloads. Python routes runs under an event loop (`ainvoke`/`astream`) to its async twin, which maps a non-dict `ToolMessage` payload to `{"output": ...}` and takes `request_id` from the message's `tool_call_id` when present, so async-Python traces differ from TS (and from sync-Python) traces on those fields. + - The `invoke` wrapper builds the execution-span end event from the invoke result (Python folds streamed state chunks, which yields `{}` on the invoke path). + - Non-string trace payloads are coerced with `JSON.stringify` where Python uses `str(...)`. + - Parallel isolation comes from forking an `AsyncLocalStorage` child context per patched run and per flow-node span (Python relies on asyncio tasks copying `contextvars` per task). See [examples/09-langgraph-adapter.ts](./examples/09-langgraph-adapter.ts) for a complete offline round trip. diff --git a/tsagentspec/src/adapters/common/tools-common.ts b/tsagentspec/src/adapters/common/tools-common.ts index 10bde6e1..bda4c909 100644 --- a/tsagentspec/src/adapters/common/tools-common.ts +++ b/tsagentspec/src/adapters/common/tools-common.ts @@ -13,6 +13,8 @@ * - Jitter randomness uses `Math.random()` (Python uses `SystemRandom`), and * TLS-failure detection extends Python's message patterns with Node's TLS * error texts (case-insensitively). + * - Malformed `Retry-After` headers are handled defensively rather than + * byte-matching Python's edge behavior — see `getRetryAfterSeconds`. * * Python-parity network behavior (NOT divergences): redirects are not * followed and requests time out after `DEFAULT_HTTP_REQUEST_TIMEOUT_MS` @@ -205,9 +207,17 @@ async function isRetryableHttpError( /** * Parse and cap a `Retry-After` header value (Python's * `_get_retry_after_seconds`): a numeric value is taken as seconds, an - * HTTP-date as the seconds until that instant (never negative); both are - * capped at `MAX_RETRY_AFTER_SECONDS`. Returns null for absent or unparsable - * values. + * HTTP-date as the seconds until that instant; both are clamped to be never + * negative and capped at `MAX_RETRY_AFTER_SECONDS`. Returns null for absent + * or unparsable values. + * + * Malformed-header edges deliberately diverge from Python (see the adapter + * README): a negative numeric value (invalid per RFC 9110) clamps to an + * immediate retry where Python lets it crash the call in `time.sleep`; + * `inf`/`Infinity` are rejected as unparsable (falling back to jittered + * backoff) where Python's `float()` accepts them and caps the wait; and + * `Date.parse` accepts some date formats (e.g. ISO 8601) that Python's + * `parsedate_to_datetime` rejects. */ export function getRetryAfterSeconds( retryAfterValue: string | null, @@ -220,7 +230,7 @@ export function getRetryAfterSeconds( if (trimmed !== "") { const numericValue = Number(trimmed); if (Number.isFinite(numericValue)) { - return Math.min(numericValue, MAX_RETRY_AFTER_SECONDS); + return Math.min(Math.max(0, numericValue), MAX_RETRY_AFTER_SECONDS); } } const retryAfterDateMs = Date.parse(retryAfterValue); diff --git a/tsagentspec/src/adapters/langgraph/tracing.ts b/tsagentspec/src/adapters/langgraph/tracing.ts index 0b73a365..c697e9c5 100644 --- a/tsagentspec/src/adapters/langgraph/tracing.ts +++ b/tsagentspec/src/adapters/langgraph/tracing.ts @@ -63,6 +63,10 @@ import { ToolExecutionSpan, type Event as TracingEvent, } from "../../tracing/index.js"; +import { + forkChildContext, + runInChildContext, +} from "../../tracing/context.js"; import { isRecordLike } from "../common/index.js"; import { extractOutputsFromInvokeResult } from "./node-execution/agent-node.js"; @@ -667,6 +671,31 @@ function isPromiseLike(value: unknown): value is PromiseLike { ); } +/** + * Drive `generator` so every resumption re-enters `enterContext`. Async + * generator bodies resume in the ambient context of whoever calls `next()`, + * so a traced stream consumed from the caller's context would otherwise push + * and pop its execution span on the caller's shared span stack — corrupting + * it under parallel invocations. + */ +function resumeGeneratorInContext( + enterContext: (fn: () => T) => T, + generator: AsyncGenerator, +): AsyncGenerator { + return { + next: (...args: [] | [unknown]) => + enterContext(() => generator.next(...args)), + return: (value?: unknown) => enterContext(() => generator.return(value)), + throw: (error?: unknown) => enterContext(() => generator.throw(error)), + [Symbol.asyncIterator]() { + return this; + }, + async [Symbol.asyncDispose]() { + await enterContext(() => generator.return(undefined)); + }, + }; +} + /** * Wrap a compiled graph (or react agent) so each run is traced inside the * execution span named by `target.kind` for `target.component`. @@ -679,6 +708,12 @@ function isPromiseLike(value: unknown): value is PromiseLike { * matching Python's `astream` path). A `stream()` promise that rejects before * producing the iterable still emits the span with its Start event, like * Python's first-`anext` failure. + * + * Each patched run executes in a forked ambient context (the JS equivalent of + * Python's asyncio tasks copying contextvars per task), so parallel + * invocations — ManagerWorkers dispatching several workers at once, or a + * user-level `Promise.all` of patched graphs — keep isolated span stacks + * instead of pushing onto and popping from the caller's shared stack. */ export function patchWithExecutionSpan( graph: T, @@ -721,36 +756,51 @@ export function patchWithExecutionSpan( const wrapInvoke = (original: (...args: unknown[]) => unknown, targetObject: object) => - async (...args: unknown[]): Promise => { - const span = factories.makeSpan(); - await span.start(); - try { - await span.addEvent(factories.makeStartEvent(invocationInputs(args[0]))); - const result: unknown = await original.apply(targetObject, args); - await span.addEvent( - factories.makeEndEvent(isRecordLike(result) ? result : {}), - ); - return result; - } finally { - await span.end(); - } - }; + (...args: unknown[]): Promise => + // Fork the ambient context per invocation so parallel runs keep + // isolated span stacks (the underlying run — and thus the LLM/tool + // callback spans it emits — executes inside the fork, parented under + // this execution span). + runInChildContext(async () => { + const span = factories.makeSpan(); + await span.start(); + try { + await span.addEvent( + factories.makeStartEvent(invocationInputs(args[0])), + ); + const result: unknown = await original.apply(targetObject, args); + await span.addEvent( + factories.makeEndEvent(isRecordLike(result) ? result : {}), + ); + return result; + } finally { + await span.end(); + } + }); const wrapStream = (original: (...args: unknown[]) => unknown, targetObject: object) => (...args: unknown[]): unknown => { const inputs = invocationInputs(args[0]); - const out = original.apply(targetObject, args); + // One fork per stream call: the underlying stream machinery, the traced + // generator's resumptions and the failed-start path all share it. + const enterContext = forkChildContext(); + const trace = (iterable: AsyncIterable): AsyncGenerator => + resumeGeneratorInContext( + enterContext, + enterContext(() => traceStream(iterable, inputs)), + ); + const out = enterContext(() => original.apply(targetObject, args)); if (isPromiseLike(out)) { return (out as Promise>).then( - (iterable) => traceStream(iterable, inputs), + (iterable) => trace(iterable), async (error: unknown) => { - await traceFailedStreamStart(inputs); + await enterContext(() => traceFailedStreamStart(inputs)); throw error; }, ); } - return traceStream(out as AsyncIterable, inputs); + return trace(out as AsyncIterable); }; // Probe-verified wrapping: a Proxy intercepting invoke/stream and binding diff --git a/tsagentspec/src/sensitive-field.ts b/tsagentspec/src/sensitive-field.ts index e0b2d208..cb6a6ebb 100644 --- a/tsagentspec/src/sensitive-field.ts +++ b/tsagentspec/src/sensitive-field.ts @@ -34,6 +34,12 @@ export const SENSITIVE_FIELDS = { "password", "sslkey", ]), + // Deliberate divergence from pyagentspec's current wire behavior: auth.py + // declares these as `Optional[SensitiveField[str]]`, which buries the + // sensitivity marker inside the Union so pydantic never lifts it into + // `FieldInfo.metadata` — Python therefore exports the plain secret values + // today. That is an upstream annotation bug; this SDK follows the declared + // intent and redacts. OAuthClientConfig: new Set(["clientId", "clientSecret", "clientIdMetadataUrl"]), } satisfies Partial>>; diff --git a/tsagentspec/src/tracing/context.ts b/tsagentspec/src/tracing/context.ts index bb778663..1888654d 100644 --- a/tsagentspec/src/tracing/context.ts +++ b/tsagentspec/src/tracing/context.ts @@ -75,6 +75,24 @@ export function popSpanFromActiveStack(): void { store.spanStack = store.spanStack.slice(0, -1); } +/** + * @internal Fork a child context seeded with the current trace and a copy of + * the current span stack, and return a runner that executes functions inside + * that same forked store on every call. Needed where one logical branch spans + * multiple resumptions driven from the parent context — e.g. an async + * generator consumed by the caller: async generator bodies resume in the + * context of whoever calls `next()`, so each resumption must re-enter the + * forked store explicitly. + */ +export function forkChildContext(): (fn: () => T) => T { + const store = currentStore(); + const childStore: TraceContextStore = { + trace: store.trace, + spanStack: [...store.spanStack], + }; + return (fn) => storage.run(childStore, fn); +} + /** * @internal Run `fn` in a forked child context seeded with the current trace * and a copy of the current span stack. Mutations inside the child (span @@ -83,6 +101,5 @@ export function popSpanFromActiveStack(): void { * context at creation time. */ export function runInChildContext(fn: () => T): T { - const store = currentStore(); - return storage.run({ trace: store.trace, spanStack: [...store.spanStack] }, fn); + return forkChildContext()(fn); } diff --git a/tsagentspec/tests/adapters/common/tools-common.test.ts b/tsagentspec/tests/adapters/common/tools-common.test.ts index aa41cb1a..75b7ac68 100644 --- a/tsagentspec/tests/adapters/common/tools-common.test.ts +++ b/tsagentspec/tests/adapters/common/tools-common.test.ts @@ -120,6 +120,26 @@ describe("getRetryAfterSeconds", () => { expect(getRetryAfterSeconds(null)).toBeNull(); expect(getRetryAfterSeconds("soon")).toBeNull(); }); + + // The malformed-header edges below deliberately diverge from Python — see + // the getRetryAfterSeconds docstring and the adapter README. + + it("clamps a negative numeric value (invalid per RFC 9110) to an immediate retry", () => { + // Python returns -5 and lets time.sleep(-5) raise, failing the call. + expect(getRetryAfterSeconds("-5")).toBe(0); + expect(getRetryAfterSeconds("-0.1")).toBe(0); + }); + + it("treats infinite numeric values as unparsable (Python's float() caps them at 30)", () => { + expect(getRetryAfterSeconds("Infinity")).toBeNull(); + expect(getRetryAfterSeconds("-Infinity")).toBeNull(); + expect(getRetryAfterSeconds("inf")).toBeNull(); + }); + + it("accepts ISO 8601 dates (Python's HTTP-date parser rejects them)", () => { + const nowMs = Date.parse("2015-10-21T07:28:00Z"); + expect(getRetryAfterSeconds("2015-10-21T07:28:10Z", nowMs)).toBe(10); + }); }); describe("computeWaitSeconds jitter modes", () => { diff --git a/tsagentspec/tests/adapters/langgraph/tracing.test.ts b/tsagentspec/tests/adapters/langgraph/tracing.test.ts index 93974a80..f3ea889c 100644 --- a/tsagentspec/tests/adapters/langgraph/tracing.test.ts +++ b/tsagentspec/tests/adapters/langgraph/tracing.test.ts @@ -67,6 +67,7 @@ import { import { AgentSpecLlmCallbackHandler, AgentSpecToolCallbackHandler, + patchWithExecutionSpan, } from "../../../src/adapters/langgraph/tracing.js"; import { FakeLlmAgentSpecLoader, @@ -1038,3 +1039,147 @@ describe("langgraph adapter tracing", () => { ]); }); }); + +/** + * Regression tests: `patchWithExecutionSpan` forks a child ambient context + * per run, so concurrent patched invocations in ONE context (ManagerWorkers + * dispatching several workers at once, a user-level `Promise.all`) keep + * isolated span stacks — a span ending first must not pop a still-running + * sibling from the caller's stack. Python is immune because asyncio tasks + * copy contextvars per task. + */ +describe("patchWithExecutionSpan parallel isolation", () => { + interface Deferred { + promise: Promise; + resolve: () => void; + } + + function deferred(): Deferred { + let resolve!: () => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; + } + + /** A fake compiled graph whose run signals `started`, then blocks on `gate`. */ + function gatedGraph(started: Deferred, gate: Deferred) { + return { + async invoke(_input: unknown): Promise> { + started.resolve(); + await gate.promise; + return { messages: [] }; + }, + async stream(_input: unknown): Promise> { + return (async function* () { + started.resolve(); + await gate.promise; + yield ["values", { messages: [] }]; + })(); + }, + }; + } + + function gatedPatchedPair() { + const started1 = deferred(); + const started2 = deferred(); + const gate1 = deferred(); + const gate2 = deferred(); + const graph1 = patchWithExecutionSpan(gatedGraph(started1, gate1), { + kind: "agent", + component: makeAgent({ name: "agent1" }), + }); + const graph2 = patchWithExecutionSpan(gatedGraph(started2, gate2), { + kind: "agent", + component: makeAgent({ name: "agent2" }), + }); + return { started1, started2, gate1, gate2, graph1, graph2 }; + } + + it("keeps concurrent invokes' span stacks isolated in one ambient context", async () => { + const { started1, started2, gate1, gate2, graph1, graph2 } = + gatedPatchedPair(); + const proc = new RecordingSpanProcessor(); + const trace = new Trace({ spanProcessors: [proc] }); + + await trace.run(async () => { + const run1 = graph1.invoke({}); + const run2 = graph2.invoke({}); + await Promise.all([started1.promise, started2.promise]); + + // Finish the first-started run while the second is still in flight. + gate1.resolve(); + await run1; + // The caller's ambient stack is untouched: agent1's end must not have + // popped agent2's still-running span, nor left ended-agent1 as current. + expect(getCurrentSpan()).toBe(trace.rootSpan); + const endedSoFar = endedSpans(proc, AgentExecutionSpan); + expect(endedSoFar).toHaveLength(1); + expect(endedSoFar[0]!.name).toBe("AgentExecution[agent1]"); + + gate2.resolve(); + await run2; + }); + + const agentSpans = startedSpans(proc, AgentExecutionSpan); + expect(agentSpans).toHaveLength(2); + // Parallel runs are siblings under the root, never nested under each other. + for (const span of agentSpans) { + expect(span.parentSpan).toBe(trace.rootSpan); + } + expect(endedSpans(proc, AgentExecutionSpan)).toHaveLength(2); + }); + + it("keeps concurrent streams isolated while consumed from the caller's context", async () => { + const { started1, started2, gate1, gate2, graph1, graph2 } = + gatedPatchedPair(); + const proc = new RecordingSpanProcessor(); + const trace = new Trace({ spanProcessors: [proc] }); + + await trace.run(async () => { + const drain = async (graph: typeof graph1): Promise => { + let chunks = 0; + for await (const _chunk of await graph.stream({})) { + chunks += 1; + } + return chunks; + }; + const run1 = drain(graph1); + const run2 = drain(graph2); + await Promise.all([started1.promise, started2.promise]); + + gate1.resolve(); + expect(await run1).toBe(1); + expect(getCurrentSpan()).toBe(trace.rootSpan); + expect(endedSpans(proc, AgentExecutionSpan)).toHaveLength(1); + + gate2.resolve(); + expect(await run2).toBe(1); + }); + + const agentSpans = startedSpans(proc, AgentExecutionSpan); + expect(agentSpans).toHaveLength(2); + for (const span of agentSpans) { + expect(span.parentSpan).toBe(trace.rootSpan); + } + expect(endedSpans(proc, AgentExecutionSpan)).toHaveLength(2); + }); + + it("parents the run's LLM and tool spans under the execution span inside the fork", async () => { + const agent = await loadWeatherAgent(); + const proc = new RecordingSpanProcessor(); + await new Trace({ spanProcessors: [proc] }).run(async () => { + await agent.invoke(WEATHER_QUESTION); + }); + + const agentSpans = startedSpans(proc, AgentExecutionSpan); + expect(agentSpans).toHaveLength(1); + const llmSpans = startedSpans(proc, LlmGenerationSpan); + const toolSpans = startedSpans(proc, ToolExecutionSpan); + expect(llmSpans.length).toBeGreaterThan(0); + expect(toolSpans.length).toBeGreaterThan(0); + for (const span of [...llmSpans, ...toolSpans]) { + expect(span.parentSpan).toBe(agentSpans[0]); + } + }); +}); From cd9e8f6a51fe6db2f5fb2cbbb6bc37742d9eaafd Mon Sep 17 00:00:00 2001 From: Salah Date: Sun, 6 Sep 2026 02:45:28 +0400 Subject: [PATCH 12/14] docs(tsagentspec): update LangGraph adapter docs for retry, allow-list, LlmConfig, auth, and tracing support --- tsagentspec/README.md | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/tsagentspec/README.md b/tsagentspec/README.md index 85146f23..2e87028d 100644 --- a/tsagentspec/README.md +++ b/tsagentspec/README.md @@ -9,11 +9,14 @@ These types round-trip correctly between JSON/YAML and TypeScript objects: - **Agents**: `Agent`, `Swarm`, `ManagerWorkers`, `RemoteAgent`, `SpecializedAgent`, `A2AAgent` - **Flows**: `Flow`, `StartNode`, `EndNode`, `LlmNode`, `ToolNode`, `AgentNode`, `FlowNode`, `BranchingNode`, `MapNode`, `ParallelMapNode`, `ParallelFlowNode`, `ApiNode`, `InputMessageNode`, `OutputMessageNode`, `CatchExceptionNode` - **Tools**: `ServerTool`, `ClientTool`, `RemoteTool`, `BuiltinTool`, `MCPTool` -- **LLM configs**: `OpenAiCompatibleConfig`, `OllamaConfig`, `VllmConfig`, `OpenAiConfig`, `OciGenAiConfig` +- **LLM configs**: `OpenAiCompatibleConfig`, `OllamaConfig`, `VllmConfig`, `OpenAiConfig`, `OciGenAiConfig`, bare `LlmConfig` - **MCP**: `MCPToolBox`, `StdioTransport`, `SSETransport`, `StreamableHTTPTransport` (and mTLS variants) +- **Auth**: `OAuthConfig`, `OAuthClientConfig` (the `auth` field on remote MCP transports) - **Datastores**: `InMemoryCollectionDatastore`, `OracleDatabaseDatastore`, `PostgresDatabaseDatastore` - **Other**: `ControlFlowEdge`, `DataFlowEdge`, `MessageSummarizationTransform`, `ConversationSummarizationTransform` +Non-component configuration objects nested in the above — `RetryPolicy` (on `RemoteTool`, `ApiNode`, MCP tools/transports, and the LLM configs) and `urlAllowList` (on `RemoteTool` and `ApiNode`) — round-trip with the same version-gated serialization as pyagentspec. + ### Fixture compatibility `tests/repo-fixtures.test.ts` is the CI contract: a curated list of 61 configs known to round-trip with the current SDK. Not every example or historical config file in the repo is covered here. Files using types outside the list above (e.g. `howto_swarm`, `howto_a2aagent`) are not yet supported. @@ -56,7 +59,7 @@ The LangChain packages are optional peer dependencies of this SDK; install the o | Packages | Needed for | |---|---| | `langchain`, `@langchain/langgraph`, `@langchain/core` | always (loader/exporter core) | -| `@langchain/openai` | `OpenAiConfig`, `OpenAiCompatibleConfig`, `VllmConfig` | +| `@langchain/openai` | `OpenAiConfig`, `OpenAiCompatibleConfig`, `VllmConfig`, bare `LlmConfig` | | `@langchain/ollama` | `OllamaConfig` | | `@langchain/mcp-adapters` | `MCPTool`, `MCPToolBox` | | `@langchain/langgraph-swarm` | `Swarm` | @@ -123,6 +126,29 @@ const yaml = exporter.toYaml(compiledGraph) as string; // also: toJson, toDict, `ParallelMapNode` and `ParallelFlowNode` are not supported and raise an error. +### Tracing + +Loaded graphs emit Agent Spec traces. Register a `SpanProcessor` by running the graph inside a `Trace`: + +```ts +import { SpanProcessor, Trace } from "agentspec"; +import type { Event, Span } from "agentspec"; + +class ConsoleSpanProcessor extends SpanProcessor { + onStart(span: Span) {} + onEnd(span: Span) { console.log("span", span.serialize()); } + onEvent(event: Event, span: Span) { console.log("event", event.serialize()); } + startup() {} + shutdown() {} +} + +await new Trace({ spanProcessors: [new ConsoleSpanProcessor()] }).run(() => + agent.invoke({ messages: [{ role: "user", content: "..." }] }), +); +``` + +Without an ambient `Trace`, nothing is emitted and behavior is unchanged. + ### Divergences from the Python adapter - The loader and converter APIs are async (`Promise`-based); Python is sync-first. From 5a95ba1b519f92831fd4432c811efa8e82a128d2 Mon Sep 17 00:00:00 2001 From: Salah Date: Sun, 6 Sep 2026 02:54:54 +0400 Subject: [PATCH 13/14] fix(tsagentspec): serialize MCP session parameters under Python's wire 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. --- .../builtin-deserialization-plugin.ts | 1 + .../builtin-serialization-plugin.ts | 1 + tsagentspec/tests/mcp/transport.test.ts | 38 +++++++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/tsagentspec/src/serialization/builtin-deserialization-plugin.ts b/tsagentspec/src/serialization/builtin-deserialization-plugin.ts index 5d946aa1..6c9c8d22 100644 --- a/tsagentspec/src/serialization/builtin-deserialization-plugin.ts +++ b/tsagentspec/src/serialization/builtin-deserialization-plugin.ts @@ -40,6 +40,7 @@ const MODEL_OBJECT_FIELDS = new Set([ "retryPolicy", "endpoints", // OAuthEndpoints "pkce", // PKCEPolicy + "sessionParameters", // SessionParameters ]); /** Deserialize a jsonSchema dict into a Property */ diff --git a/tsagentspec/src/serialization/builtin-serialization-plugin.ts b/tsagentspec/src/serialization/builtin-serialization-plugin.ts index e7d4e12c..98d5fbf3 100644 --- a/tsagentspec/src/serialization/builtin-serialization-plugin.ts +++ b/tsagentspec/src/serialization/builtin-serialization-plugin.ts @@ -37,6 +37,7 @@ const MODEL_OBJECT_FIELDS: Record = { }, endpoints: { excludeNulls: false }, // OAuthEndpoints pkce: { excludeNulls: false }, // PKCEPolicy + sessionParameters: { excludeNulls: false }, // SessionParameters }; function hasSerializedSensitiveValue(value: unknown): boolean { diff --git a/tsagentspec/tests/mcp/transport.test.ts b/tsagentspec/tests/mcp/transport.test.ts index 71dbc2a8..9803bc57 100644 --- a/tsagentspec/tests/mcp/transport.test.ts +++ b/tsagentspec/tests/mcp/transport.test.ts @@ -6,6 +6,8 @@ import { createStreamableHTTPTransport, createStreamableHTTPmTLSTransport, createRemoteTransport, + AgentSpecSerializer, + AgentSpecDeserializer, } from "../../src/index.js"; describe("StdioTransport", () => { @@ -138,3 +140,39 @@ describe("RemoteTransport", () => { expect(t.retryPolicy?.backoffFactor).toBe(2.0); }); }); + +describe("SessionParameters wire format", () => { + it("serializes readTimeoutSeconds under Python's snake_case key", () => { + const transport = createStreamableHTTPTransport({ + name: "t", + url: "https://mcp.example.com", + sessionParameters: { readTimeoutSeconds: 42 }, + }); + + const dumped = JSON.parse( + new AgentSpecSerializer().toJson(transport), + ) as { session_parameters: Record }; + + expect(dumped.session_parameters).toEqual({ read_timeout_seconds: 42 }); + }); + + it("reads a Python-authored session_parameters block", () => { + // A non-default read timeout must survive the crossing, not silently + // fall back to the 60s default. + const pythonWire = JSON.stringify({ + component_type: "StreamableHTTPTransport", + agentspec_version: "26.4.0", + id: "11111111-1111-1111-1111-111111111111", + name: "t", + metadata: {}, + session_parameters: { read_timeout_seconds: 42.0 }, + url: "https://mcp.example.com", + }); + + const transport = new AgentSpecDeserializer().fromJson(pythonWire) as { + sessionParameters: { readTimeoutSeconds: number }; + }; + + expect(transport.sessionParameters.readTimeoutSeconds).toBe(42); + }); +}); From 9330290dbe0c672a0e7b74bd79e438505765c0a5 Mon Sep 17 00:00:00 2001 From: Salah Date: Sun, 6 Sep 2026 11:45:14 +0400 Subject: [PATCH 14/14] fix(tsagentspec): bound the retry engine and stop leaking query secrets 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. --- tsagentspec/README.md | 4 +- .../src/adapters/common/tools-common.ts | 129 ++++++++++++- tsagentspec/src/retry-policy.ts | 11 +- .../adapters/common/tools-common.test.ts | 181 +++++++++++++++++- tsagentspec/vitest.config.ts | 7 + 5 files changed, 318 insertions(+), 14 deletions(-) diff --git a/tsagentspec/README.md b/tsagentspec/README.md index 2e87028d..2370f777 100644 --- a/tsagentspec/README.md +++ b/tsagentspec/README.md @@ -152,7 +152,9 @@ Without an ambient `Trace`, nothing is emitted and behavior is unchanged. ### Divergences from the Python adapter - The loader and converter APIs are async (`Promise`-based); Python is sync-first. -- `RemoteTool` and `ApiNode` requests honor the spec's `RetryPolicy` (attempts, backoff with all four jitter modes, `Retry-After` with the 30s cap, recoverable statuses with response-body code matching, per-request `requestTimeout` override, no retry on TLS failures) and enforce `urlAllowList` on every rendered URL; a configured allow list suppresses the templated-URL warning, like Python. `ApiNode` retries diverge from Python, whose executor performs a single plain request. Requests do not follow redirects and default to a 5-second timeout (`DEFAULT_HTTP_REQUEST_TIMEOUT_MS`), matching httpx's defaults. Jitter randomness uses `Math.random()` (Python uses `SystemRandom`), and malformed `Retry-After` headers are handled defensively rather than byte-matching Python's edge behavior: a negative numeric value (invalid per RFC 9110) clamps to an immediate retry where Python lets it crash the call, `inf`/`Infinity` fall back to jittered backoff where Python caps the wait, and some date formats Python rejects (e.g. ISO 8601) are accepted. +- `RemoteTool` and `ApiNode` requests honor the spec's `RetryPolicy` (attempts, backoff with all four jitter modes, `Retry-After` with the 30s cap, recoverable statuses with response-body code matching, per-request `requestTimeout` override, no retry on TLS failures) and enforce `urlAllowList` on every rendered URL; a configured allow list suppresses the templated-URL warning, like Python. `ApiNode` retries diverge from Python, whose executor performs a single plain request. Requests do not follow redirects and default to a 5-second timeout (`DEFAULT_HTTP_REQUEST_TIMEOUT_MS`), matching httpx's defaults. Jitter randomness uses `Math.random()` (Python uses `SystemRandom`), and malformed `Retry-After` headers are handled defensively rather than byte-matching Python's edge behavior: a negative numeric value (invalid per RFC 9110) is treated as an absent header so the configured backoff applies, where Python lets it crash the call in `time.sleep`; `inf`/`Infinity` fall back to jittered backoff where Python caps the wait; and some date formats Python rejects (e.g. ISO 8601) are accepted. +- Because spec files are untrusted input, the retry engine is bounded in ways Python's is not. Python caps only total elapsed time, which permits hundreds of thousands of requests from a single call when the configured delays are zero. Here, one call makes at most `MAX_HTTP_ATTEMPTS_PER_CALL` (100) HTTP attempts, consecutive attempts are separated by at least `MIN_RETRY_DELAY_SECONDS` (50ms) however low `initialRetryDelay`/`maxRetryDelay` are set (or however a server sets `Retry-After`), and `requestTimeout` must be finite and is clamped to a delay a timer can express (`MAX_HTTP_REQUEST_TIMEOUT_MS`) — Python's httpx reads a non-finite timeout as "no timeout", which no JS timer can represent. TLS *handshake* failures (e.g. an `https://` URL aimed at a plain-HTTP port) are treated as non-retryable alongside certificate-validation failures; Python retries them. +- Errors thrown for a non-2xx response redact the URL's query string, which routinely carries credentials in templated specs. Python's `raise_for_status` embeds the full URL, and this error reaches both the model (as the tool result) and the logs. - When exporting a LangGraph graph whose conditional edge collides with a real node literally named `condition`, the synthetic conditional/branching node names are suffixed (`condition_1`, ...) so the real node keeps its edges; the Python-style names are used otherwise. - MCP transport `auth` and `retryPolicy` are representation-only (as in Python): they survive load → export untouched but are not wired into the MCP connection. - `OciGenAiConfig` is not supported (no `langchain-oci` package for JS). diff --git a/tsagentspec/src/adapters/common/tools-common.ts b/tsagentspec/src/adapters/common/tools-common.ts index bda4c909..c9a556af 100644 --- a/tsagentspec/src/adapters/common/tools-common.ts +++ b/tsagentspec/src/adapters/common/tools-common.ts @@ -50,6 +50,41 @@ export const DEFAULT_TOTAL_ELAPSED_TIME_SECONDS = 600; /** Cap (seconds) applied to server-provided `Retry-After` delays. */ export const MAX_RETRY_AFTER_SECONDS = 30; +/** + * Largest per-attempt timeout a timer can actually express, in milliseconds. + * + * `AbortSignal.timeout` throws a `RangeError` for a non-integer or out-of-range + * delay, and Node silently degrades a delay in `(2^31, 2^32)` ms to 1ms while + * reporting the requested value. Timeouts are clamped to this bound so neither + * happens: an untrusted spec cannot turn `requestTimeout` into a thrown + * `RangeError` (which the retry loop would otherwise treat as transient) or + * into a 1ms budget masquerading as a large one. + */ +export const MAX_HTTP_REQUEST_TIMEOUT_MS = 2 ** 31 - 1; + +/** + * Cap on the number of HTTP attempts one call may make, regardless of the + * spec's `RetryPolicy.maxAttempts`. + * + * Divergence from Python, which bounds only elapsed time: spec files are + * untrusted input, and `max_attempts` is unbounded in both SDKs, so a hostile + * spec could otherwise turn a single tool call into a request flood (the + * elapsed-time cap alone permits hundreds of thousands of requests when the + * configured delays are zero). Legitimate policies stay far below this bound. + */ +export const MAX_HTTP_ATTEMPTS_PER_CALL = 100; + +/** + * Floor (seconds) on the wait between two attempts of the same call. + * + * Divergence from Python: a spec may configure `initial_retry_delay` and + * `max_retry_delay` to 0, and a hostile server may send `Retry-After: 0`, both + * of which would otherwise let one call retry as fast as the event loop allows. + * The floor bounds the outbound request RATE; `MAX_HTTP_ATTEMPTS_PER_CALL` + * bounds the total. + */ +export const MIN_RETRY_DELAY_SECONDS = 0.05; + /** * Perform one `fetch` with the adapter's Python-parity network behavior: * @@ -68,11 +103,12 @@ export async function fetchWithAdapterDefaults( requesterDescription: string, timeoutMs: number = DEFAULT_HTTP_REQUEST_TIMEOUT_MS, ): Promise { + const boundedTimeoutMs = clampRequestTimeoutMs(timeoutMs); try { return await fetch(url, { ...init, redirect: "manual", - signal: AbortSignal.timeout(timeoutMs), + signal: AbortSignal.timeout(boundedTimeoutMs), }); } catch (error) { // AbortSignal.timeout aborts with a DOMException named "TimeoutError". @@ -82,13 +118,44 @@ export async function fetchWithAdapterDefaults( (error as { name?: unknown }).name === "TimeoutError" ) { throw new Error( - `${requesterDescription} HTTP request timed out after ${timeoutMs}ms.`, + `${requesterDescription} HTTP request timed out after ${boundedTimeoutMs}ms.`, ); } throw error; } } +/** + * Clamp a per-attempt timeout into the range a timer can express. + * + * Applied at the `fetch` call site rather than only in the schema so a policy + * built in code (not parsed from a spec) cannot reach the same broken states. + */ +export function clampRequestTimeoutMs(timeoutMs: number): number { + if (!Number.isFinite(timeoutMs) || timeoutMs > MAX_HTTP_REQUEST_TIMEOUT_MS) { + return MAX_HTTP_REQUEST_TIMEOUT_MS; + } + return Math.max(1, Math.round(timeoutMs)); +} + +/** + * Whether an error was raised while CONSTRUCTING the request, before any + * network activity. Such an error fails identically on every attempt, so + * retrying it only burns the elapsed budget. + * + * Only `RangeError` is classified here, because it is unambiguous: `fetch` + * never reports a transport failure that way, while an out-of-range timer + * delay does. A cause-less `TypeError` is deliberately NOT treated as local — + * undici attaches a `cause` to real transport failures, but test doubles and + * non-undici fetch implementations raise bare `TypeError`s for simulated + * network errors, and those must stay retryable. Permanently-failing + * `TypeError`s (e.g. a URL carrying credentials) are bounded instead by + * `MAX_HTTP_ATTEMPTS_PER_CALL` and `MIN_RETRY_DELAY_SECONDS`. + */ +export function isNonRetryableLocalError(error: unknown): boolean { + return error instanceof RangeError; +} + /** * Message-text patterns identifying TLS/certificate validation failures * (matched case-insensitively over each error's name, code and message). @@ -106,6 +173,16 @@ const TLS_ERROR_PATTERNS: readonly string[] = [ "unable to get local issuer certificate", "certificate has expired", "altname", + // TLS handshake failures (e.g. an https:// URL pointed at a plain-HTTP + // port). Retrying cannot fix them either, and without these an untrusted + // spec aimed at an internal port retries for the full elapsed budget. + // Divergence from Python, whose pattern list covers only cert validation. + "err_ssl_wrong_version_number", + "wrong version number", + "ssl routines", + "sslv3 alert", + "tlsv1 alert", + "packet length too long", ]; /** @@ -230,7 +307,15 @@ export function getRetryAfterSeconds( if (trimmed !== "") { const numericValue = Number(trimmed); if (Number.isFinite(numericValue)) { - return Math.min(Math.max(0, numericValue), MAX_RETRY_AFTER_SECONDS); + // A negative delay is invalid per RFC 9110, and must be treated as an + // ABSENT header so the configured backoff applies. Honoring it as a + // zero-second wait would let a hostile server erase the operator's + // backoff and drive the retry loop at full speed. A legitimate `0` + // still means "retry immediately" (subject to the delay floor). + if (numericValue < 0) { + return null; + } + return Math.min(numericValue, MAX_RETRY_AFTER_SECONDS); } } const retryAfterDateMs = Date.parse(retryAfterValue); @@ -300,9 +385,14 @@ function computeWaitBeforeNextAttempt( retryAfterValue: string | null, timeStartedMs: number, ): number | null { - const waitTimeSeconds = + const waitTimeSeconds = Math.max( getRetryAfterSeconds(retryAfterValue) ?? - computeWaitSeconds(retryPolicy, attemptNum, statusCode); + computeWaitSeconds(retryPolicy, attemptNum, statusCode), + // Bound the outbound request rate: zero configured delays (or a server + // sending `Retry-After: 0`) must not let one call retry as fast as the + // event loop allows. See MIN_RETRY_DELAY_SECONDS. + MIN_RETRY_DELAY_SECONDS, + ); const remainingSeconds = DEFAULT_TOTAL_ELAPSED_TIME_SECONDS - (Date.now() - timeStartedMs) / 1000; @@ -349,7 +439,10 @@ export async function requestWithRetry( retryPolicy.requestTimeout != null ? retryPolicy.requestTimeout * 1000 : DEFAULT_HTTP_REQUEST_TIMEOUT_MS; - const totalAttempts = retryPolicy.maxAttempts + 1; + const totalAttempts = Math.min( + retryPolicy.maxAttempts + 1, + MAX_HTTP_ATTEMPTS_PER_CALL, + ); const timeStartedMs = Date.now(); for (let attemptNum = 0; attemptNum < totalAttempts; attemptNum += 1) { @@ -362,7 +455,11 @@ export async function requestWithRetry( timeoutMs, ); } catch (error) { - if (isTlsOrCertError(error) || attemptNum >= totalAttempts - 1) { + if ( + isTlsOrCertError(error) || + isNonRetryableLocalError(error) || + attemptNum >= totalAttempts - 1 + ) { throw error; } const waitTimeSeconds = computeWaitBeforeNextAttempt( @@ -435,10 +532,26 @@ export function raiseForStatusWhenPolicySet( response.statusText.length > 0 ? ` ${response.statusText}` : ""; throw new Error( `${requesterDescription} HTTP request failed with status ` + - `'${response.status}${statusText}' for url '${url}'.`, + `'${response.status}${statusText}' for url '${redactUrlQuery(url)}'.`, ); } +/** + * Strip the query string from a URL before it reaches an error message. + * + * Divergence from Python, whose `raise_for_status` embeds the full URL: this + * error surfaces to the model as the tool result and into logs, and templated + * query parameters routinely carry credentials. The path is kept so the + * message stays diagnostically useful. + */ +export function redactUrlQuery(url: string): string { + const queryStart = url.indexOf("?"); + if (queryStart === -1) { + return url; + } + return `${url.slice(0, queryStart)}?`; +} + /** * Render `{{placeholder}}` templates in both the keys and the values of a * record (header/query-param maps), like Python's dict comprehensions over diff --git a/tsagentspec/src/retry-policy.ts b/tsagentspec/src/retry-policy.ts index fe012ab9..2068308b 100644 --- a/tsagentspec/src/retry-policy.ts +++ b/tsagentspec/src/retry-policy.ts @@ -52,8 +52,15 @@ export const RetryPolicySchema: z.ZodType< .object({ /** Maximum number of retries (not counting the initial attempt). */ maxAttempts: z.number().int().min(0).default(2), - /** Per-attempt timeout in seconds (fractional values allowed). */ - requestTimeout: z.number().gt(0).nullish().default(null), + /** + * Per-attempt timeout in seconds (fractional values allowed). + * + * Must be finite: `Infinity` means "no timeout" to Python's httpx, but no + * timer can express it, so it is rejected here rather than silently + * reinterpreted. Values too large for a timer are clamped when the request + * is made (see `clampRequestTimeoutMs`). + */ + requestTimeout: z.number().gt(0).finite().nullish().default(null), /** Base delay (seconds) used for exponential backoff. */ initialRetryDelay: z.number().min(0).default(1.0), /** Cap (seconds) on the backoff delay between two retries. */ diff --git a/tsagentspec/tests/adapters/common/tools-common.test.ts b/tsagentspec/tests/adapters/common/tools-common.test.ts index 75b7ac68..c9c50852 100644 --- a/tsagentspec/tests/adapters/common/tools-common.test.ts +++ b/tsagentspec/tests/adapters/common/tools-common.test.ts @@ -17,11 +17,18 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { RetryPolicySchema, type RetryPolicy } from "../../../src/index.js"; import { isRecordLike } from "../../../src/adapters/common/guards.js"; import { + MAX_HTTP_ATTEMPTS_PER_CALL, + MAX_HTTP_REQUEST_TIMEOUT_MS, MAX_RETRY_AFTER_SECONDS, + MIN_RETRY_DELAY_SECONDS, buildTemplatedHttpRequest, + clampRequestTimeoutMs, computeWaitSeconds, getRetryAfterSeconds, + isNonRetryableLocalError, isTlsOrCertError, + raiseForStatusWhenPolicySet, + redactUrlQuery, requestWithRetry, } from "../../../src/adapters/common/tools-common.js"; @@ -124,10 +131,17 @@ describe("getRetryAfterSeconds", () => { // The malformed-header edges below deliberately diverge from Python — see // the getRetryAfterSeconds docstring and the adapter README. - it("clamps a negative numeric value (invalid per RFC 9110) to an immediate retry", () => { + it("treats a negative numeric value (invalid per RFC 9110) as an absent header", () => { // Python returns -5 and lets time.sleep(-5) raise, failing the call. - expect(getRetryAfterSeconds("-5")).toBe(0); - expect(getRetryAfterSeconds("-0.1")).toBe(0); + // Returning null here makes the caller fall back to the configured + // backoff: honoring the value as a zero-second wait would let a hostile + // server erase the operator's backoff and drive the retry loop at speed. + expect(getRetryAfterSeconds("-5")).toBeNull(); + expect(getRetryAfterSeconds("-0.1")).toBeNull(); + }); + + it("keeps a legitimate zero-second Retry-After", () => { + expect(getRetryAfterSeconds("0")).toBe(0); }); it("treats infinite numeric values as unparsable (Python's float() caps them at 30)", () => { @@ -311,3 +325,164 @@ describe("requestWithRetry backoff and elapsed cap", () => { expect(calls).toHaveLength(1); }); }); + +describe("retry engine bounds against untrusted specs", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it("clamps a per-attempt timeout to a value a timer can express", () => { + // AbortSignal.timeout throws a RangeError for these, which the retry loop + // would otherwise treat as a transient transport failure. + expect(clampRequestTimeoutMs(Infinity)).toBe(MAX_HTTP_REQUEST_TIMEOUT_MS); + expect(clampRequestTimeoutMs(1e308)).toBe(MAX_HTTP_REQUEST_TIMEOUT_MS); + // (2^31, 2^32) ms silently degrades to a 1ms timer in Node. + expect(clampRequestTimeoutMs(3_000_000_000)).toBe( + MAX_HTTP_REQUEST_TIMEOUT_MS, + ); + expect(clampRequestTimeoutMs(0.4)).toBe(1); + expect(clampRequestTimeoutMs(5000)).toBe(5000); + expect(() => AbortSignal.timeout(clampRequestTimeoutMs(Infinity))).not.toThrow(); + }); + + it("rejects a non-finite requestTimeout at the schema boundary", () => { + expect(() => RetryPolicySchema.parse({ requestTimeout: Infinity })).toThrow(); + expect(() => RetryPolicySchema.parse({ requestTimeout: NaN })).toThrow(); + expect(RetryPolicySchema.parse({ requestTimeout: 30 }).requestTimeout).toBe(30); + }); + + it("does not retry an out-of-range timer error", () => { + // A RangeError is never a transport failure; retrying it spins the loop + // without producing any network traffic. + expect(isNonRetryableLocalError(new RangeError("out of range"))).toBe(true); + // A bare TypeError stays retryable: test doubles and non-undici fetch + // implementations raise those for simulated network errors. + expect(isNonRetryableLocalError(new TypeError("temporary failure"))).toBe( + false, + ); + }); + + it("caps the number of attempts however large maxAttempts is", async () => { + vi.useFakeTimers(); + let calls = 0; + globalThis.fetch = vi.fn(async () => { + calls += 1; + return new Response("{}", { status: 503 }); + }) as typeof fetch; + const policy = makeRetryPolicy({ + maxAttempts: 1_000_000_000, + initialRetryDelay: 0, + maxRetryDelay: 0, + jitter: null, + serviceErrorRetryOnAny5xx: true, + }); + + const pending = requestWithRetry(policy, "https://x/", {}, "T"); + await vi.runAllTimersAsync(); + await pending; + + expect(calls).toBe(MAX_HTTP_ATTEMPTS_PER_CALL); + }); + + it("floors the wait between attempts when the spec configures zero delays", async () => { + vi.useFakeTimers(); + const waits: number[] = []; + const setTimeoutSpy = vi + .spyOn(globalThis, "setTimeout") + .mockImplementation(((fn: () => void, ms?: number) => { + waits.push(ms ?? 0); + fn(); + return 0 as unknown as ReturnType; + }) as typeof setTimeout); + globalThis.fetch = vi.fn( + async () => new Response("{}", { status: 503 }), + ) as typeof fetch; + const policy = makeRetryPolicy({ + maxAttempts: 3, + initialRetryDelay: 0, + maxRetryDelay: 0, + jitter: null, + serviceErrorRetryOnAny5xx: true, + }); + + await requestWithRetry(policy, "https://x/", {}, "T"); + + expect(waits.length).toBeGreaterThan(0); + for (const wait of waits) { + expect(wait).toBeGreaterThanOrEqual(MIN_RETRY_DELAY_SECONDS * 1000); + } + setTimeoutSpy.mockRestore(); + }); + + it("falls back to backoff when a server sends a negative Retry-After", async () => { + vi.useFakeTimers(); + const waits: number[] = []; + const setTimeoutSpy = vi + .spyOn(globalThis, "setTimeout") + .mockImplementation(((fn: () => void, ms?: number) => { + waits.push(ms ?? 0); + fn(); + return 0 as unknown as ReturnType; + }) as typeof setTimeout); + globalThis.fetch = vi.fn( + async () => + new Response("{}", { status: 503, headers: { "Retry-After": "-1" } }), + ) as typeof fetch; + const policy = makeRetryPolicy({ + maxAttempts: 2, + initialRetryDelay: 1, + maxRetryDelay: 8, + jitter: null, + serviceErrorRetryOnAny5xx: true, + }); + + await requestWithRetry(policy, "https://x/", {}, "T"); + + // The configured 1s backoff applies; the hostile header does not erase it. + expect(waits[0]).toBeGreaterThanOrEqual(1000); + setTimeoutSpy.mockRestore(); + }); + + it("treats a TLS handshake failure as non-retryable, like a bad certificate", () => { + const handshakeFailure = new TypeError("fetch failed", { + cause: Object.assign(new Error("wrong version number"), { + code: "ERR_SSL_WRONG_VERSION_NUMBER", + }), + }); + expect(isTlsOrCertError(handshakeFailure)).toBe(true); + }); +}); + +describe("error messages do not leak query secrets", () => { + it("redacts the query string from a non-2xx tool error", () => { + const policy = makeRetryPolicy({}); + const response = new Response("nope", { status: 401 }); + + expect(() => + raiseForStatusWhenPolicySet( + policy, + response, + "RemoteTool `leak`", + "https://api.example.com/v1/x?api_key=SECRET&token=ALSO_SECRET", + ), + ).toThrow(/https:\/\/api\.example\.com\/v1\/x\?/); + + try { + raiseForStatusWhenPolicySet( + policy, + response, + "RemoteTool `leak`", + "https://api.example.com/v1/x?api_key=SECRET", + ); + } catch (error) { + expect((error as Error).message).not.toContain("SECRET"); + } + }); + + it("leaves a URL without a query string intact", () => { + expect(redactUrlQuery("https://api.example.com/v1/x")).toBe( + "https://api.example.com/v1/x", + ); + }); +}); diff --git a/tsagentspec/vitest.config.ts b/tsagentspec/vitest.config.ts index 2e752f88..047e416f 100644 --- a/tsagentspec/vitest.config.ts +++ b/tsagentspec/vitest.config.ts @@ -5,6 +5,13 @@ export default defineConfig({ globals: true, environment: "node", include: ["tests/**/*.test.ts"], + // The adapter suites load the LangChain/LangGraph peer packages through + // dynamic imports. Those imports are slow enough that the 5s default + // makes whichever test happens to pull a package in first time out on a + // loaded machine, so the failure rotates between suites instead of + // pointing at a real defect. Generous enough to absorb that, still tight + // enough to catch a genuine hang. + testTimeout: 30_000, coverage: { provider: "v8", reporter: ["text", "json", "html"],