AgentCanvas / Pages / Developer Guide / Design Docs / Surfaces / SDK Dependency Boundary
2026-07-12

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.

Dependency tiers (each includes the one above) import agentcanvas — stdlib only Graph builder · to_code · RunEvent — zero third-party pure-local g.run() — + pydantic-settings executor + builtin nodes + Settings (workspace discovery) [server] — + httpx · msgpack · numpy server-node proxy · cassette record / replay [llm] + litellm · pyyaml  ·  [backend] + FastAPI stack llmCall builtin · the full canvas backend The seams that hold the boundaries app/standard/telemetry.py accounting ContextVars — executor never imports litellm / numpy app/server/__init__.py · __getattr__ importing serialization no longer drags FastAPI in app/replay/__init__.py · __getattr__ registry import no longer drags httpx in guard: app/test_dependency_boundary.py — meta-path blocker raises on every heavy import, then imports agentcanvas (tier 0) and runs a pure-local graph (tier 1) — clean-venv semantics, in CI, on every push

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:

TierRequiresUnlocks
import agentcanvasstdlib onlybuild 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 · numpyserver-node proxy calls, cassette record / replay
[llm]litellm · pyyamlthe llmCall builtin node
[backend]FastAPI · uvicorn · websockets · psutil · mcp · dotenvthe 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.

SeamWas dragging inVia
app/replay/__init__.py · __getattr__httpxcomponents/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:

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

Extras gate dependencies, not code. The wheel always contains the entire backend — 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.
The top-level 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.
The workspace only travels with the source tree. An editable install resolves 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

FileRole
root pyproject.tomlthe declared contract — core dependency + the server / llm / backend / all extras; packages mapped from agentcanvas/backend
app/standard/telemetry.pythe stdlib-only home of both accounting ContextVars
app/replay/__init__.py · app/server/__init__.pythe two PEP 562 lazy package inits
app/agent_loop/graph_executor.pyimports the ContextVars from standard.telemetry — the executor's import graph is what the core tier ultimately pays for
app/test_dependency_boundary.pythe CI guard — active import blocking, tiers 0 + 1
AgentCanvas docs