diff --git a/tsagentspec/README.md b/tsagentspec/README.md index e5f14e21..2370f777 100644 --- a/tsagentspec/README.md +++ b/tsagentspec/README.md @@ -9,15 +9,22 @@ 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. +### 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: @@ -41,6 +48,127 @@ 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`, bare `LlmConfig` | +| `@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` (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. + +### 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. +- `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). +- The MCP mTLS transports (`SSEmTLSTransport`, `StreamableHTTPmTLSTransport`) are not supported. +- 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. + ## 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..6508eeae --- /dev/null +++ b/tsagentspec/src/adapters/common/agentspec-exporter.ts @@ -0,0 +1,198 @@ +/** + * 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( + (serializer, agentspecAssistant, serializerOptions) => + serializer.toJson(agentspecAssistant, serializerOptions), + runtimeComponent, + options, + ); + } + + /** + * 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( + (serializer, agentspecAssistant, serializerOptions) => + serializer.toYaml(agentspecAssistant, serializerOptions), + runtimeComponent, + options, + ); + } + + /** + * 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( + ( + 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, + ); + } + + /** + * 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. Each public method passes + * the closure that serializes the converted component to its output form. + */ + protected _export( + serialize: ( + serializer: AgentSpecSerializer, + agentspecAssistant: ComponentBase, + serializerOptions: { + agentspecVersion?: AgentSpecVersion; + disaggregatedComponents?: DisaggregatedComponentsConfig; + exportDisaggregatedComponents: boolean; + }, + ) => SerializedT, + runtimeComponent: unknown, + options?: ExportOptions, + ): SerializedT { + 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, + }; + return serialize(serializer, agentspecAssistant, serializerOptions); + } + + /** + * 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..0770e61d --- /dev/null +++ b/tsagentspec/src/adapters/common/agentspec-loader.ts @@ -0,0 +1,234 @@ +/** + * 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( + (deserializer, deserializeOptions) => + deserializer.fromYaml(serializedAssistant, deserializeOptions), + 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( + (deserializer, deserializeOptions) => + deserializer.fromJson(serializedAssistant, deserializeOptions), + options, + ); + } + + /** + * Transform the given Agent Spec dictionary into runtime components, with + * support for disaggregated configurations. + */ + async loadDict( + serializedAssistant: Record, + options?: LoadOptions, + ): Promise { + // 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, + ); + } + + /** + * 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. Each public method passes the + * closure that runs its deserializer entry point. + */ + protected async _load( + deserialize: ( + deserializer: AgentSpecDeserializer, + deserializeOptions: { + componentsRegistry?: ComponentsRegistry; + importOnlyReferencedComponents?: boolean; + }, + ) => ComponentBase | Record, + options?: LoadOptions, + ): Promise { + const deserializer = new AgentSpecDeserializer(this.plugins); + + const convertedRegistry = + options?.componentsRegistry !== undefined + ? this._convertComponentRegistry(options.componentsRegistry) + : undefined; + + if (options?.importOnlyReferencedComponents) { + // Loading the disaggregated components + const referencedComponentsDict = deserialize(deserializer, { + 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(deserializer, { + 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..b41ea1c6 --- /dev/null +++ b/tsagentspec/src/adapters/common/component-policy.ts @@ -0,0 +1,247 @@ +/** + * 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 { 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; + +/** 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; + +/** + * 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 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), + ); +} + +// 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); + +/** 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 + * runtime discriminated union owning that group. + */ +const ABSTRACT_COMPONENT_GROUPS: Record> = { + AgenticComponent: AGENTIC_COMPONENT_TYPES, + Node: NODE_TYPES, + Tool: TOOL_TYPES, + LlmConfig: LLM_CONFIG_TYPES, + ToolBox: unionMemberTypes(ToolBoxUnion), + OciClientConfig: unionMemberTypes(OciClientConfigUnion), + ClientTransport: CLIENT_TRANSPORT_TYPES, + 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. + */ +export 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/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 new file mode 100644 index 00000000..b48f4cf8 --- /dev/null +++ b/tsagentspec/src/adapters/common/index.ts @@ -0,0 +1,62 @@ +/** + * 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 { isPlainRecord, isRecordLike } from "./guards.js"; +export { importOptionalPeer } from "./optional-peer.js"; +export { + renderTemplate, + renderNestedObjectTemplate, + stringifyTemplateValue, +} from "./templating.js"; +export { + getUrlMatchParts, + matchesAllowListEntry, + getUrlDestinationPlaceholderNames, + maybeWarnAboutUnrestrictedTemplatedUrl, + validateUrlAgainstAllowList, +} from "./url-validation.js"; +export { + CLIENT_TRANSPORT_TYPES, + ComponentLoadPolicy, + LLM_CONFIG_TYPES, + NODE_TYPES, + type ComponentPolicyEntry, + type ComponentPolicyInput, +} from "./component-policy.js"; +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 { + 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..c820b7c2 --- /dev/null +++ b/tsagentspec/src/adapters/common/json-schema.ts @@ -0,0 +1,49 @@ +/** + * JSON-schema helpers shared by the AgentSpec adapters. + * + * `buildJsonSchemaFromProperties` builds an object schema from AgentSpec + * 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"; + +/** + * 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/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 new file mode 100644 index 00000000..e83ea4df --- /dev/null +++ b/tsagentspec/src/adapters/common/templating.ts @@ -0,0 +1,99 @@ +/** + * 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"; +import { isPlainRecord } from "./guards.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); +} + +/** + * 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 (isPlainRecord(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..c9a556af --- /dev/null +++ b/tsagentspec/src/adapters/common/tools-common.ts @@ -0,0 +1,761 @@ +/** + * 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, which also reuses the + * retry engine (Python's ApiNodeExecutor performs a single plain request). + * + * Divergences from Python (see the adapter README): + * - `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). + * - 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` + * 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 { + renderNestedObjectTemplate, + renderTemplate, + stringifyTemplateValue, +} from "./templating.js"; +import { + maybeWarnAboutUnrestrictedTemplatedUrl, + validateUrlAgainstAllowList, +} from "./url-validation.js"; + +/** + * 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. 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; + +/** + * 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: + * + * - 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 `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 { + const boundedTimeoutMs = clampRequestTimeoutMs(timeoutMs); + try { + return await fetch(url, { + ...init, + redirect: "manual", + signal: AbortSignal.timeout(boundedTimeoutMs), + }); + } 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 ${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). + * + * 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", + // 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", +]; + +/** + * 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; 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, + nowMs?: number, +): number | null { + if (retryAfterValue === null) { + return null; + } + const trimmed = retryAfterValue.trim(); + if (trimmed !== "") { + const numericValue = Number(trimmed); + if (Number.isFinite(numericValue)) { + // 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); + 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 = Math.max( + getRetryAfterSeconds(retryAfterValue) ?? + 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; + 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 = Math.min( + retryPolicy.maxAttempts + 1, + MAX_HTTP_ATTEMPTS_PER_CALL, + ); + 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) || + isNonRetryableLocalError(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 '${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 + * `render_template(k)` / `render_nested_object_template(v)`. + */ +export 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; +} + +/** + * The structural surface shared by the AgentSpec `RemoteTool` and `ApiNode` + * components: a templated HTTP request specification with an optional URL + * allow list. + */ +export interface TemplatedHttpRequestSpec { + url: string; + httpMethod: string; + 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 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` + * (`_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). + * + * `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); + 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 !== "" && + !(isRecord(renderedData) && Object.keys(renderedData).length === 0); + + let body: string | URLSearchParams | Uint8Array | undefined; + if (methodAllowsBody) { + if (expectUrlencodedFormData && isRecord(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"; + } + } + } + + // 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)) { + 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. + * + * The returned function renders `{{placeholder}}` templates in the URL, data, + * headers and query parameters using the call kwargs, validates the rendered + * 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. + */ +export function createRemoteToolFunc( + remoteTool: RemoteTool, +): (kwargs: Record) => Promise { + // A configured allow list suppresses the templated-destination warning, + // exactly like Python. + maybeWarnAboutUnrestrictedTemplatedUrl( + remoteTool.url, + remoteTool.urlAllowList, + `RemoteTool \`${remoteTool.name}\``, + ); + + return async function remoteToolFunc( + kwargs: Record, + ): Promise { + const { url, init } = buildTemplatedHttpRequest(remoteTool, kwargs); + const response = await requestWithRetry( + remoteTool.retryPolicy, + url, + init, + `RemoteTool \`${remoteTool.name}\``, + ); + // 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/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..9951d163 --- /dev/null +++ b/tsagentspec/src/adapters/langgraph/agentspec-converter-flow.ts @@ -0,0 +1,504 @@ +/** + * 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"; +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__"; + +/** 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 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: RuntimeToAgentSpecConverter, + 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, + }); + 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, + nodes, + 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..5252f956 --- /dev/null +++ b/tsagentspec/src/adapters/langgraph/agentspec-converter.ts @@ -0,0 +1,458 @@ +/** + * 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. + * - 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"; +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 { isRecordLike } from "../common/index.js"; +import { langgraphGraphConvertToAgentSpec } from "./agentspec-converter-flow.js"; +import { + getGraphBuilder, + isCompiledGraphLike, + isStateGraphLike, + stateSchemaKeys, +} from "./graph-introspection.js"; + +/** + * 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 (!isRecordLike(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 isRecordLike(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); + if (!(stateSchemaKeys(builder) ?? []).includes("activeAgent")) { + return false; + } + const startBranches = builder.branches?.["__start__"]; + if (!isRecordLike(startBranches)) { + return false; + } + const nodeSpecs = Object.values(builder.nodes ?? {}); + return ( + nodeSpecs.length > 0 && + nodeSpecs.every((spec) => isCompiledGraphLike(spec.runnable)) + ); +} + +/** 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 = isRecordLike(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 ?? + ""; + const retryPolicy = this.chatOpenAiRetryPolicyToAgentSpec(model); + if (baseUrl.startsWith("https://api.openai.com")) { + return createOpenAiConfig({ + name: modelName, + modelId: modelName, + apiType, + ...(retryPolicy !== undefined ? { retryPolicy } : {}), + }); + } + return createOpenAiCompatibleConfig({ + name: modelName, + url: baseUrl, + modelId: modelName, + apiType, + ...(retryPolicy !== undefined ? { retryPolicy } : {}), + }); + } + throw new Error( + `The LLM instance provided is of an unsupported type \`${constructorName || llmType}\`.`, + ); + } + + /** + * 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 + * 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 ( + isRecordLike(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..f73fa7c0 --- /dev/null +++ b/tsagentspec/src/adapters/langgraph/agentspec-loader.ts @@ -0,0 +1,99 @@ +/** + * 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"; +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). + */ + 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 { + /** 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. */ + 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/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/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-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 new file mode 100644 index 00000000..517fb283 --- /dev/null +++ b/tsagentspec/src/adapters/langgraph/langgraph-converter.ts @@ -0,0 +1,651 @@ +/** + * 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. + * - `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. + */ +import { ToolMessage } from "@langchain/core/messages"; +import type { RunnableConfig } from "@langchain/core/runnables"; +import type { BaseCheckpointSaver } 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, 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 } 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, + 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, + InvocableGraph, + ToolRegistry, +} from "./types.js"; + +/** Conversion parameters threaded through every recursive conversion. */ +interface ConversionContext { + toolRegistry: ToolRegistry; + convertedComponents: Map; + checkpointer?: BaseCheckpointSaver; + config: RunnableConfig; + middleware: unknown[]; +} + +/** + * Unwrap a langchain `ReactAgent` to its compiled graph; compiled graphs (and + * anything else) pass through unchanged. + */ +function resolveCompiledGraph(agentOrGraph: unknown): unknown { + 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. + * + * 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; + } +} + +/** + * 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.createReactAgent(agentspecComponent as Agent, context); + case "Swarm": + return this.convertSwarm(agentspecComponent as Swarm, context); + case "ManagerWorkers": + return this.compileManagerWorkersGraph( + agentspecComponent as ManagerWorkers, + context, + ); + 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: + // 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, + ); + } + 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 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 createReactAgent( + agent: Agent, + context: ConversionContext, + overrides?: { + systemPrompt?: string; + extraLangGraphTools?: unknown[]; + dropDeclaredInputs?: boolean; + }, + ): Promise { + 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 agent.toolboxes ?? []) { + const toolboxTools = (await this.convertWithContext( + toolbox, + context, + )) as unknown[]; + langgraphTools.push(...toolboxTools); + } + + const inputs = overrides?.dropDeclaredInputs ? [] : (agent.inputs ?? []); + const outputs = agent.outputs ?? []; + let systemPrompt = overrides?.systemPrompt ?? agent.systemPrompt; + let responseFormat: unknown; + 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", 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: agent.name, + model, + tools: langgraphTools, + systemPrompt, + }; + if (context.checkpointer !== undefined) { + createAgentParams["checkpointer"] = context.checkpointer; + } + if (responseFormat !== undefined) { + createAgentParams["responseFormat"] = responseFormat; + } + if (inputs.length > 0) { + createAgentParams["stateSchema"] = this.buildAgentStateSchema(inputs); + } + if (context.middleware.length > 0) { + createAgentParams["middleware"] = context.middleware; + } + const reactAgent = createAgent( + createAgentParams as unknown as Parameters[0], + ); + applyPythonToolErrorSemantics(reactAgent); + return patchWithExecutionSpan(reactAgent, { + kind: "agent", + component: agent, + }); + } + + 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 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.createReactAgent(agent, context, { + extraLangGraphTools: (handoffs.get(agent.name) ?? []).map( + (toAgentName) => + swarmModule.createHandoffTool({ agentName: toAgentName }), + ), + }); + 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.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) => + this.convertWithContext(worker as unknown as ComponentBase, context), + }); + } + + /** + * 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 { + return compileFlow(flow, { + convertNode: async (node: Node) => + (await this.convertWithContext( + node as unknown as ComponentBase, + context, + )) as NodeExecutor, + checkpointer: context.checkpointer, + }); + } + + /** 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, + ); + assertInvocableGraph( + subflow, + "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, + ); + assertInvocableGraph( + subflow, + "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, + ); + assertInvocableGraph( + subflow, + "MapNodeExecutor can only be initialized with MapNode", + ); + return new MapNodeExecutor(node, subflow); + } + 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, + ): NodeExecutor { + const agentComponent = node.agent; + if (agentComponent.componentType === "ManagerWorkers") { + return new ManagerWorkersNodeExecutor( + node, + (renderedSystemPrompt: string) => + this.compileManagerWorkersGraph( + agentComponent, + context, + renderedSystemPrompt, + ), + context.config, + ); + } + // 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 => + 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 new file mode 100644 index 00000000..cf302b44 --- /dev/null +++ b/tsagentspec/src/adapters/langgraph/llm.ts @@ -0,0 +1,299 @@ +/** + * 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 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). + */ +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(); + 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(); +} + +/** 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. + * + * 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: LlmGenerationConfig; + retryConfig: ChatRetryConfig; + callbacks: BaseCallbackHandler[]; + baseUrl?: string; + apiKey?: string; +}): Promise { + 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 + // developer's environment credential. + const apiKey = + options.apiKey ?? (process.env["OPENAI_API_KEY"] || "EMPTY"); + return new ChatOpenAI({ + model: options.modelId, + useResponsesApi: options.useResponsesApi, + apiKey, + callbacks: options.callbacks, + 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 } } + : {}), + }); +} + +/** + * 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 (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, +): Promise { + // Only temperature / maxTokens / topP are supported; each use site reads + // 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": + return createChatOpenAiModel({ + modelId: llmConfig.modelId, + baseUrl: prepareOpenAiCompatibleUrl(llmConfig.url), + apiKey: llmConfig.apiKey, + useResponsesApi: llmConfig.apiType === OpenAIAPIType.RESPONSES, + generationConfig, + retryConfig: retryPolicyConvertToLanggraph(llmConfig.retryPolicy), + callbacks, + }); + 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", + "convert OllamaConfig LLM configs", + "remove them from the spec.", + ); + return new ChatOllama({ + baseUrl: llmConfig.url, + model: llmConfig.modelId, + callbacks, + 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, + retryConfig: retryPolicyConvertToLanggraph(llmConfig.retryPolicy), + callbacks, + }); + 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), + callbacks, + }); + } + 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.", + ); + 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..c80e207d --- /dev/null +++ b/tsagentspec/src/adapters/langgraph/manager-workers.ts @@ -0,0 +1,489 @@ +/** + * 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 } 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 { + DynamicStateGraph, + ExecuteOutput, + InvocableGraph, + 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; + }; +} + +/** + * 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 : "") || "", + }), + ], + }; + }; +} + +/** 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, { + kind: "manager-workers", + component: managerWorkers, + }); +} + +/** + * 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. + * + * 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 managerWorkers: ManagerWorkers; + + 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.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 ?? []; + 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 + * `agentsCache` 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.agentsCache.get(systemPrompt); + if (graph === undefined) { + graph = await this.compileAgent(systemPrompt); + this.agentsCache.set(systemPrompt, graph); + } + return graph; + } + + protected override async prepareAgentAndInputs( + inputs: NodeOutputs, + messages: BaseMessage[], + ): 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); + 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]; + // 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..ef206741 --- /dev/null +++ b/tsagentspec/src/adapters/langgraph/mcp.ts @@ -0,0 +1,373 @@ +/** + * 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). + * - 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 { + 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"; + +/** + * 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. + * + * 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, +): 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; +} + +/** 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[], + 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 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). + const client = new MultiServerMCPClient({ + mcpServers: { [serverName]: connection }, + }); + 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); +} + +/** 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..319e0e55 --- /dev/null +++ b/tsagentspec/src/adapters/langgraph/node-execution.ts @@ -0,0 +1,33 @@ +/** + * 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. + * + * 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..76632be5 --- /dev/null +++ b/tsagentspec/src/adapters/langgraph/node-execution/agent-node.ts @@ -0,0 +1,151 @@ +/** + * 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 { 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 + * 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 (isRecordLike(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. + * + * `_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 { + /** 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. */ + protected 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 { + const agentComponent = this.node.agent; + if (agentComponent.componentType !== "Agent") { + throw new Error( + "AgentNodeExecutor can only be used with AgentSpecAgent agents", + ); + } + const systemPrompt = renderTemplate(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: "" }]; + } + + /** 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 ?? []; + 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, 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/api-node.ts b/tsagentspec/src/adapters/langgraph/node-execution/api-node.ts new file mode 100644 index 00000000..9cde4010 --- /dev/null +++ b/tsagentspec/src/adapters/langgraph/node-execution/api-node.ts @@ -0,0 +1,97 @@ +/** + * 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): + * - `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 + * (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, + isRecordLike, + maybeWarnAboutUnrestrictedTemplatedUrl, + raiseForStatusWhenPolicySet, + requestWithRetry, +} 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); + // A configured allow list suppresses the templated-destination warning, + // exactly like Python. + maybeWarnAboutUnrestrictedTemplatedUrl( + node.url, + node.urlAllowList, + `ApiNode \`${node.name}\``, + ); + } + + protected async _execute( + 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. + 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 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}\``, + ); + // 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/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..d62c8eb6 --- /dev/null +++ b/tsagentspec/src/adapters/langgraph/node-execution/executor.ts @@ -0,0 +1,184 @@ +/** + * 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. + * - 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, 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, + 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; + /** The node's Agent Spec type (Python's `type(node).__name__`), used in span names. */ + componentType: string; + inputs?: Property[]; + outputs?: Property[]; +} + +/** + * 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), + * 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 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. */ + 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 = isRecordLike(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 = isRecordLike(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..87951d5d --- /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 { isRecordLike, renderTemplate } from "../../common/index.js"; +import type { ExecuteOutput, NodeOutputs } from "../types.js"; +import { NodeExecutor } 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 (!isRecordLike(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..7b72d0d7 --- /dev/null +++ b/tsagentspec/src/adapters/langgraph/node-execution/subflow-nodes.ts @@ -0,0 +1,250 @@ +/** + * 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. + */ +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 { + exceptionRaisedFromError, + getCurrentSpan, +} from "../../../tracing/index.js"; +import { isRecordLike, stringifyTemplateValue } from "../../common/index.js"; +import type { + ExecuteOutput, + InvocableGraph, + NodeExecutionDetails, + NodeOutputs, +} from "../types.js"; +import { NodeExecutor } 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: InvocableGraph, config: RunnableConfig) { + super(node); + this.subflow = subflow; + 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: InvocableGraph, + config: RunnableConfig, + ) { + super(node); + this.subflow = subflow; + 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 = isRecordLike(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) { + // 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) ?? []; + 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: 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; + } + + /** 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 (isRecordLike(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..eb0d43f9 --- /dev/null +++ b/tsagentspec/src/adapters/langgraph/node-execution/tool-node.ts @@ -0,0 +1,166 @@ +/** + * 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 { 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 { + // 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 (!isRecordLike(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 ( + isRecordLike(toolOutput) && + Object.keys(toolOutput).length === 1 && + Object.hasOwn(toolOutput, onlyTitle) + ) { + mapped = toolOutput; + } else { + mapped = { [onlyTitle]: toolOutput }; + } + } else if (isRecordLike(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/src/adapters/langgraph/tools.ts b/tsagentspec/src/adapters/langgraph/tools.ts new file mode 100644 index 00000000..84c5243d --- /dev/null +++ b/tsagentspec/src/adapters/langgraph/tools.ts @@ -0,0 +1,400 @@ +/** + * 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`. + * - 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"; +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, + 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; + +/** + * 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 (!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}.`, + ); + } + 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 ( + !isRecordLike(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 = isRecordLike(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 toolWithTracingCallback( + wrapped as (input: unknown) => unknown, + { + name: registeredTool.name, + description: registeredTool.description, + schema: registeredTool.schema, + }, + agentspecServerTool, + ); + } + if (typeof toolObj === "function") { + const toolInputs = agentspecServerTool.inputs ?? []; + const wrapped = confirmThen(toolObj, toolName, requiresConfirmation); + const withDefaults: ToolFunction = (input, config) => + wrapped( + isRecordLike(input) ? applyInputDefaults(input, toolInputs) : input, + config, + ); + 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}. ` + + `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 requestClientTool: ToolFunction = (kwargs) => { + const toolRequest = { + type: "client_tool_request", + name: toolName, + description: toolDescription, + inputs: { + args: [] as unknown[], + 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, { + 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( + // `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(isRecordLike(input) ? input : {}, toolInputs), + config, + ); + 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 new file mode 100644 index 00000000..c697e9c5 --- /dev/null +++ b/tsagentspec/src/adapters/langgraph/tracing.ts @@ -0,0 +1,830 @@ +/** + * Tracing for the LangGraph adapter. + * + * 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. + * + * 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 { + forkChildContext, + runInChildContext, +} from "../../tracing/context.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); +} + +/** + * Normalize LangChain callback tool inputs into the mapping expected by trace + * events (port of Python's `_normalize_tool_inputs`). + * + * 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. + */ +export type ExecutionSpanTarget = + | { kind: "agent"; component: Agent } + | { 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 : {}; +} + +/** + * 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" + ); +} + +/** + * 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`. + * + * 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. + * + * 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, + target: ExecutionSpanTarget, +): T { + 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) => + (...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]); + // 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) => trace(iterable), + async (error: unknown) => { + await enterContext(() => traceFailedStreamStart(inputs)); + throw error; + }, + ); + } + return trace(out as AsyncIterable); + }; + + // 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/src/adapters/langgraph/types.ts b/tsagentspec/src/adapters/langgraph/types.ts new file mode 100644 index 00000000..d51dbeb7 --- /dev/null +++ b/tsagentspec/src/adapters/langgraph/types.ts @@ -0,0 +1,92 @@ +/** + * 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 { StructuredToolInterface } from "@langchain/core/tools"; +import type { BaseCheckpointSaver, Send } 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]; + +/** + * 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< + string, + StructuredToolInterface | ToolImplementation +>; + +/** 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/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..c24ee1a6 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, @@ -310,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/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/property.ts b/tsagentspec/src/property.ts index f2694203..e8aac1ad 100644 --- a/tsagentspec/src/property.ts +++ b/tsagentspec/src/property.ts @@ -248,15 +248,19 @@ 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[] { 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[]) ?? []), @@ -276,10 +280,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 { @@ -322,12 +334,17 @@ 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/src/retry-policy.ts b/tsagentspec/src/retry-policy.ts new file mode 100644 index 00000000..2068308b --- /dev/null +++ b/tsagentspec/src/retry-policy.ts @@ -0,0 +1,113 @@ +/** + * 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). + * + * 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. */ + 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..cb6a6ebb 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,13 @@ 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>>; /** 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..6c9c8d22 100644 --- a/tsagentspec/src/serialization/builtin-deserialization-plugin.ts +++ b/tsagentspec/src/serialization/builtin-deserialization-plugin.ts @@ -32,8 +32,16 @@ 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 + "sessionParameters", // SessionParameters +]); /** 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..98d5fbf3 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,29 @@ 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 + sessionParameters: { excludeNulls: false }, // SessionParameters }; function hasSerializedSensitiveValue(value: unknown): boolean { @@ -85,9 +103,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/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/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/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/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/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..1888654d --- /dev/null +++ b/tsagentspec/src/tracing/context.ts @@ -0,0 +1,105 @@ +/** + * 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 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 + * 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 { + return forkChildContext()(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/adapters/common/component-policy.test.ts b/tsagentspec/tests/adapters/common/component-policy.test.ts new file mode 100644 index 00000000..d18f7e97 --- /dev/null +++ b/tsagentspec/tests/adapters/common/component-policy.test.ts @@ -0,0 +1,230 @@ +/** + * 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 { z } from "zod"; +import { + BUILTIN_SCHEMA_MAP, + createAgent, + createMCPTool, + createStdioTransport, + createVllmConfig, +} from "../../../src/index.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 { + 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("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(); + 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..f86e82af --- /dev/null +++ b/tsagentspec/tests/adapters/common/json-schema.test.ts @@ -0,0 +1,243 @@ +/** + * Tests for the shared JSON-schema helpers. + * + * `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). + */ +import { describe, expect, it } from "vitest"; +import type { JsonSchemaValue } from "../../../src/index.js"; +import { integerProperty, stringProperty } from "../../../src/index.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", () => { + 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("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( + { 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/tools-common.test.ts b/tsagentspec/tests/adapters/common/tools-common.test.ts new file mode 100644 index 00000000..c9c50852 --- /dev/null +++ b/tsagentspec/tests/adapters/common/tools-common.test.ts @@ -0,0 +1,488 @@ +/** + * 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 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) 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 { 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"; + +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); + }); +}); + +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(); + }); + + // The malformed-header edges below deliberately diverge from Python — see + // the getRetryAfterSeconds docstring and the adapter README. + + 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. + // 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)", () => { + 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", () => { + 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); + }); +}); + +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/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-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 new file mode 100644 index 00000000..a6b2259d --- /dev/null +++ b/tsagentspec/tests/adapters/langgraph/exporter.test.ts @@ -0,0 +1,699 @@ +/** + * 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, shared-object + * memoization, disaggregated exports and the documented TS-only rejections + * (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 { 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 { + OpenAIAPIType, + createOpenAiCompatibleConfig, + createServerTool, + stringProperty, +} from "../../../src/index.js"; +import type { + Agent, + 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") }), + }); +} + +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("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({ + 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: 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/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..5afb3bd3 --- /dev/null +++ b/tsagentspec/tests/adapters/langgraph/flow-nodes/api-node.test.ts @@ -0,0 +1,444 @@ +/** + * ApiNode flow execution tests for the LangGraph adapter. + * + * 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 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 { + 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("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" }); + 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 new file mode 100644 index 00000000..68cd2f79 --- /dev/null +++ b/tsagentspec/tests/adapters/langgraph/flow-state.test.ts @@ -0,0 +1,319 @@ +/** + * 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 { + createFlow, + createServerTool, + createToolNode, + integerProperty, + numberProperty, + stringProperty, + type Flow, + type Property, +} from "../../../src/index.js"; +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 { + 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, { + toolRegistry: { 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, { + 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 }); + }); + + 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, { + 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 } }); + // 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..9ebfe0c4 --- /dev/null +++ b/tsagentspec/tests/adapters/langgraph/llm.test.ts @@ -0,0 +1,552 @@ +/** + * 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, 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 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"; +import { ChatOllama } from "@langchain/ollama"; +import { ChatOpenAI } from "@langchain/openai"; +import { + OpenAIAPIType, + createLlmConfig, + createOciClientConfigWithApiKey, + createOciGenAiConfig, + createOllamaConfig, + createOpenAiCompatibleConfig, + createOpenAiConfig, + createVllmConfig, + type LlmConfig, + type LlmGenerationConfig, +} from "../../../src/index.js"; +import { + convertLlmConfig, + 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 }; + /** 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 { + 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("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(); + }); + + 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 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({ + 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..53fe74e7 --- /dev/null +++ b/tsagentspec/tests/adapters/langgraph/loader-agent.test.ts @@ -0,0 +1,636 @@ +/** + * 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 } 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, + messagesOf, + 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.`; +} + +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..5df295d6 --- /dev/null +++ b/tsagentspec/tests/adapters/langgraph/manager-workers.test.ts @@ -0,0 +1,731 @@ +/** + * 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, + messagesOf, + 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 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..47fb2786 --- /dev/null +++ b/tsagentspec/tests/adapters/langgraph/mcp.test.ts @@ -0,0 +1,557 @@ +/** + * 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 { + AgentSpecDeserializer, + AgentSpecSerializer, + createMCPTool, + createMCPToolBox, + createMCPToolSpec, + createOAuthClientConfig, + createOAuthConfig, + createRemoteTransport, + createSSETransport, + createSSEmTLSTransport, + createStdioTransport, + createStreamableHTTPTransport, + createStreamableHTTPmTLSTransport, + integerProperty, + stringProperty, + type ClientTransport, + type SSETransport, +} 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("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(); + 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..98044da4 --- /dev/null +++ b/tsagentspec/tests/adapters/langgraph/remote-tools.test.ts @@ -0,0 +1,985 @@ +/** + * RemoteTool / ClientTool conversion tests for the LangGraph adapter. + * + * Mirrors the RemoteTool sections of + * `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): + * - 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, vi } 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; + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +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 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"}', { + 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; 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( + "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("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({ + 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..5275de93 --- /dev/null +++ b/tsagentspec/tests/adapters/langgraph/test-helpers.ts @@ -0,0 +1,466 @@ +/** + * 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. + * - 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 { + 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, 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, +} 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 } : {}) }, + ], + }, + }); +} + +/** 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; +} diff --git a/tsagentspec/tests/adapters/langgraph/tracing.test.ts b/tsagentspec/tests/adapters/langgraph/tracing.test.ts new file mode 100644 index 00000000..f3ea889c --- /dev/null +++ b/tsagentspec/tests/adapters/langgraph/tracing.test.ts @@ -0,0 +1,1185 @@ +/** + * 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, + patchWithExecutionSpan, +} 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", + ]); + }); +}); + +/** + * 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]); + } + }); +}); 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..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", () => { @@ -52,6 +54,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 +129,50 @@ 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); + }); +}); + +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); + }); }); 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", () => { 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": [] }); + }); }); 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(); + }); +}); 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, 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"],