Graph SDK Dependency Boundary
dependency tiers (core / server / llm / backend) · three lazy-import seams · app.standard.telemetry · the active guard test
The installable package (root pyproject.toml) promises a tiered contract: import agentcanvas pulls nothing third-party at all, a pure-local g.run() needs only the declared core dependency (pydantic-settings), and everything heavier — litellm, FastAPI, httpx, msgpack, numpy — belongs to an opt-in extra. Nothing in the language enforces this: one eager top-level import added anywhere in the transitive graph silently breaks the core tier, and only a user in a clean venv would ever notice. This page names the three seams that hold the boundary and the guard test that fails CI when someone breaks one. Feature-level install story: Graph SDK capability page §10; the record/replay machinery living behind the server extra: SDK Record / Replay.
1. What it does
The package always ships all the code — extras add third-party libraries, never files. So the real contract is about imports: which third-party modules become load-bearing at which usage tier. The tiers, as declared in the root pyproject.toml:
| Tier | Requires | Unlocks |
|---|---|---|
import agentcanvas | stdlib only | build graphs, to_code(), RunEvent, serialise to JSON |
core install (pip install -e .) | pydantic-settings (brings pydantic, dotenv) | pure-local g.run(), catalog scan, workspace discovery |
[server] | httpx · msgpack · numpy | server-node proxy calls, cassette record / replay |
[llm] | litellm · pyyaml | the llmCall builtin node |
[backend] | FastAPI · uvicorn · websockets · psutil · mcp · dotenv | the full canvas backend (superset of requirements.txt) |
A tier boundary holds only if no module on the cheap side eagerly imports a module from the expensive side. Python's import rules make that fragile: importing any module executes every top-level import in it, and importing any submodule first executes its package's __init__.py. The boundary therefore lives in three specific seams (§3–§4) plus one test (§5).
2. The smallest demonstration
A clean venv is the contract made visible — eight packages in total at the core tier, and each extra widens what runs, never what imports:
python -m venv v && v/bin/pip install -e . # core tier: 8 packages total v/bin/python -c "from agentcanvas import Graph" # stdlib-only import surface v/bin/pip install -e ".[server]" # + httpx msgpack numpy → proxy + cassettes
3. The two package-init seams (PEP 562)
A package __init__.py taxes every submodule import, so the two taxing inits became lazy. Both use module-level __getattr__: re-exports still work by name, but nothing is imported until the name is actually touched.
| Seam | Was dragging in | Via |
|---|---|---|
app/replay/__init__.py · __getattr__ | httpx | components/registry.py imports app.replay.interface (stdlib) — but importing the submodule executes the package init, which eagerly imported renderer_client (httpx). Every registry import — hence every run — paid it. |
app/server/__init__.py · __getattr__ | FastAPI (+ pydantic) | graph_sdk_replay.py imports app.server.serialization (msgpack + numpy — correctly [server]-tier) — but the package init eagerly imported server_app, so cassette replay was paying for FastAPI. |
def __getattr__(name: str) -> Any: # app/server/__init__.py — PEP 562 submodule = _EXPORTS.get(name) if submodule is None: raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from importlib import import_module return getattr(import_module(submodule, __name__), name)
4. The telemetry seam — shared ContextVars, relocated
The executor and the heavy producer modules share two per-node accounting buckets; the ContextVars had to live somewhere both can import cheaply. GraphExecutor sets _current_node_usage (LLM token accounting) and _current_node_transport (proxy round-trip accounting) around every node firing. They were defined in their producer modules — app/llm/call.py (imports litellm at top level) and app/server/serialization.py (imports numpy) — which made litellm and numpy load-bearing for every run, including pure-local ones, through the executor's module-level imports.
The fix is a home with no dependencies: app/standard/telemetry.py defines both ContextVars (stdlib only); the executor imports them from there (graph_executor.py top-of-file), and the producers re-export them so their own accumulation code is untouched. Identity is the load-bearing property — executor and producer must reference the same ContextVar object — and two details protect it:
app/llm/call.pyre-imports it plainly (the module already requires litellm, no extra cost).app/server/serialization.pyre-imports it insidetry/except ImportErrorwith a local fallback — the module documents itself as standalone-loadable outside theapppackage (server-side use), and the fallback preserves that contract. Inside the package the import succeeds, so identity holds where the executor is present.
One constraint worth naming: serialization.py and therefore telemetry.py are imported by server subprocesses running foreign conda envs down to Python 3.8 (ac-vlnce), so telemetry.py must stay 3.8-importable (from __future__ import annotations; no runtime-evaluated new syntax).
5. The guard test — active blocking, not passive inspection
app/test_dependency_boundary.py recreates clean-venv semantics inside the dev env, so the boundary is CI-enforced. A passive check ("assert litellm not in sys.modules") cannot tell a soft import from a hard one — in the dev env everything is installed, so standard/wire_types.py's guarded try: import numpy would land numpy in sys.modules and false-positive. The guard instead installs a meta-path finder that raises ModuleNotFoundError for every forbidden top-level package: soft imports degrade exactly as they would in a bare venv, hard imports crash the subprocess and fail the test. Two tiers are asserted: tier 0 blocks even the core dependency chain and does import agentcanvas; tier 1 unblocks only pydantic-settings's chain and runs a full pure-local graph with on_event and to_code. Same shape as the framework's test_import_boundary.py (which guards against simulator imports); this one guards against dependency-tier erosion. The same file also carries test_backend_extra_matches_requirements_txt: the [backend] extra and requirements.txt declare the same stack in two hand-maintained lists, and this check fails CI when they drift.
6. Where it deviates from the mental model
app/api, app/services, app/main.py, and 33 test_*.py modules ship to every core-tier user as dead files (importable only once [backend] is installed). Harmless bulk today (~500 KB); a true slim SDK would need the package boundary redrawn, which is deferred to the top-level app rename.app import name squats site-packages. The wheel installs two import names, agentcanvas and app — the latter generic enough to collide with anything else called app in the same environment. Deliberate, known, and the reason PyPI publication is deferred; editable / git installs into a dedicated env are the supported channels meanwhile.workspace/ through the clone (config.py derives the default from __file__), so graphs / nodesets / cassettes work with zero configuration — but a wheel install has no workspace at all: building graphs, local nodes, and cassette replay work; nodeset runs need a clone (or WORKSPACE_DIR).7. Key files
| File | Role |
|---|---|
root pyproject.toml | the declared contract — core dependency + the server / llm / backend / all extras; packages mapped from agentcanvas/backend |
app/standard/telemetry.py | the stdlib-only home of both accounting ContextVars |
app/replay/__init__.py · app/server/__init__.py | the two PEP 562 lazy package inits |
app/agent_loop/graph_executor.py | imports the ContextVars from standard.telemetry — the executor's import graph is what the core tier ultimately pays for |
app/test_dependency_boundary.py | the CI guard — active import blocking, tiers 0 + 1 |