Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
34 changes: 23 additions & 11 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
47 changes: 31 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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.
Expand Down Expand Up @@ -142,21 +156,22 @@ 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
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.
Expand Down
12 changes: 7 additions & 5 deletions pkg/agentkit/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}
}
}
7 changes: 7 additions & 0 deletions pkg/agentkit/runtimes/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions pkg/agentkit/runtimes/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand Down
35 changes: 29 additions & 6 deletions pkg/build/router_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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").
Expand Down Expand Up @@ -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)
}
Expand Down
13 changes: 13 additions & 0 deletions runtimes/catalog/langgraph.yaml
Original file line number Diff line number Diff line change
@@ -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=<ref>` (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
20 changes: 19 additions & 1 deletion runtimes/common/agentkit_serve_common/adapter_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from __future__ import annotations

import os
import re

from .config import AgentSpec, ToolSpec
from .conversation import FORWARDED_ROLES
Expand All @@ -22,6 +23,7 @@


MCP_TIMEOUT_ENV = "AGENTKIT_MCP_TIMEOUT"
_BRACED_ENV_REF_RE = re.compile(r"\$\{([^}]+)\}")


class AgentBuildError(Exception):
Expand Down Expand Up @@ -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]]:
Expand Down
12 changes: 11 additions & 1 deletion runtimes/common/agentkit_serve_common/conformance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading
Loading