3Isolated Runtime Environments
Server-mode nodesets run in their own subprocess and Python env; the framework talks HTTP to them.
Research tools often require conflicting Python environments. Any BaseNodeSet can run in server mode in its own interpreter โ the framework auto-generates an HTTP server from the tool's port definitions. Zero extra server code. Under batch eval, the same mechanism fans out to N tagged subprocesses for per-worker isolation (ADR-eval-002).
Design docs: Server Mode ยท Env Panels
1. Problem
AgentCanvas runs in agentcanvas (Python 3.10+, modern torch / HF / FastAPI โ the primary env, ADR-platform-004). Research tools it drives often need different environments:
- Habitat-Sim 0.1.7 โ pinned to Python 3.8 (
ac-vlnceenv) by its binary wheel - SAM / modern HF models โ need torch โฅ 2.x, won't load under old CUDA
- SLAM / ROS โ Python 3.8 with ROS Noetic bindings
These cannot coexist in one interpreter. AgentCanvas solves this by running each nodeset in its own subprocess with its own interpreter โ transparently to the canvas.
2. Two Loading Modes
| Mode | Command | Where tools run | Use case |
|---|---|---|---|
| Local | POST /api/components/nodesets/{name}/load |
In the backend process | Same-env tools, fast I/O |
| Server | POST /api/components/nodesets/{name}/load?mode=server |
In a separate subprocess | Different Python env, GPU isolation, parallel workers |
In both modes the tools appear as identical canvas nodes โ the researcher doesn't need to know which mode is active. Under batch eval, worker_count > 1 auto-routes env nodesets to server mode even if the graph was loaded locally (registry.py ยท load_nodeset, gated on parallelism="replicated").
3. Auto-Server Architecture
When mode=server, the framework performs these steps automatically:
1. WorkspaceComponentRegistry._load_nodeset_as_server(name, nodeset, worker_count=N)
2. Read server_python from nodeset class (or default to sys.executable)
3. Allocate N free TCP ports (one subprocess per worker)
4. Launch subprocess(es):
PYTHONPATH=... {server_python} -m app.server.auto_host \
--file {source_file} --class {class_name} --port {port}
5. Subprocess starts AutoServerApp:
a. Instantiate nodeset env panel (if any) for the BaseNodeSet class
b. Call on_startup() โ instantiate nodeset, await initialize()
c. Introspect get_tools() โ read input_ports / output_ports
d. Auto-generate ServerFunction per tool
e. Serve HTTP: /manifest + /call/{fn} + /env-panel/* + /health
6. Framework fetches /manifest from each subprocess
7. Generate proxy canvas nodes from manifest
8. Register proxy nodes in NODE_HANDLERS (once; proxies route to the right subprocess at call time)
3.1 AutoServerApp Introspection
AutoServerApp reads each tool's PortDef declarations and converts them to ServerFunction entries:
# From auto_server_app.py โ get_functions()
for tool in self._nodeset.get_tools():
functions.append(ServerFunction(
name=tool.node_type,
description=tool.description,
input_ports=_portdefs_to_schemas(tool.input_ports),
output_ports=_portdefs_to_schemas(tool.output_ports),
config_schema=tool.config_schema,
handler=_make_handler(tool, self._batched_server), # batched server passed for batched=True nodes
))
No manual port re-declaration needed โ the same PortDef used for local mode drives the server API. The manifest.FunctionSchema.name is the tool's node_type, so local and server-mode proxies register under the same node_type (TODO #33 closed).
4. Proxy Nodes
After fetching the manifest, the framework generates proxy canvas nodes โ BaseCanvasNode subclasses that forward forward() calls to the remote server via HTTP.
Canvas node forward() โ HTTP POST /call/{fn} โ subprocess: tool.forward() โ HTTP response โ return dict
Proxy nodes have the same node_type, input_ports, output_ports, and category as the original tool. They're registered in NODE_HANDLERS exactly like local nodes, so the GraphExecutor treats them identically.
At call time, a proxy checks the running executor for a per-worker server_url_overrides entry (ADR-eval-002 PB-1.5, proxy.py::_make_forward); if present, the request is routed to that worker's URL, otherwise it falls back to the globally-registered URL.
5. Env Panel Bridging
Env nodesets own mutable episode state (current split, current episode, last observation) that must live inside the subprocess where the simulator actually runs. ADR-server-002 formalises this with a generic BaseEnvPanel contract:
BaseNodeSet.env_panel: type[BaseEnvPanel] | Noneโ opt-in ClassVar on the nodeset classAutoServerApp.on_startup()instantiates the env panel inside the subprocess with_context = {"mode": "local", "server_url": None, "nodeset_name": ...}โ the env panel is local to the subprocess, not the frameworkServerApp._build_app()mounts generic/env-panel/info,/env-panel/{method}routes that proxy method calls to the subprocess env panel- Framework side:
RemoteEnvPanelProxy(one per worker) is registered in the global env panel registry. Calls go HTTP โ subprocess env panel โ live simulator state
This replaced the earlier pattern of smuggling episode control through hidden env_*__ep_* tool nodes. 5 hidden tools + api/canvas/env.py (233 lines) + app/env_controller.py (343 lines) were deleted; episode management is fully decoupled from the tool-call path.
6. Worker-Pool Fan-Out (ADR-eval-002)
For batch evaluation, worker_count > 1 extends server mode to N parallel isolated subprocesses per env nodeset:
ensure_nodesets_for_graph(graph, worker_count=N)
โโ for env nodeset in graph:
โ โโ _load_nodeset_as_server(name, ns, worker_count=N)
โ โโ allocate N free ports
โ โโ spawn N auto_host subprocesses
โ โโ fetch manifest from first; generate proxy nodes once
โ โโ register N tagged RemoteEnvPanelProxy instances: "{name}#0"โฆ"{name}#{N-1}"
โโ non-env nodesets stay singleton (one subprocess, shared across workers)
EnvWorkerPool then leases one WorkerHandle per episode; each handle carries:
env_panel_overrides{nodeset_name: tagged_proxy}โ consulted byexecutor.get_env_panel(name)before the global registry, soset_episode/resetcalls hit the right subprocessserver_url_overrides{nodeset_name: tagged_url}โ consulted byexecutor.get_server_url(name), so in-graph tool calls from the proxy node hit the same subprocess
At worker_count=1 both overrides are empty and the code path collapses to the singleton case (zero behaviour change). On eval shutdown, all N tagged subprocesses are unloaded together with rollback if any failed to come up.
7. Batched Inference (ADR-eval-002)
Some nodes (e.g. policy_adapter_vla__predict) amortise better when K parallel callers share one GPU forward pass. BatchedInferenceServer is a per-AutoServerApp rendezvous tier:
- Opt-in on the node class:
batched: ClassVar[bool] = True,batch_dim: ClassVar[str] = "<input_port_name>"(validated at scan time) AutoServerApp._make_handlerroutes batched=True calls throughBatchedInferenceServer.submit(function_name, inputs, config)- The server holds one
_BatchQueueper(function_name, config_hash)โ collects K submissions, calls the underlying handler once with stacked inputs (viaSAMPLES_KEY/OUTPUTS_KEYmarkers), scatters the K result slices to K awaiting futures - Restart-on-each-submit flush timer at
flush_timeout_ms(default 50 ms) โ flush on eitherbatch_size == worker_countor timeout, whichever fires first - Pure-functional contract: any per-call state must travel as explicit
hidden_in/hidden_outports on the node โ the server never carries caller-scoped state between calls
This sits inside a single subprocess (one AutoServerApp instance), so it composes cleanly with the worker-pool fan-out: K parallel workers submit into the same batched queue hosted by the inference subprocess.
8. How to Use
8.1 Specifying a Different Interpreter
Add server_python to the nodeset class:
class SlamNodeSet(BaseNodeSet):
name = "slam"
description = "SLAM spatial tools"
server_python = "/opt/ros/noetic/bin/python3"
def get_tools(self) -> list:
return [LocalizeTool(), MapQueryTool(), FrontierTool()]
For the ac-vlnce env:
class EnvHabitatNodeSet(BaseNodeSet):
name = "env_habitat"
server_python = "python"
env_panel = HabitatEnvPanel # ADR-server-002 โ lives in the subprocess
8.2 Loading via API
# Load in local mode (default)
curl -X POST http://localhost:8000/api/components/nodesets/sam/load
# Load in server mode (auto-hosted subprocess)
curl -X POST http://localhost:8000/api/components/nodesets/slam/load?mode=server
# Unload (works for all modes, including worker-pool)
curl -X POST http://localhost:8000/api/components/nodesets/slam/unload
Batch eval loads nodesets via ensure_nodesets_for_graph(graph, worker_count=N) โ there is no public ?worker_count=N query parameter; fan-out is driven by EvalConfig.worker_count.
8.3 Opting Into Batched Inference
class MyPolicyForward(BaseCanvasNode):
node_type = "my_policy__forward"
batched = True
batch_dim = "raw_obs" # must name an actual input port
input_ports = [
PortDef("raw_obs", WIRE_OBSERVATION),
PortDef("hidden_in", WIRE_ANY), # explicit per-caller state
...
]
output_ports = [
PortDef("action", WIRE_ACTION),
PortDef("hidden_out", WIRE_ANY), # explicit per-caller state
]
9. Key Files
| File | Role |
|---|---|
agentcanvas/backend/app/server/auto_server_app.py |
AutoServerApp โ introspects nodeset, generates ServerFunction entries, hosts BatchedInferenceServer, instantiates nodeset env panel in-process |
agentcanvas/backend/app/server/auto_host.py |
CLI entry point: python -m app.server.auto_host --file ... --class ... --port ... |
agentcanvas/backend/app/server/server_app.py |
ServerApp base โ HTTP endpoints, manifest serving, /env-panel/* routes (ADR-server-002) |
agentcanvas/backend/app/server/base_server.py |
BaseServer โ subprocess lifecycle, health check, manifest fetch |
agentcanvas/backend/app/server/nodeset.py |
ServerNodeSet โ loadable group backed by an external server process |
agentcanvas/backend/app/server/proxy.py |
generate_proxy_nodes() + _make_forward โ proxy BaseCanvasNode classes with per-worker URL routing |
agentcanvas/backend/app/server/batched_inference.py |
BatchedInferenceServer + _BatchQueue โ per-app rendezvous batching (ADR-eval-002) |
agentcanvas/backend/app/server/manifest.py |
ServerManifest, FunctionSchema, PortSchema โ manifest data model |
agentcanvas/backend/app/server/serialization.py |
Wire-type (de)serialization for HTTP transport โ base64 images, numpy arrays, nested dicts |
agentcanvas/backend/app/components/registry.py |
_load_nodeset_as_server(worker_count=N), tagged-proxy registration, rollback on partial failure |
agentcanvas/backend/app/components/env_panel.py |
BaseEnvPanel ABC + RemoteEnvPanelProxy (ADR-server-002) |
agentcanvas/backend/app/agent_loop/env_worker_pool.py |
EnvWorkerPool + WorkerHandle โ N-worker lease / release for batch eval |
Status
| Item | Status | Notes |
|---|---|---|
| Local loading mode | Done | load_nodeset(name, mode="local") โ in-process |
| Server loading mode | Done | load_nodeset(name, mode="server") โ subprocess with auto-server |
| AutoServerApp (introspect PortDef โ ServerFunction) | Done | Zero manual port re-declaration |
auto_host CLI (python -m app.server.auto_host) |
Done | --file, --class, --port args |
| BaseServer (subprocess lifecycle, health check, manifest) | Done | start/stop/restart, _wait_for_health, fetch_manifest |
| Proxy node generation | Done | generate_proxy_nodes() + per-worker URL routing in _make_forward |
| server_python ClassVar | Done | Selects interpreter for subprocess |
| Load/unload API endpoints | Done | POST /api/components/nodesets/{name}/load?mode=server |
Manifest protocol (/manifest endpoint) |
Done | ServerApp serves manifest with FunctionSchema entries; node_type is the schema name |
| Env panel bridging (ADR-server-002) | Done | /env-panel/* routes, RemoteEnvPanelProxy, env panel lives in subprocess |
| Worker-pool fan-out (ADR-eval-002 PB) | Done | worker_count > 1 spawns N tagged subprocesses + N tagged proxies with rollback |
| Per-worker URL routing (ADR-eval-002 PB-1.5) | Done | server_url_overrides consulted by proxy._make_forward |
| Batched inference tier (ADR-eval-002 PC) | Done | BatchedInferenceServer + batched/batch_dim opt-in on BaseCanvasNode |
| Docker server mode | Planned | Feature TODO F7 โ unblocked by ADR-eval-002; subprocess spawn is the only seam to swap |