diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0046ca..347a2e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,6 +107,8 @@ jobs: path: runtimes/pydantic-ai - package: microsoft-agent-framework adapter path: runtimes/microsoft-agent-framework + - package: langgraph adapter + path: runtimes/langgraph steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -212,19 +214,21 @@ jobs: make build-agentkit TAG=ci make build-serve TAG=ci make build-serve-maf TAG=ci + make build-serve-langgraph TAG=ci - - name: Build test agent images for both runtimes + - name: Build test agent images for all runtimes run: | set -euxo pipefail make build-test-agent TAG=ci BUILDER= make build-test-agent TAG=ci BUILDER= RUNTIME=maf + make build-test-agent TAG=ci BUILDER= RUNTIME=langgraph - name: Smoke test built agent images run: | set -euo pipefail cleanup() { - docker rm -f agentkit-smoke-pydantic agentkit-smoke-maf >/dev/null 2>&1 || true + docker rm -f agentkit-smoke-pydantic agentkit-smoke-maf agentkit-smoke-langgraph >/dev/null 2>&1 || true } trap cleanup EXIT @@ -256,6 +260,7 @@ jobs: smoke agentkit-smoke-pydantic hello-agent:ci 18080 smoke agentkit-smoke-maf maf-agent:ci 18081 + smoke agentkit-smoke-langgraph langgraph-agent:ci 18082 live-copilot-e2e: name: Live Vekil/Copilot E2E diff --git a/Makefile b/Makefile index b07b0ac..7f55e4d 100644 --- a/Makefile +++ b/Makefile @@ -35,17 +35,22 @@ endif PLATFORM ?= linux/amd64 # RUNTIME selects which runtime adapter the test-agent targets: `pydantic-ai` -# (default) or the Microsoft Agent Framework, named either `maf` (alias) or -# `microsoft-agent-framework` (canonical) — both are accepted. build-test-agent -# derives the adapter image, the fixture, and the output tag from it, so you can -# build the SAME logical agent under either runtime (the §10.4 equivalence proof): -# make build-serve build-test-agent # pydantic-ai → hello-agent -# make build-serve-maf build-test-agent RUNTIME=maf # MAF → maf-agent +# (default), Microsoft Agent Framework (`maf` alias or canonical name), or +# LangGraph (`langgraph`). build-test-agent derives the adapter image, fixture, +# and output tag from it, so you can build the SAME logical agent under any +# supported runtime (the §10.4 equivalence proof): +# make build-serve build-test-agent # pydantic-ai → hello-agent +# make build-serve-maf build-test-agent RUNTIME=maf # MAF → maf-agent +# make build-serve-langgraph build-test-agent RUNTIME=langgraph # LangGraph → langgraph-agent RUNTIME ?= pydantic-ai -# Per-runtime adapter image, fixture, and output tag (overridable). The MAF branch -# matches BOTH spellings via $(filter ...) so the canonical name does not silently -# fall through to the pydantic-ai default. -ifneq ($(filter maf microsoft-agent-framework,$(RUNTIME)),) +# Per-runtime adapter image, fixture, and output tag (overridable). Branches +# match all accepted spellings so a canonical name does not silently fall through +# to the pydantic-ai default. +ifneq ($(filter langgraph,$(RUNTIME)),) +SERVE_IMAGE ?= agentkit-serve-langgraph:$(TAG) +FIXTURE ?= test/agentkitfile-langgraph-hello.yaml +AGENT_IMAGE ?= langgraph-agent:$(TAG) +else ifneq ($(filter maf microsoft-agent-framework,$(RUNTIME)),) SERVE_IMAGE ?= agentkit-serve-maf:$(TAG) FIXTURE ?= test/agentkitfile-maf-hello.yaml AGENT_IMAGE ?= maf-agent:$(TAG) @@ -92,9 +97,16 @@ build-serve: build-serve-maf: docker buildx build . -f runtimes/microsoft-agent-framework/Dockerfile -t agentkit-serve-maf:$(TAG) --load +# Build the LangGraph runtime adapter (agentkit-serve-langgraph) image. +# This is the LLB base used when an agentkitfile selects `runtime: langgraph`. +.PHONY: build-serve-langgraph +build-serve-langgraph: + docker buildx build . -f runtimes/langgraph/Dockerfile -t agentkit-serve-langgraph:$(TAG) --load + # Build a test agent against the LOCAL frontend (BUILDKIT_SYNTAX) and the LOCAL # adapter (--build-arg adapter). The runtime, fixture, adapter image, and output -# tag all derive from RUNTIME (default pydantic-ai; `RUNTIME=maf` for MAF). +# tag all derive from RUNTIME (default pydantic-ai; `RUNTIME=maf` for MAF; +# `RUNTIME=langgraph` for LangGraph). # --provenance=false keeps the output a plain single-platform image for --load. .PHONY: build-test-agent build-test-agent: diff --git a/README.md b/README.md index 0ba0336..2b76152 100644 --- a/README.md +++ b/README.md @@ -58,15 +58,18 @@ the optional `runtime:` key: |---|---|---| | *(omitted)* / `pydantic-ai` | `agentkit-serve` | [pydantic-ai](https://ai.pydantic.dev) (default) | | `microsoft-agent-framework` (alias `maf`) | `agentkit-serve-maf` | [Microsoft Agent Framework](https://github.com/microsoft/agent-framework) | +| `langgraph` | `agentkit-serve-langgraph` | [LangChain/LangGraph](https://docs.langchain.com/oss/python/langgraph/overview) | ```yaml -runtime: microsoft-agent-framework # or: maf +runtime: langgraph +# or: runtime: microsoft-agent-framework # alias: maf ``` -Both runtimes consume the **same** baked `/agent/agent.yaml` and serve the +All runtimes consume the **same** baked `/agent/agent.yaml` and serve the **same** non-streaming OpenAI `/v1` façade with the same guards — so the same -agentkitfile produces a behavior-compatible image under either. Only the in-image -runtime adapter differs. The `AGENTKIT_MCP_TIMEOUT` knob applies to both. +agentkitfile produces a behavior-compatible image under any supported runtime. +Only the in-image runtime adapter differs. The `AGENTKIT_MCP_TIMEOUT` knob +applies to all runtimes. ## Local dev loop (3 steps) @@ -92,16 +95,27 @@ make build-serve-maf # agentkit-serve-maf:test make build-test-agent RUNTIME=maf # test/agentkitfile-maf-hello.yaml -> maf-agent:test ``` +To iterate on the **LangGraph** runtime, build its adapter and target it with +`RUNTIME=langgraph`: + +```sh +make build-serve-langgraph # agentkit-serve-langgraph:test +make build-test-agent RUNTIME=langgraph # test/agentkitfile-langgraph-hello.yaml -> langgraph-agent:test +``` + ## CI GitHub Actions runs the full closeout loop on pushes and pull requests: - Go lint, formatting, vet, race tests, and frontend build. - Python compile, pytest, and wheel checks for `runtimes/common/`, - `runtimes/pydantic-ai/`, and `runtimes/microsoft-agent-framework/`. -- Docker builds for the frontend and both runtime adapters, followed by offline - `/healthz` smoke tests for generated pydantic-ai and MAF agent images. -- Optional live Vekil-backed Copilot E2E, using the official pinned `ghcr.io/sozercan/vekil` image and a repository secret named + `runtimes/pydantic-ai/`, `runtimes/microsoft-agent-framework/`, and + `runtimes/langgraph/`. +- Docker builds for the frontend and all three runtime adapters, followed by + offline `/healthz` smoke tests for generated pydantic-ai, MAF, and LangGraph + agent images. +- Optional live Vekil-backed Copilot E2E, using the official pinned + `ghcr.io/sozercan/vekil` image and a repository secret named `COPILOT_GITHUB_TOKEN`. If that secret is unavailable (for example on forks or unconfigured repos), or Vekil reports that the token lacks Copilot access/ permissions, the live job is skipped while the offline checks still run. @@ -142,11 +156,12 @@ Each adapter is a thin shell over a shared core: ABI loader, the OpenAI `/v1` façade, the CLI/network posture, and the neutral run contract (`RunResult`, `AgentRunError`, `RuntimeSession`, the `RuntimeFactory` protocol). Imports no agent framework. -- `runtimes/pydantic-ai/`, `runtimes/microsoft-agent-framework/` — each ships - only an `agent_factory.py` (the one file that imports its framework) plus a - thin `__main__.py`, implementing `RuntimeFactory` / `RuntimeSession`. They stay - **separate images** with disjoint framework deps; that physical separation is - what guarantees the lock-in boundary. +- `runtimes/pydantic-ai/`, `runtimes/microsoft-agent-framework/`, + `runtimes/langgraph/` — each ships only an `agent_factory.py` (the one file + that imports its framework) plus a thin `__main__.py`, implementing + `RuntimeFactory` / `RuntimeSession`. They stay **separate images** with + disjoint framework deps; that physical separation is what guarantees the + lock-in boundary. Adding a single-agent runtime is therefore one `agent_factory.py` + one Go `runtimes.RuntimeSpec` entry; it inherits the shared `/v1` façade and the conformance @@ -154,9 +169,9 @@ test suite for free. ## v0 scope / not yet -- **v0**: two runtimes (pydantic-ai default + microsoft-agent-framework), - `provider: openai-compatible` only, stdio `command` MCP tools, the OpenAI `/v1` - façade, single OCI image output. +- **v0**: three runtimes (pydantic-ai default + microsoft-agent-framework + + langgraph), `provider: openai-compatible` only, stdio `command` MCP tools, the + OpenAI `/v1` façade, single OCI image output. - **Not yet**: image-based MCP tools, evals, lock file / SBOM / signing, agentpack, `extends`/patches, knowledge/RAG, memory/state, model fallback, streaming, and embedded/BYO serving targets. diff --git a/pkg/agentkit/config/config_test.go b/pkg/agentkit/config/config_test.go index c57d1a9..65fb4c0 100644 --- a/pkg/agentkit/config/config_test.go +++ b/pkg/agentkit/config/config_test.go @@ -245,10 +245,10 @@ expose: } // TestValidateAcceptsRegisteredRuntimes proves the widened runtime gate (plan §8): -// the canonical MAF name, its "maf" alias, the default runtime, and an omitted -// runtime all validate. +// the canonical MAF name, its "maf" alias, LangGraph, the default runtime, and +// an omitted runtime all validate. func TestValidateAcceptsRegisteredRuntimes(t *testing.T) { - for _, rt := range []string{"", "pydantic-ai", "microsoft-agent-framework", "maf"} { + for _, rt := range []string{"", "pydantic-ai", "microsoft-agent-framework", "maf", "langgraph"} { cfg, err := NewFromBytes(agentBaseYAML(rt)) if err != nil { t.Fatalf("runtime %q: parse error: %v", rt, err) @@ -270,7 +270,9 @@ func TestValidateRejectsUnknownRuntime(t *testing.T) { if verr == nil || !strings.Contains(verr.Error(), "runtime") { t.Fatalf("expected unknown-runtime rejection, got: %v", verr) } - if !strings.Contains(verr.Error(), "microsoft-agent-framework") { - t.Errorf("error should list supported runtimes; got: %v", verr) + for _, want := range []string{"microsoft-agent-framework", "langgraph"} { + if !strings.Contains(verr.Error(), want) { + t.Errorf("error should list supported runtime %q; got: %v", want, verr) + } } } diff --git a/pkg/agentkit/runtimes/catalog.go b/pkg/agentkit/runtimes/catalog.go index 1572e51..ee50893 100644 --- a/pkg/agentkit/runtimes/catalog.go +++ b/pkg/agentkit/runtimes/catalog.go @@ -19,6 +19,9 @@ const ( // MAFAlias is a short, convenient alias for MAF that users may write in // `runtime:`; it resolves to MAF (see CanonicalRuntime). MAFAlias = "maf" + + // LangGraph is the LangChain/LangGraph runtime adapter. + LangGraph = "langgraph" ) // RuntimeSpec is the complete declaration of one runtime adapter. @@ -46,6 +49,10 @@ var Runtimes = []RuntimeSpec{ Aliases: []string{MAFAlias}, // "maf" → "microsoft-agent-framework" DefaultAdapterRef: "ghcr.io/sozercan/agentkit/serve-maf:latest", }, + { + Name: LangGraph, + DefaultAdapterRef: "ghcr.io/sozercan/agentkit/serve-langgraph:latest", + }, } // DefaultRuntime is the runtime used when an agentkitfile does not name one. It is diff --git a/pkg/agentkit/runtimes/catalog_test.go b/pkg/agentkit/runtimes/catalog_test.go index e0be1c8..71a5ef6 100644 --- a/pkg/agentkit/runtimes/catalog_test.go +++ b/pkg/agentkit/runtimes/catalog_test.go @@ -15,6 +15,7 @@ func TestCanonicalRuntime(t *testing.T) { PydanticAI: PydanticAI, // canonical → itself MAF: MAF, // canonical → itself MAFAlias: MAF, // alias → canonical + LangGraph: LangGraph, // canonical → itself nonexistentRuntime: nonexistentRuntime, // unknown returned verbatim } for in, want := range cases { @@ -25,7 +26,7 @@ func TestCanonicalRuntime(t *testing.T) { } func TestIsKnownRuntime(t *testing.T) { - for _, name := range []string{PydanticAI, MAF, MAFAlias} { + for _, name := range []string{PydanticAI, MAF, MAFAlias, LangGraph} { if !IsKnownRuntime(name) { t.Errorf("IsKnownRuntime(%q) = false, want true", name) } @@ -37,10 +38,10 @@ func TestIsKnownRuntime(t *testing.T) { } } -func TestKnownRuntimesContainsBoth(t *testing.T) { +func TestKnownRuntimesContainsAll(t *testing.T) { got := KnownRuntimes() sort.Strings(got) - want := []string{MAF, PydanticAI} + want := []string{LangGraph, MAF, PydanticAI} if len(got) != len(want) { t.Fatalf("KnownRuntimes() = %v, want %v", got, want) } diff --git a/pkg/build/router_test.go b/pkg/build/router_test.go index 0c925b4..a1e9298 100644 --- a/pkg/build/router_test.go +++ b/pkg/build/router_test.go @@ -3,11 +3,13 @@ package build import "testing" const ( - wantImageRoute = "pydantic-ai/image" - runtimePydca = "pydantic-ai" - runtimeMAFName = "microsoft-agent-framework" - runtimeMAFAls = "maf" - wantMAFRoute = "microsoft-agent-framework/image" + wantImageRoute = "pydantic-ai/image" + runtimePydca = "pydantic-ai" + runtimeMAFName = "microsoft-agent-framework" + runtimeMAFAls = "maf" + wantMAFRoute = "microsoft-agent-framework/image" + runtimeLangGraph = "langgraph" + wantLangGraphRoute = "langgraph/image" ) func TestLookupRouteEmptyTargetDefaults(t *testing.T) { @@ -43,6 +45,27 @@ func TestLookupRouteUnknownRuntime(t *testing.T) { } } +// TestLookupRouteLangGraph proves the LangGraph runtime resolves through the +// same data-derived router as pydantic-ai and MAF. +func TestLookupRouteLangGraph(t *testing.T) { + // empty target + LangGraph runtime → LangGraph image route. + matched, _, rc, ok := lookupRoute("", runtimeLangGraph) + if !ok || matched != wantLangGraphRoute { + t.Fatalf("LangGraph empty target: matched=%q ok=%v, want %s", matched, ok, wantLangGraphRoute) + } + if rc == nil || rc.Name != runtimeLangGraph { + t.Fatalf("rc = %+v, want langgraph", rc) + } + // exact target match. + if m, _, _, okExact := lookupRoute(wantLangGraphRoute, runtimeLangGraph); !okExact || m != wantLangGraphRoute { + t.Fatalf("LangGraph exact target: matched=%q ok=%v", m, okExact) + } + // bare runtime target. + if m, _, _, okBare := lookupRoute(runtimeLangGraph, runtimeLangGraph); !okBare || m != wantLangGraphRoute { + t.Fatalf("LangGraph bare target: matched=%q ok=%v", m, okBare) + } +} + // TestLookupRouteMAF proves the second runtime resolves through the SAME flat // router with zero handler changes (plan §8 — "the router already handles the // second runtime"). @@ -116,7 +139,7 @@ func TestLookupRouteAliasTargetEmptyRuntime(t *testing.T) { // TestIsRegisteredRuntime locks the validator's seam: every canonical runtime and // the alias are registered; an unknown name is not. func TestIsRegisteredRuntime(t *testing.T) { - for _, name := range []string{runtimePydca, runtimeMAFName, runtimeMAFAls} { + for _, name := range []string{runtimePydca, runtimeMAFName, runtimeMAFAls, runtimeLangGraph} { if !IsRegisteredRuntime(name) { t.Errorf("IsRegisteredRuntime(%q) = false, want true", name) } diff --git a/runtimes/catalog/langgraph.yaml b/runtimes/catalog/langgraph.yaml new file mode 100644 index 0000000..2b1652e --- /dev/null +++ b/runtimes/catalog/langgraph.yaml @@ -0,0 +1,13 @@ +# AgentKit runtime catalog entry — documentation only. +# +# Purpose: names the "langgraph" runtime adapter and its default adapter image. +# The converter uses this adapter image as the LLB BASE and merges the resolved +# /agent/agent.yaml layer on top; the image then serves the same OpenAI /v1 +# façade as the pydantic-ai and Microsoft Agent Framework runtimes — byte-for-byte +# ABI compatible. Select it from an agentkitfile with `runtime: langgraph`. +# Override the adapter per build with `--build-arg adapter=` (the local dev +# loop points it at `agentkit-serve-langgraph:test`). Nothing parses this file in +# v0 — it is the human-readable catalog entry for this runtime. +apiVersion: v1alpha1 +runtime: langgraph +adapter: ghcr.io/sozercan/agentkit/serve-langgraph:latest diff --git a/runtimes/common/agentkit_serve_common/adapter_support.py b/runtimes/common/agentkit_serve_common/adapter_support.py index bd81a1d..9b5e923 100644 --- a/runtimes/common/agentkit_serve_common/adapter_support.py +++ b/runtimes/common/agentkit_serve_common/adapter_support.py @@ -10,6 +10,7 @@ from __future__ import annotations import os +import re from .config import AgentSpec, ToolSpec from .conversation import FORWARDED_ROLES @@ -22,6 +23,7 @@ MCP_TIMEOUT_ENV = "AGENTKIT_MCP_TIMEOUT" +_BRACED_ENV_REF_RE = re.compile(r"\$\{([^}]+)\}") class AgentBuildError(Exception): @@ -55,8 +57,24 @@ def declared_tool_env(tool: ToolSpec) -> dict[str, str]: The MCP subprocess must never inherit the full container environment — that would bleed the model API key (and every other secret) into every tool. We pass through exactly the declared names that are actually present. + + Some MCP stdio transports expand braced references like ``${VAR}`` inside env + values against the parent process environment. Reject references to undeclared + vars so a declared tool env such as ``TOOL_CONFIG=${OPENAI_API_KEY}`` cannot + smuggle the model key into a subprocess unless that key was explicitly listed + in the tool's own ``env`` allowlist. """ - return {name: os.environ[name] for name in tool.env if name in os.environ} + allowed = set(tool.env) + out = {name: os.environ[name] for name in tool.env if name in os.environ} + for name, value in out.items(): + undeclared = sorted(ref for ref in _BRACED_ENV_REF_RE.findall(value) if ref not in allowed) + if undeclared: + raise AgentBuildError( + f"tool {tool.name!r} env var {name!r} references undeclared env var(s) " + f"{', '.join(undeclared)}; list every referenced env var in that tool's " + "env allowlist or remove the ${...} reference" + ) + return out def split_tool_command(tool: ToolSpec, *, example: str) -> tuple[str, list[str]]: diff --git a/runtimes/common/agentkit_serve_common/conformance.py b/runtimes/common/agentkit_serve_common/conformance.py index abd7830..4827ce0 100644 --- a/runtimes/common/agentkit_serve_common/conformance.py +++ b/runtimes/common/agentkit_serve_common/conformance.py @@ -43,7 +43,17 @@ # Framework/model-SDK roots that must NOT be imported outside an adapter's # agent_factory.py. The union across adapters is fine: each adapter only has one # of these installed, so listing all is harmless and keeps this test shared. -_FRAMEWORK_SDK_ROOTS = {"agent_framework", "pydantic_ai", "openai"} +_FRAMEWORK_SDK_ROOTS = { + "agent_framework", + "pydantic_ai", + "openai", + "langchain", + "langchain_core", + "langchain_mcp_adapters", + "langchain_openai", + "langgraph", + "mcp", +} def test_healthz_open(make_client): diff --git a/runtimes/common/tests/test_adapter_support.py b/runtimes/common/tests/test_adapter_support.py index 163577b..57cdb4b 100644 --- a/runtimes/common/tests/test_adapter_support.py +++ b/runtimes/common/tests/test_adapter_support.py @@ -64,6 +64,35 @@ def test_declared_tool_env_passes_only_declared_present_names(): assert env == {"FETCH_TOKEN": "tok"} +def test_declared_tool_env_rejects_interpolation_of_undeclared_env(): + tool = ToolSpec(name="fetch", command=["uvx", "mcp-server-fetch"], env=["FETCH_CONFIG"]) + with mock.patch.dict( + os.environ, + {"FETCH_CONFIG": "token=${OPENAI_API_KEY}", "OPENAI_API_KEY": "sk-should-not-leak"}, + clear=True, + ): + with pytest.raises(support.AgentBuildError) as exc: + support.declared_tool_env(tool) + msg = str(exc.value) + assert "OPENAI_API_KEY" in msg + assert "sk-should-not-leak" not in msg + + +def test_declared_tool_env_allows_interpolation_of_declared_env(): + tool = ToolSpec( + name="fetch", + command=["uvx", "mcp-server-fetch"], + env=["FETCH_CONFIG", "FETCH_TOKEN"], + ) + with mock.patch.dict( + os.environ, + {"FETCH_CONFIG": "token=${FETCH_TOKEN}", "FETCH_TOKEN": "tok"}, + clear=True, + ): + env = support.declared_tool_env(tool) + assert env == {"FETCH_CONFIG": "token=${FETCH_TOKEN}", "FETCH_TOKEN": "tok"} + + def test_split_tool_command_returns_executable_and_args(): tool = ToolSpec(name="fetch", command=["uvx", "mcp-server-fetch", "--flag"], env=[]) assert support.split_tool_command(tool, example='["uvx", "mcp-server-fetch"]') == ( diff --git a/runtimes/langgraph/Dockerfile b/runtimes/langgraph/Dockerfile new file mode 100644 index 0000000..d674661 --- /dev/null +++ b/runtimes/langgraph/Dockerfile @@ -0,0 +1,76 @@ +# syntax=docker/dockerfile:1 +# +# agentkit-serve-langgraph ADAPTER image (LangChain/LangGraph runtime). +# +# This is the runtime adapter the AgentKit Go converter uses as the LLB BASE +# image when an agentkitfile selects `runtime: langgraph`: the converter pulls +# this image and merges the resolved /agent/agent.yaml layer on top. It is also +# runnable standalone (ENTRYPOINT + CMD below). +# +# It bundles BOTH a Python runtime (for the LangGraph agent) AND Node.js + npx and +# uv/uvx, so stdio MCP servers distributed on npm (`npx -y ...`) or PyPI +# (`uvx ...`) can be spawned as tool subprocesses. +# +# No secret is ever baked in: agent.yaml carries env var NAMES only; values are +# injected at runtime via `docker run -e`. +# +# Dependency lock-in boundary: the installed Python deps are LangChain/LangGraph, +# langchain-openai, langchain-mcp-adapters, and the MCP SDK only — NEVER an +# azure/foundry package. Foundry protocol serving belongs in a separate adapter. + +FROM python:3.12-slim-bookworm + +# --- system deps: Node.js + npx (for npm-distributed MCP servers) ------------ +# ca-certificates for TLS to model endpoints; nodejs/npm provide `npx`. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + nodejs \ + npm \ + && rm -rf /var/lib/apt/lists/* + +# --- uv / uvx (for PyPI-distributed MCP servers via `uvx ...`) --------------- +# Copy the static uv + uvx binaries from the official image onto PATH. +COPY --from=ghcr.io/astral-sh/uv:0.11.24@sha256:99ea34acedc870ba4ad11a1f540a1c04267c9f30aadc465a94406f52dfda2c36 /uv /uvx /usr/local/bin/ + +# AgentKit layout constants (must match pkg/utils/const.go): +# AgentKitRoot = /opt/agentkit ; ServeBinary = /opt/agentkit/bin/agentkit-serve +# AgentConfigPath = /agent/agent.yaml +ENV AGENTKIT_ROOT=/opt/agentkit \ + PATH=/opt/agentkit/bin:$PATH \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +# --- install agentkit-serve-langgraph into a dedicated venv at /opt/agentkit -- +# The console script lands at /opt/agentkit/bin/agentkit-serve (== ServeBinary). +# Build context is the REPO ROOT (see Makefile `build-serve-langgraph`), so paths +# are repo-root-relative. The shared framework-neutral core is installed FIRST; +# then the adapter adds LangChain/LangGraph/OpenAI/MCP deps. This image stays +# separate from the pydantic-ai and MAF images — disjoint framework deps, same +# shared core and ABI. +WORKDIR /src +COPY runtimes/common/pyproject.toml runtimes/common/README.md ./common/ +COPY runtimes/common/agentkit_serve_common ./common/agentkit_serve_common +COPY runtimes/langgraph/pyproject.toml runtimes/langgraph/README.md ./langgraph/ +COPY runtimes/langgraph/agentkit_serve ./langgraph/agentkit_serve + +RUN python -m venv /opt/agentkit \ + && /opt/agentkit/bin/pip install --upgrade pip \ + && /opt/agentkit/bin/pip install ./common \ + && /opt/agentkit/bin/pip install ./langgraph \ + && { /opt/agentkit/bin/agentkit-serve --help >/dev/null 2>&1 || true; } + +# --- non-root user + agent config mount point -------------------------------- +# uid 1000 owns /agent so a writer/runtime can place agent.yaml there. +RUN useradd --uid 1000 --create-home --shell /usr/sbin/nologin agentkit \ + && mkdir -p /agent \ + && chown -R 1000:1000 /agent /opt/agentkit + +# Loopback-only by default; binding 0.0.0.0 requires AGENTKIT_AUTH_TOKEN (§10). +ENV AGENTKIT_BIND=127.0.0.1 +EXPOSE 8080 + +USER 1000 + +ENTRYPOINT ["/opt/agentkit/bin/agentkit-serve"] +CMD ["--config", "/agent/agent.yaml"] diff --git a/runtimes/langgraph/README.md b/runtimes/langgraph/README.md new file mode 100644 index 0000000..f1d7fdf --- /dev/null +++ b/runtimes/langgraph/README.md @@ -0,0 +1,93 @@ +# AgentKit LangGraph runtime adapter + +`runtimes/langgraph` is the generic LangChain/LangGraph AgentKit runtime. It +consumes the same frozen `/agent/agent.yaml` ABI as the pydantic-ai and Microsoft +Agent Framework adapters, and serves the same non-streaming OpenAI-compatible +surface: + +- `GET /healthz` +- `GET /v1/models` +- `POST /v1/chat/completions` + +## Support level + +`runtime: langgraph` supports AgentKit-authored single-agent LangGraph agents +generated from the AgentKit ABI: + +- `model.provider: openai-compatible` via `langchain_openai.ChatOpenAI` +- `instructions` as the graph `system_prompt` +- stdio MCP `tools` loaded with `langchain-mcp-adapters` +- one final collapsed assistant message through AgentKit's `/v1` façade + +Arbitrary user-authored LangGraph modules, checkpointing, streaming, multi-node +graph authoring, and Microsoft Foundry `/responses` or `/invocations` protocol +serving are intentionally out of scope for this generic adapter. + +## Dependency boundary + +This adapter depends on LangChain/LangGraph/OpenAI/MCP packages only: + +- `langchain` +- `langgraph` +- `langchain-openai` +- `langchain-mcp-adapters` +- `mcp` +- `agentkit-serve-common` + +It must not import Azure or Foundry hosting packages such as +`langchain_azure_ai` or `azure.*`. A future Foundry-native mode should be a +separate adapter/target so the generic LangGraph runtime stays cloud-neutral. + +## Tool lifecycle and secret hygiene + +Each `tools:` entry is treated as one stdio MCP server. The adapter creates a +persistent `MultiServerMCPClient` session for each server during FastAPI lifespan +startup, initializes it with `AGENTKIT_MCP_TIMEOUT` (default `120` seconds), loads +LangChain tools with `tool_name_prefix=True`, and closes sessions at shutdown. + +Tool subprocess env is declared-only. If a tool declares: + +```yaml +tools: + - name: fetch + command: ["uvx", "mcp-server-fetch"] + env: ["FETCH_TIMEOUT"] +``` + +then only `FETCH_TIMEOUT` is passed to that subprocess when it is present in the +container env. Model API keys and other process env vars are not inherited unless +explicitly declared on that tool. + +## Build and test + +From the repo root: + +```sh +cd runtimes/langgraph +python3.12 -m venv .venv +. .venv/bin/activate +pip install -e ../common -e '.[dev]' +pytest -q +``` + +Build the adapter image: + +```sh +make build-serve-langgraph +``` + +Build a test AgentKit image with the LangGraph runtime: + +```sh +make build-agentkit +make build-test-agent RUNTIME=langgraph +``` + +Run it (requires the model API key named by the fixture): + +```sh +docker run --rm --platform linux/amd64 \ + -p 127.0.0.1:8080:8080 \ + -e OPENAI_API_KEY="$OPENAI_API_KEY" \ + langgraph-agent:test +``` diff --git a/runtimes/langgraph/agentkit_serve/__init__.py b/runtimes/langgraph/agentkit_serve/__init__.py new file mode 100644 index 0000000..daafa14 --- /dev/null +++ b/runtimes/langgraph/agentkit_serve/__init__.py @@ -0,0 +1 @@ +"""AgentKit LangGraph runtime adapter package.""" diff --git a/runtimes/langgraph/agentkit_serve/__main__.py b/runtimes/langgraph/agentkit_serve/__main__.py new file mode 100644 index 0000000..91a724c --- /dev/null +++ b/runtimes/langgraph/agentkit_serve/__main__.py @@ -0,0 +1,20 @@ +"""``agentkit-serve`` CLI entrypoint for the LangGraph adapter. + +The CLI logic and network posture live in ``agentkit_serve_common.cli``; this thin +binding injects THIS adapter's framework-specific ``agent_factory`` module (which +satisfies the ``RuntimeFactory`` protocol). +""" + +from __future__ import annotations + +from agentkit_serve_common.cli import run + +from . import agent_factory + + +def main(argv: list[str] | None = None) -> None: + run(agent_factory, argv) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/runtimes/langgraph/agentkit_serve/agent_factory.py b/runtimes/langgraph/agentkit_serve/agent_factory.py new file mode 100644 index 0000000..9030c7b --- /dev/null +++ b/runtimes/langgraph/agentkit_serve/agent_factory.py @@ -0,0 +1,302 @@ +"""Build a LangChain/LangGraph agent from a validated :class:`AgentSpec`. + +This adapter keeps AgentKit's generic runtime boundary intact: it consumes the +same frozen ``/agent/agent.yaml`` ABI as the pydantic-ai and MAF adapters and +serves the same non-streaming OpenAI ``/v1/chat/completions`` façade through +``agentkit_serve_common``. LangGraph is used internally via LangChain's +``create_agent`` helper; arbitrary user-authored graphs and Foundry +``/responses``/``/invocations`` hosting are intentionally out of scope here. + +Verified during implementation against the installed package set: + +* ``langchain.agents.create_agent(model=..., tools=..., system_prompt=...)`` + returns a compiled LangGraph with ``ainvoke``. +* ``ChatOpenAI(model=..., base_url=..., api_key=...)`` is the generic + OpenAI-compatible chat model client. +* ``MultiServerMCPClient.session(server_name, auto_initialize=False)`` plus + ``load_mcp_tools(..., server_name=..., tool_name_prefix=True)`` keeps stdio MCP + sessions open for the server lifespan and namespaces tool names. + +THE LOCK-IN BOUNDARY: imports here are confined to LangChain/LangGraph/OpenAI/MCP +packages and ``agentkit_serve_common``. NEVER import Azure / Foundry hosting +packages from this generic runtime; a future Foundry mode should be a separate +adapter/target. +""" + +from __future__ import annotations + +import asyncio +from contextlib import AsyncExitStack +from datetime import timedelta +from types import TracebackType +from typing import Any + +from langchain.agents import create_agent +from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage +from langchain_mcp_adapters.client import MultiServerMCPClient +from langchain_mcp_adapters.tools import load_mcp_tools +from langchain_openai import ChatOpenAI + +from agentkit_serve_common.adapter_support import ( + FORWARDED_ROLES, + AgentBuildError, + declared_tool_env, + normalize_agent_run_error, + positive_float_env, + resolve_api_key, + split_tool_command, + upstream_status_code, +) +from agentkit_serve_common.config import AgentSpec, ToolSpec +from agentkit_serve_common.conversation import RunRequest +from agentkit_serve_common.runtime import AgentRunError, RunResult, RuntimeSession + +# Seconds to wait for a stdio MCP server's initialize handshake. A cold `uvx` or +# `npx` tool may download/install before speaking MCP, so match pydantic-ai's +# generous default and let operators tune via env. +_DEFAULT_MCP_INIT_TIMEOUT = 120.0 + + +def _mcp_init_timeout() -> float: + """MCP stdio init timeout (seconds), overridable via AGENTKIT_MCP_TIMEOUT.""" + return positive_float_env(default=_DEFAULT_MCP_INIT_TIMEOUT) + + +def _resolve_api_key(spec: AgentSpec) -> str: + """Compatibility wrapper over the shared API-key resolver.""" + return resolve_api_key(spec) + + +def build_model(spec: AgentSpec) -> ChatOpenAI: + """Construct the OpenAI-compatible chat model pointed at ``model.baseURL``.""" + return ChatOpenAI( + model=spec.model.name, + base_url=spec.model.base_url, + api_key=_resolve_api_key(spec), + ) + + +def _tool_env(tool: ToolSpec) -> dict[str, str]: + """Compatibility wrapper over the shared declared-only tool env helper.""" + return declared_tool_env(tool) + + +def build_mcp_connection(tool: ToolSpec) -> dict[str, Any]: + """Convert an AgentKit stdio tool declaration into a LangChain MCP connection.""" + command, args = split_tool_command(tool, example='["uvx", "mcp-server-fetch"]') + timeout = _mcp_init_timeout() + return { + "transport": "stdio", + "command": command, + "args": args, + # Declared-only env, even when empty; never let the MCP SDK inherit the + # model key or process env by omission. + "env": _tool_env(tool), + # MCP ClientSession exposes a read timeout as a timedelta. Use the same + # operator knob for request/read waits that we use for initialize. + "session_kwargs": {"read_timeout_seconds": timedelta(seconds=timeout)}, + } + + +class LangGraphRuntime: + """Async lifespan wrapper around a compiled LangGraph agent. + + The shared FastAPI server enters this object once for the process lifespan, + so stdio MCP sessions stay warm across requests and close on shutdown. + """ + + def __init__(self, spec: AgentSpec) -> None: + self.spec = spec + self.stack = AsyncExitStack() + self.graph: Any | None = None + self.client: MultiServerMCPClient | None = None + + async def __aenter__(self) -> RuntimeSession: + try: + model = build_model(self.spec) + tools = await self._load_tools() + self.graph = create_agent( + model=model, + tools=tools, + system_prompt=self.spec.instructions, + ) + return self + except Exception: + await self.stack.aclose() + raise + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> bool | None: + self.graph = None + await self.stack.aclose() + return None + + async def run(self, request: RunRequest) -> RunResult: + return await run_agent(self, request) + + async def _load_tools(self) -> list[Any]: + if not self.spec.tools: + return [] + + connections = {tool.name: build_mcp_connection(tool) for tool in self.spec.tools} + self.client = MultiServerMCPClient(connections, tool_name_prefix=True) + + tools: list[Any] = [] + for tool in self.spec.tools: + session_cm = self.client.session(tool.name, auto_initialize=False) + session = await self.stack.enter_async_context(session_cm) + await asyncio.wait_for(session.initialize(), timeout=_mcp_init_timeout()) + tools.extend( + await load_mcp_tools( + session, + server_name=tool.name, + tool_name_prefix=True, + ) + ) + return tools + + +def build_runtime(spec: AgentSpec) -> LangGraphRuntime: + """Build the runtime session consumed by the shared server.""" + return LangGraphRuntime(spec) + + +def build_agent(spec: AgentSpec) -> LangGraphRuntime: + """Compatibility alias for wrappers that build an adapter runtime directly.""" + return build_runtime(spec) + + +def _to_messages(request: RunRequest) -> list[BaseMessage]: + """Map a neutral RunRequest to LangChain messages. + + The agent's own ``spec.instructions`` is passed as ``system_prompt`` when the + graph is created; do not duplicate it here. + """ + messages: list[BaseMessage] = [] + for turn in request.history: + if turn.role not in FORWARDED_ROLES or not turn.text: + continue + if turn.role == "system": + messages.append(SystemMessage(content=turn.text)) + elif turn.role == "user": + messages.append(HumanMessage(content=turn.text)) + elif turn.role == "assistant": + messages.append(AIMessage(content=turn.text)) + messages.append(HumanMessage(content=request.prompt)) + return messages + + +def _status_of(exc: Exception) -> int: + """Compatibility wrapper over shared upstream status unwrapping.""" + return upstream_status_code(exc) + + +def _state_messages(state: Any) -> list[Any]: + """Extract the LangGraph ``messages`` list or raise a normalized run error.""" + messages = state.get("messages") if isinstance(state, dict) else getattr(state, "messages", None) + if not isinstance(messages, list): + raise AgentRunError( + "agent run failed: LangGraph result did not contain a messages list", + status=502, + code="LangGraphResultError", + ) + return messages + + +def _last_ai_message(state: Any) -> AIMessage: + """Extract the final AIMessage from a LangGraph ``ainvoke`` state.""" + for msg in reversed(_state_messages(state)): + if isinstance(msg, AIMessage): + return msg + raise AgentRunError( + "agent run failed: LangGraph result did not contain an assistant message", + status=502, + code="LangGraphResultError", + ) + + +def _message_text(message: AIMessage) -> str: + """Extract assistant text from LangChain message content. + + LangChain may surface content as a string or as a list of content blocks. Text + blocks are joined; non-text blocks are ignored unless there is no text at all, + in which case we fall back to ``str(content)`` for debuggability. + """ + content = message.content + if content is None: + return "" + if isinstance(content, str): + return content + if isinstance(content, list): + text_parts: list[str] = [] + for part in content: + if isinstance(part, str): + text_parts.append(part) + continue + if isinstance(part, dict) and part.get("type") == "text": + text_parts.append(str(part.get("text", ""))) + continue + part_type = getattr(part, "type", None) + part_text = getattr(part, "text", None) + if part_type == "text" and part_text is not None: + text_parts.append(str(part_text)) + if text_parts: + return "".join(text_parts) + return str(content) + + +def _message_usage(message: AIMessage) -> dict[str, int]: + """Map one LangChain ``usage_metadata`` block to the OpenAI usage shape.""" + usage = getattr(message, "usage_metadata", None) + get = getattr(usage, "get", None) + if not callable(get): + return {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0} + + prompt_tokens = int(get("input_tokens", 0) or 0) + completion_tokens = int(get("output_tokens", 0) or 0) + total = get("total_tokens", None) + total_tokens = int(total) if total is not None else prompt_tokens + completion_tokens + return { + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": total_tokens, + } + + +def _state_usage(state: Any) -> dict[str, int]: + """Aggregate token usage across every model call in a LangGraph run. + + A tool-using LangGraph agent can make multiple model calls: one AIMessage may + request a tool, and a later AIMessage contains the final answer. Each message + carries per-call ``usage_metadata`` when available, so the OpenAI facade should + report the sum for the whole agent turn rather than only the final answer. + """ + totals = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0} + for msg in _state_messages(state): + if not isinstance(msg, AIMessage): + continue + usage = _message_usage(msg) + totals["prompt_tokens"] += usage["prompt_tokens"] + totals["completion_tokens"] += usage["completion_tokens"] + totals["total_tokens"] += usage["total_tokens"] + return totals + + +async def run_agent(agent: LangGraphRuntime, request: RunRequest) -> RunResult: + """Run the compiled LangGraph once and return a neutral ``RunResult``.""" + if agent.graph is None: + raise AgentRunError("agent graph is not initialized", status=500, code="AgentNotInitialized") + + try: + state = await agent.graph.ainvoke({"messages": _to_messages(request)}) + except AgentRunError: + raise + except Exception as exc: # noqa: BLE001 — normalized for the façade + raise normalize_agent_run_error(exc) from exc + + msg = _last_ai_message(state) + return RunResult(text=_message_text(msg), usage=_state_usage(state)) diff --git a/runtimes/langgraph/pyproject.toml b/runtimes/langgraph/pyproject.toml new file mode 100644 index 0000000..ce145e7 --- /dev/null +++ b/runtimes/langgraph/pyproject.toml @@ -0,0 +1,35 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "agentkit-serve" +version = "0.0.0" +description = "AgentKit runtime adapter: serves an OpenAI Chat-Completions facade backed by a LangChain/LangGraph agent with stdio MCP tools." +readme = "README.md" +requires-python = ">=3.11" +license = { text = "MIT" } +authors = [{ name = "AgentKit" }] +dependencies = [ + "langchain>=1.2,<2", + "langgraph>=1.1,<2", + "langchain-openai>=1.0,<2", + "langchain-mcp-adapters>=0.2,<1", + "mcp>=1.24,<2", + # The framework-neutral core (ABI loader, /v1 facade, CLI). Brings fastapi, + # uvicorn, pydantic, pyyaml transitively. Resolved from the sibling path in dev + # (see [tool.uv.sources]) and COPYed + installed in the Docker image build. + "agentkit-serve-common", +] + +[tool.uv.sources] +agentkit-serve-common = { path = "../common", editable = true } + +[project.scripts] +agentkit-serve = "agentkit_serve.__main__:main" + +[project.optional-dependencies] +dev = ["pytest>=8.0"] + +[tool.hatch.build.targets.wheel] +packages = ["agentkit_serve"] diff --git a/runtimes/langgraph/tests/conftest.py b/runtimes/langgraph/tests/conftest.py new file mode 100644 index 0000000..164f119 --- /dev/null +++ b/runtimes/langgraph/tests/conftest.py @@ -0,0 +1,97 @@ +"""Pytest fixtures wiring the shared conformance suite to the LangGraph adapter. + +The offline double is a tiny fake compiled graph. Patching ``create_agent`` to +return it means the shared FastAPI server and LangGraph adapter run with no +network and no real model API key. +""" + +from __future__ import annotations + +import os +from contextlib import contextmanager +from unittest import mock + +import pytest +from fastapi.testclient import TestClient +from langchain_core.messages import AIMessage + +from agentkit_serve import agent_factory +from agentkit_serve_common.config import AgentSpec +from agentkit_serve_common.server import create_app + +# build_model resolves the API key from this env var at construction time before +# create_agent is patched to the fake graph. Provide a dummy so tests stay fully +# offline. NOT a real secret. +os.environ.setdefault("OPENAI_API_KEY", "test-key-not-used") + +_MODEL_NAME = "gpt-4o-mini" +_SPEC_DATA = { + "abiVersion": "v0", + "metadata": {"name": "test-agent"}, + "model": { + "provider": "openai-compatible", + "baseURL": "https://api.openai.com/v1", + "name": _MODEL_NAME, + "apiKeyEnv": "OPENAI_API_KEY", + }, + "instructions": "Be helpful.", + "tools": [], + "expose": {"openai": True, "port": 8080}, +} + + +class _FakeGraph: + def __init__(self, output: str = "ok") -> None: + self.output = output + self.inputs = [] + + async def ainvoke(self, state): + self.inputs.append(state) + return {"messages": [*state.get("messages", []), AIMessage(content=self.output)]} + + +class _FailingGraph: + def __init__(self, exc: Exception) -> None: + self.exc = exc + + async def ainvoke(self, state): + raise self.exc + + +def _spec() -> AgentSpec: + return AgentSpec.model_validate(_SPEC_DATA) + + +@pytest.fixture +def model_name() -> str: + return _MODEL_NAME + + +@pytest.fixture +def make_client(): + """Factory: a TestClient whose agent uses an offline fake compiled graph.""" + + @contextmanager + def _make(auth_token: str | None = None, output: str = "ok"): + fake_graph = _FakeGraph(output=output) + with mock.patch("agentkit_serve.agent_factory.create_agent", return_value=fake_graph): + app = create_app(_spec(), agent_factory, auth_token=auth_token) + with TestClient(app) as client: + yield client + + return _make + + +@pytest.fixture +def make_failing_client(): + """Factory: a TestClient whose graph raises during ``ainvoke``.""" + + @contextmanager + def _make(exc: Exception, auth_token: str | None = None): + fake_graph = _FailingGraph(exc) + with mock.patch("agentkit_serve.agent_factory.create_agent", return_value=fake_graph): + app = create_app(_spec(), agent_factory, auth_token=auth_token) + with TestClient(app) as client: + yield client + + return _make diff --git a/runtimes/langgraph/tests/test_guardrails.py b/runtimes/langgraph/tests/test_guardrails.py new file mode 100644 index 0000000..c128b64 --- /dev/null +++ b/runtimes/langgraph/tests/test_guardrails.py @@ -0,0 +1,324 @@ +"""LangGraph adapter-specific guardrails and translation tests.""" + +from __future__ import annotations + +import ast +import asyncio +from contextlib import asynccontextmanager +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +import pytest +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage + +from agentkit_serve import agent_factory +from agentkit_serve_common.config import AgentSpec, ToolSpec +from agentkit_serve_common.conversation import ConversationTurn, RunRequest +from agentkit_serve_common.runtime import AgentRunError + +_MODEL_NAME = "gpt-4o-mini" + + +def _spec_data(api_key_env: str | None = "OPENAI_API_KEY", tools: list[dict] | None = None) -> dict: + model = { + "provider": "openai-compatible", + "baseURL": "https://api.openai.com/v1", + "name": _MODEL_NAME, + } + if api_key_env is not None: + model["apiKeyEnv"] = api_key_env + return { + "abiVersion": "v0", + "metadata": {"name": "test-agent"}, + "model": model, + "instructions": "Be helpful.", + "tools": tools or [], + "expose": {"openai": True, "port": 8080}, + } + + +def _spec(api_key_env: str | None = "OPENAI_API_KEY", tools: list[dict] | None = None) -> AgentSpec: + return AgentSpec.model_validate(_spec_data(api_key_env=api_key_env, tools=tools)) + + +def test_missing_api_key_env_fails_secret_free(monkeypatch): + monkeypatch.delenv("MISSING_MODEL_KEY", raising=False) + with pytest.raises(agent_factory.AgentBuildError) as ei: + agent_factory._resolve_api_key(_spec(api_key_env="MISSING_MODEL_KEY")) + msg = str(ei.value) + assert "MISSING_MODEL_KEY" in msg + assert "sk-" not in msg + + +def test_no_api_key_env_uses_placeholder(monkeypatch): + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + assert agent_factory._resolve_api_key(_spec(api_key_env=None)) == "not-needed" + + +def test_message_mapping_drops_unsupported_roles_and_does_not_duplicate_instructions(): + messages = agent_factory._to_messages( + RunRequest( + prompt="final question", + history=( + ConversationTurn("system", "request system"), + ConversationTurn("user", "first question"), + ConversationTurn("assistant", "first answer"), + ConversationTurn("tool", "client tool result"), + ConversationTurn("user", ""), + ConversationTurn("unknown", "ignored"), + ), + ) + ) + + assert [type(m) for m in messages] == [SystemMessage, HumanMessage, AIMessage, HumanMessage] + assert [m.content for m in messages] == [ + "request system", + "first question", + "first answer", + "final question", + ] + + +def test_message_text_extraction_string_blocks_and_fallback(): + assert agent_factory._message_text(AIMessage(content="plain")) == "plain" + assert ( + agent_factory._message_text( + AIMessage( + content=[ + {"type": "text", "text": "hello"}, + {"type": "image", "url": "ignored"}, + " world", + ] + ) + ) + == "hello world" + ) + non_text = [{"type": "image", "url": "x"}] + assert agent_factory._message_text(AIMessage(content=non_text)) == str(non_text) + + +def test_message_usage_extraction(): + msg = AIMessage( + content="ok", + usage_metadata={"input_tokens": 3, "output_tokens": 4, "total_tokens": 7}, + ) + assert agent_factory._message_usage(msg) == { + "prompt_tokens": 3, + "completion_tokens": 4, + "total_tokens": 7, + } + # Some providers omit total_tokens; LangChain's AIMessage model currently + # requires it when constructing usage_metadata, so use a duck-typed object to + # exercise the adapter's defensive mapper. + msg_no_total = SimpleNamespace(usage_metadata={"input_tokens": 3, "output_tokens": 4}) + assert agent_factory._message_usage(msg_no_total)["total_tokens"] == 7 + assert agent_factory._message_usage(AIMessage(content="ok")) == { + "prompt_tokens": 0, + "completion_tokens": 0, + "total_tokens": 0, + } + + +def test_state_usage_aggregates_all_ai_messages_in_tool_loop(): + state = { + "messages": [ + HumanMessage(content="use a tool"), + AIMessage( + content="", + usage_metadata={"input_tokens": 10, "output_tokens": 2, "total_tokens": 12}, + ), + HumanMessage(content="tool result"), + AIMessage( + content="final", + usage_metadata={"input_tokens": 20, "output_tokens": 5, "total_tokens": 25}, + ), + ] + } + + assert agent_factory._state_usage(state) == { + "prompt_tokens": 30, + "completion_tokens": 7, + "total_tokens": 37, + } + + +def test_status_unwraps_framework_exception_chain(): + class _BadRequest(Exception): + status_code = 400 + + class _Wrapped(Exception): + def __init__(self, msg, inner=None): + super().__init__(msg) + self.inner_exception = inner + + assert agent_factory._status_of(_BadRequest()) == 400 + try: + try: + raise _BadRequest("upstream 400") + except _BadRequest as ex: + raise _Wrapped("wrapped", inner=ex) from ex + except _Wrapped as exc: + assert agent_factory._status_of(exc) == 400 + assert agent_factory._status_of(ValueError("x")) == 502 + + +def test_last_ai_message_errors_on_bad_state(): + with pytest.raises(AgentRunError) as ei: + agent_factory._last_ai_message({"messages": [HumanMessage(content="hi")]}) + assert ei.value.status == 502 + assert ei.value.code == "LangGraphResultError" + + +def test_tool_env_declared_only_and_model_key_not_inherited(monkeypatch): + monkeypatch.setenv("MODEL_API_KEY", "model-secret") + monkeypatch.setenv("TOOL_SECRET", "tool-secret") + monkeypatch.setenv("UNDECLARED", "must-not-pass") + tool = ToolSpec(name="fetch", command=["uvx", "mcp-server-fetch"], env=["TOOL_SECRET"]) + + conn = agent_factory.build_mcp_connection(tool) + + assert conn["env"] == {"TOOL_SECRET": "tool-secret"} + assert "MODEL_API_KEY" not in conn["env"] + assert "UNDECLARED" not in conn["env"] + + +def test_tool_env_rejects_interpolation_of_undeclared_secret(monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "model-secret") + monkeypatch.setenv("TOOL_CONFIG", "token=${OPENAI_API_KEY}") + tool = ToolSpec(name="fetch", command=["uvx", "mcp-server-fetch"], env=["TOOL_CONFIG"]) + + with pytest.raises(agent_factory.AgentBuildError) as ei: + agent_factory.build_mcp_connection(tool) + + msg = str(ei.value) + assert "OPENAI_API_KEY" in msg + assert "model-secret" not in msg + + +def test_tool_env_allows_interpolation_of_declared_env(monkeypatch): + monkeypatch.setenv("TOOL_SECRET", "secret") + monkeypatch.setenv("TOOL_CONFIG", "token=${TOOL_SECRET}") + tool = ToolSpec( + name="fetch", + command=["uvx", "mcp-server-fetch"], + env=["TOOL_CONFIG", "TOOL_SECRET"], + ) + + conn = agent_factory.build_mcp_connection(tool) + + assert conn["env"] == {"TOOL_CONFIG": "token=${TOOL_SECRET}", "TOOL_SECRET": "secret"} + + +def test_tool_env_empty_is_explicit_empty_dict(monkeypatch): + monkeypatch.setenv("MODEL_API_KEY", "model-secret") + tool = ToolSpec(name="fetch", command=["uvx", "mcp-server-fetch"], env=[]) + assert agent_factory.build_mcp_connection(tool)["env"] == {} + + +def test_empty_tool_command_fails(): + # The strict ABI reader now rejects empty commands, but keep a defensive + # adapter-level guard for hand-constructed specs. + tool = ToolSpec.model_construct(name="fetch", command=[], env=[]) + with pytest.raises(agent_factory.AgentBuildError): + agent_factory.build_mcp_connection(tool) + + +def test_mcp_timeout_env_default_invalid_negative_and_positive(monkeypatch): + monkeypatch.delenv("AGENTKIT_MCP_TIMEOUT", raising=False) + assert agent_factory._mcp_init_timeout() == 120.0 + + monkeypatch.setenv("AGENTKIT_MCP_TIMEOUT", "garbage") + assert agent_factory._mcp_init_timeout() == 120.0 + + monkeypatch.setenv("AGENTKIT_MCP_TIMEOUT", "-1") + assert agent_factory._mcp_init_timeout() == 120.0 + + monkeypatch.setenv("AGENTKIT_MCP_TIMEOUT", "2.5") + assert agent_factory._mcp_init_timeout() == 2.5 + conn = agent_factory.build_mcp_connection(ToolSpec(name="fetch", command=["cmd"], env=[])) + assert conn["session_kwargs"]["read_timeout_seconds"].total_seconds() == 2.5 + + +def test_missing_model_key_fails_before_loading_tools(monkeypatch): + monkeypatch.delenv("MISSING_MODEL_KEY", raising=False) + spec = _spec( + api_key_env="MISSING_MODEL_KEY", + tools=[{"name": "fetch", "command": ["cmd"], "env": []}], + ) + runtime = agent_factory.LangGraphRuntime(spec) + + async def _should_not_load(): + raise AssertionError("tools loaded before model API key was resolved") + + with mock.patch.object(runtime, "_load_tools", _should_not_load): + with pytest.raises(agent_factory.AgentBuildError): + asyncio.run(runtime.__aenter__()) + + +def test_load_tools_uses_persistent_sessions_and_prefixed_names(): + spec = _spec(tools=[{"name": "fetch", "command": ["cmd", "arg"], "env": []}]) + runtime = agent_factory.LangGraphRuntime(spec) + initialized = [] + entered = [] + exited = [] + seen_connections = [] + + class _Session: + async def initialize(self): + initialized.append(True) + + class _FakeClient: + def __init__(self, connections, tool_name_prefix=False): + seen_connections.append((connections, tool_name_prefix)) + + @asynccontextmanager + async def session(self, server_name, auto_initialize=True): + entered.append((server_name, auto_initialize)) + try: + yield _Session() + finally: + exited.append(server_name) + + async def _fake_load(session, *, server_name, tool_name_prefix): + assert server_name == "fetch" + assert tool_name_prefix is True + return [SimpleNamespace(name=f"{server_name}_fetch")] + + with ( + mock.patch("agentkit_serve.agent_factory.MultiServerMCPClient", _FakeClient), + mock.patch("agentkit_serve.agent_factory.load_mcp_tools", _fake_load), + ): + tools = asyncio.run(runtime._load_tools()) + asyncio.run(runtime.stack.aclose()) + + assert tools[0].name == "fetch_fetch" + assert initialized == [True] + assert entered == [("fetch", False)] + assert exited == ["fetch"] + assert seen_connections[0][1] is True + assert seen_connections[0][0]["fetch"]["env"] == {} + + +def _imported_roots(path: Path) -> set[str]: + tree = ast.parse(path.read_text(encoding="utf-8")) + roots: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + roots.update(a.name.split(".")[0] for a in node.names) + elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0: + roots.add(node.module.split(".")[0]) + return roots + + +def test_no_azure_or_foundry_imports_in_generic_langgraph_adapter(): + forbidden = { + "azure", + "azure_ai_agentserver", + "azure_ai_projects", + "langchain_azure_ai", + } + pkg_dir = Path(agent_factory.__file__).parent + for path in pkg_dir.glob("*.py"): + leaked = _imported_roots(path) & forbidden + assert not leaked, f"{path.name} imports Azure/Foundry symbols {sorted(leaked)}" diff --git a/runtimes/langgraph/tests/test_server.py b/runtimes/langgraph/tests/test_server.py new file mode 100644 index 0000000..98d7e34 --- /dev/null +++ b/runtimes/langgraph/tests/test_server.py @@ -0,0 +1,34 @@ +"""Behavioral regression tests for the LangGraph adapter's OpenAI facade. + +The HARD invariants (400 guards, single-completion, auth gate, multi-turn, +framework-agnostic shared core) are the SHARED conformance suite — imported here +so this adapter is held to the exact same contract as every other adapter. The +offline double + spec are supplied by ``conftest.py``. +""" + +from __future__ import annotations + +# Re-export the shared conformance suite; pytest collects each `test_*` against +# this adapter's `make_client` / `model_name` fixtures from conftest.py. +from agentkit_serve_common.conformance import * # noqa: F401,F403 + + +def test_unsupported_feature_error_codes(make_client): + """The shared server returns stable OpenAI-shaped codes for v0 rejections.""" + with make_client() as c: + base = {"model": "x", "messages": [{"role": "user", "content": "hi"}]} + + r = c.post("/v1/chat/completions", json={**base, "stream": True}) + assert r.status_code == 400 + assert r.json()["error"]["code"] == "stream_unsupported" + + r = c.post( + "/v1/chat/completions", + json={**base, "tools": [{"type": "function", "function": {"name": "x"}}]}, + ) + assert r.status_code == 400 + assert r.json()["error"]["code"] == "tools_unsupported" + + r = c.post("/v1/chat/completions", json={**base, "tool_choice": "required"}) + assert r.status_code == 400 + assert r.json()["error"]["code"] == "tool_choice_unsupported" diff --git a/test/agentkitfile-langgraph-hello.yaml b/test/agentkitfile-langgraph-hello.yaml new file mode 100644 index 0000000..3d98957 --- /dev/null +++ b/test/agentkitfile-langgraph-hello.yaml @@ -0,0 +1,21 @@ +#syntax=agentkit:test +# The "four keys" fixture (plan §4.1) under the LangGraph runtime. Identical to +# test/agentkitfile-hello.yaml except for the `runtime:` line — so the baked +# /agent/agent.yaml is byte-identical to the pydantic-ai version (the ABI is +# runtime-neutral) and the two build the SAME logical agent. Build it against the +# LOCAL frontend and LangGraph adapter with: +# make build-serve-langgraph build-test-agent RUNTIME=langgraph +apiVersion: v1alpha1 +kind: Agent +metadata: + name: url-summarizer +runtime: langgraph +model: + provider: openai-compatible + baseURL: https://api.openai.com/v1 + name: gpt-4o-mini + apiKeyEnv: OPENAI_API_KEY +instructions: | + Summarize any URL the user gives you in three bullet points. +expose: + openai: true diff --git a/test/agentkitfile-langgraph-tools.yaml b/test/agentkitfile-langgraph-tools.yaml new file mode 100644 index 0000000..45b7ce7 --- /dev/null +++ b/test/agentkitfile-langgraph-tools.yaml @@ -0,0 +1,24 @@ +#syntax=agentkit:test +# The stdio-MCP tool fixture under the LangGraph runtime: the same four keys plus +# ONE stdio MCP server declared as a `command` (v0 supports command tools only). +# agentkit-serve-langgraph keeps a persistent langchain-mcp-adapters session open +# for the process lifespan and namespaces exposed tools by server name. Nothing +# is baked into the image but the tool's argv and declared env var NAMES. +apiVersion: v1alpha1 +kind: Agent +metadata: + name: fetch-summarizer +runtime: langgraph +model: + provider: openai-compatible + baseURL: https://api.openai.com/v1 + name: gpt-4o-mini + apiKeyEnv: OPENAI_API_KEY +instructions: | + Summarize any URL the user gives you in three bullet points. + Use the fetch tool to retrieve the page contents first. +tools: + - name: fetch + command: ["uvx", "mcp-server-fetch"] +expose: + openai: true diff --git a/test/foundry-hosted-agent/README.md b/test/foundry-hosted-agent/README.md index 308ecbb..378948c 100644 --- a/test/foundry-hosted-agent/README.md +++ b/test/foundry-hosted-agent/README.md @@ -23,13 +23,19 @@ equivalent to the native `/v1` server. ## Build locally -From the repository root, first build the local frontend and pydantic-ai adapter: +From the repository root, first build the local frontend and one AgentKit runtime +adapter. The commands below use the default pydantic-ai adapter because the +fixture is a protocol smoke test, not a runtime-specific behavior test: ```sh make build-agentkit make build-serve ``` +To smoke the wrapper with another runtime, build that adapter instead and use the +matching `adapter=` build arg; for example LangGraph uses +`make build-serve-langgraph` and `--build-arg adapter=agentkit-serve-langgraph:test`. + Build the AgentKit base image from this fixture: ```sh