feat(sdk-python): AgentMesh — host several agents in one process with in-process call resolution - #1026
Merged
Merged
Conversation
…cess Refs #651 (v1: offline-only; the registered/online mode stays open). `AgentMesh([a, b])` mounts every member on one FastAPI app (`/{node_id}`) so a single uvicorn serves them all, and resolves `app.call()` between members in-process instead of round-tripping through the control plane. Dispatch goes through the target Agent's own ASGI app — Agent already subclasses FastAPI — so validation, the per-execution CostTracker, workflow events, DID and trigger unwrapping all run exactly as they do for an HTTP-routed execution. The ASGI scope/receive/send are built by hand rather than through `httpx.ASGITransport`, because httpx is not a declared runtime dependency of the SDK; the mesh adds no new dependency. Three hazards the implementation is built around: - `to_headers()` emits `X-Execution-ID`, and the reasoner endpoint turns that header plus a non-empty `agentfield_server` into a fire-and-forget 202. `agentfield_server` defaults to http://localhost:8080 even with AGENTFIELD_SERVER unset, so the header is popped unconditionally — otherwise every mesh call would return {"status": "processing"}. - The target's `_execute_reasoner_endpoint` ends in `_clear_current()`. The dispatch therefore runs in a child context (`asyncio.create_task`), which keeps the caller's agent/execution contextvars intact, and snapshots/restores the `Agent._current_agent` CLASS attribute that a child context cannot protect. - Argument binding is left entirely to the existing `Agent.call` mapping ladder (extracted verbatim into `_map_call_args`), so mesh and control-plane paths bind identically — including the `arg_0` degradation for a cross-node positional call. A mesh-only improvement here would be a dev/prod trap. `Agent.call_local()` ships the same in-process dispatch as an explicit opt-in for a bare agent (Go SDK `CallLocal` parity); the intentionally DISABLED same-agent short-circuit in `call()` is untouched, so implicit short-circuiting still only happens inside a mesh. v1 is offline-only: members get `auto_register=False` and `AgentMesh(register=True)` raises NotImplementedError rather than registering members at a mounted path the control plane cannot route back to. An unknown node or member raises the new `MeshTargetNotFound` instead of the misleading "server unavailable" error, with no control-plane fallthrough. With no AgentMesh constructed, `call()` is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…viour Behaviour-level tests for every item of the AgentMesh contract, written against the public surface (`app.call`, `call_local`, `AgentMesh`, the mounted app over TestClient) rather than mesh internals. The regression guards worth calling out: - `test_mesh_does_not_forward_execution_id_header` pins the 202 fire-and-forget trap: it captures the headers actually handed to the ASGI app and asserts x-execution-id is gone while x-run-id and x-parent-execution-id survive. - `test_mesh_nested_a_b_a_current_agent_at_each_hop` walks a->b->a and asserts both the contextvar and `Agent.get_current()` report the right agent at every hop and after the outermost call returns. - `test_mesh_positional_binding_matches_control_plane` asserts the mesh and control-plane paths bind the same call identically, so a mesh-only binding improvement cannot silently reintroduce the dev/prod trap. - `test_call_semantics_unchanged_without_mesh` pins the exact existing offline AgentFieldClientError text for an agent with no mesh. `agentfield.mesh` is added to the pytest `--cov` list because scripts/coverage-surface.sh runs pytest with only those targets, so a new module absent from the list produces no coverage rows and the required patch-coverage gate cannot see it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds docs/agent-mesh.md and a short README section pointing at it. Every limitation listed is one verified against the code rather than assumed: mesh calls carry no DID signature (so a member with local_verification=True would 401 its own traffic), the connection manager and memory-event client never connect because the mesh does not run AgentServer.serve()'s resilient startup lifecycle, child executions land at depth 0 because to_headers() never emits depth and from_request() never reads it, and both `Agent.get_current()` and the set_current_agent contextvar are last-writer-wins with several agents in one process — which is why the mesh resolves targets from its own registry and never from the ambient one. The error-mapping table is part of the contract: unknown node or member raises MeshTargetNotFound, a validation failure raises ExecuteError(status_code=422) exactly as the control-plane path does, and a reasoner exception becomes ExecutionFailedError with the original exception as __cause__. No new environment variable is introduced, so docs/ENVIRONMENT_VARIABLES.md is untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Contributor
Performance
✓ No regressions detected |
Contributor
📊 Coverage gateThresholds from
✅ Gate passedNo surface regressed past the allowed threshold and the aggregate stayed above the floor. |
Contributor
📐 Patch coverage gateThreshold: 80% on lines this PR touches vs
✅ Patch gate passedEvery surface whose lines were touched by this PR has patch coverage at or above the threshold. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
AgentMesh([a, b])hosts several Python agents in one process: it mounts each member on one FastAPI app under/{node_id}so a single uvicorn serves them all, and resolvesagent.call("other-node.reasoner", ...)in-process instead of round-tripping through the control plane. Dispatch goes through the targetAgent's own ASGI app —Agentalready subclasses FastAPI — so validation, the per-executionCostTracker, workflow events, DID handling and trigger unwrapping run exactly as they do for an HTTP-routed execution.Agent.call_local()ships the same in-process dispatch as an explicit opt-in for a single agent with no mesh (Go SDKCallLocalparity).The whole thing is gated on
self._mesh is not None: an agent that never constructs a mesh takes byte-identical code paths, down to the exact text of the existing offline error. v1 is offline-only and adds no runtime dependency.Why
Refs #651 — the reporter runs six agents from one monorepo and had to monkeypatch
agent.callonto a hand-rolled lookup table to exercise them together locally. This lands that as a supported surface.Not
Fixes: the issue also asks for control-plane delegation to stay the default with a swappable in-memory manager, i.e. a mesh whose members are still registered and routable. That is deliberately out of scope here —AgentMesh(register=True)raisesNotImplementedError— so the issue should stay open for the online mode.Changes
feat(sdk-python): add AgentMesh for hosting several agents in one process— newagentfield/mesh.py(mount-based single-port hosting, in-process ASGI dispatch, one drain/signal owner),Agent.call_local(), the newMeshTargetNotFoundexception, and thecall()mesh branch. The ASGI scope/receive/send are built by hand rather than throughhttpx.ASGITransport, because httpx is only a dev extra of the SDK.test(sdk-python): cover AgentMesh dispatch, lineage and shutdown behaviour— 32 behaviour-level tests written against the public surface (call,call_local,AgentMesh, the mounted app overTestClient) rather than mesh internals. Also addsagentfield.meshto the pytest--covlist, becausescripts/coverage-surface.shruns pytest with only those targets and a module absent from the list produces no rows for the patch-coverage gate.docs: document AgentMesh, call_local and the v1 limitations—docs/agent-mesh.mdplus a short pointer in the Python SDK README. Every limitation listed was verified against the code rather than assumed. No new environment variable, sodocs/ENVIRONMENT_VARIABLES.mdis untouched.Three hazards the implementation is built around, since they are the parts most likely to look like over-engineering in review:
ExecutionContext.to_headers()emitsX-Execution-ID, and the reasoner endpoint turns that header plus a non-emptyagentfield_serverinto a fire-and-forget202 {"status": "processing"}.agentfield_serverhas a non-empty default even withAGENTFIELD_SERVERunset, so the header is popped unconditionally, not conditionally on the env._execute_reasoner_endpointends in_clear_current(). Dispatch therefore runs in a child context (asyncio.create_task) so the caller's contextvars survive, and separately snapshots/restores theAgent._current_agentclass attribute, which a child context cannot protect.Agent.callmapping ladder (extracted verbatim into_map_call_args), so mesh and control-plane paths bind identically — including thearg_0degradation for a cross-node positional call. A mesh-only binding improvement here would be a dev/prod trap.Validation contract
AgentMesh([a, b]).run()starts withAGENTFIELD_SERVERunset; one app serves both members andGET /healthlists both node idstest_mesh_mounts_all_agents_on_one_app,test_mesh_offline_two_agents_call_each_otherawait a.call("agent-b.greet", ...)returns the reasoner's dict with no control-plane round trip, dispatched through the target's own ASGI apptest_mesh_offline_two_agents_call_each_other(assertsclient.execute/execute_asyncare never called),test_mesh_custom_reasoner_path_resolves_set_as_current/_clear_currentcannot null the caller's contextvarstest_mesh_caller_context_survives,test_mesh_nested_a_b_a_current_agent_at_each_hop,test_mesh_dispatch_leaves_no_ambient_agent_when_none_was_setrun_idandparent_execution_id == caller.execution_id;X-Execution-IDis popped unconditionallytest_mesh_child_context_lineage,test_mesh_does_not_forward_execution_id_header(captures the headers actually handed to the ASGI app)depthis dropped from the contract rather than half-transported:to_headers()never emits it andfrom_request()never reads it, so a header-transported child lands at depth 0 — same as the control-plane HTTP pathdocs/agent-mesh.md; no code change, so no testawait a.call("agent-a.hello")(same node, inside a mesh) resolves in-process withagentfield_connectedFalse — the case that raises todaytest_mesh_same_node_short_circuitMeshTargetNotFoundlisting the known node ids, never the "server unavailable" connection error. Fallthrough resolved by choosing one path: members never connect, so there is no control-plane fallthrough at alltest_mesh_unknown_node_raises_named_errorMeshTargetNotFoundnaming the target, not a 404ExecuteErrortest_mesh_unknown_reasoner_on_known_node_raises_named_errorExecuteError(422)); a raising reasoner propagates a typed exception with the original as__cause__(raise_app_exceptions=Falseequivalent, mapped from the 500 body)test_mesh_validation_error_parity,test_mesh_reasoner_exception_maps_to_execution_failed_errorCostTracker, matching what the control plane stripstest_mesh_result_has_no_usage_envelopetest_mesh_emits_workflow_events,test_mesh_workflow_event_failure_does_not_break_the_callarg_0degradation) rather than silently improving on ittest_mesh_positional_binding_matches_control_planeAgentMeshconstructed,call()is unchanged: same headers, same fallback ladder, sameAgentFieldClientErrortexttest_call_semantics_unchanged_without_mesh(pins the exact existing message)agent.call_local("hello", "world")works on a bare agent with no mesh and no control plane, returns the dict and emits the same workflow eventstest_call_local_without_mesh_or_cp,test_call_local_rejects_foreign_node,test_call_local_forwards_parent_vc_idserve()installs exactly one SIGTERM/SIGINT pair for the process regardless of member count; every member drains under a singleAGENTFIELD_SHUTDOWN_TIMEOUTbudget that the mesh assigns itself (agent.router.lifespan_contextis only assigned insideAgentServer.serve(), so entering it from the mesh would be a no-op)test_mesh_installs_one_signal_handler_pair,test_mesh_shutdown_drains_every_member_under_one_budget,test_mesh_shutdown_timeout_comes_from_env,test_mesh_serve_lifespan_and_cleanup_are_single_owner,test_mesh_signal_callback_marks_all_members,test_mesh_lifespan_tolerates_loop_without_signal_removalAgentMesh(register=True)raisesNotImplementedErrorpointing at the callback-URL path stripping instead of registering members the control plane cannot route to; members getauto_register=Falsetest_mesh_register_true_is_refused,test_mesh_sets_auto_register_false_on_memberstest_mesh_raw_asgi_and_defensive_dispatch_branches;sdk/python/pyproject.tomldependency list unchangedlocal_verification=Truewould 401 its own traffic), and bothAgent.get_current()and theset_current_agentcontextvar are last-writer-wins with several agents in one process, which is why the mesh resolves targets from its own registry and never from the ambient onedocs/agent-mesh.mdHow it was tested
Gates were re-run against the rebased branch (base
d48f40f3), using the literal CI commands for the surfaces this PR touches:Full-suite coverage total after the change: 95% (2213 statements, 117 missed).
Patch-coverage gate (
scripts/coverage-surface.sh sdk-python+scripts/patch-coverage-gate.sh, the required CI check):sdk-pythonThe rebase over
origin/mainmoved the base but changed no file in this branch, so the patch-coverage figure above (measured pre-rebase) still describes the same diff; ruff and the full pytest suite were re-run after the rebase.No live control plane was started for any of this — that is the point of the feature, and every test asserts on the transport actually used rather than patching sockets process-wide.
Notes / follow-ups
Review verdict was ship. These are the known non-blocking items, deliberately left for a follow-up rather than folded in after review:
json.dumps(input_data, default=str), while the control-plane branch ofAgent.calldoes per-key fixups first (enum →.value, object →__dict__, thenstr()). Socall("b.f", color=Color.RED)puts"red"on the wire through a control plane and"Color.RED"through a mesh. Contract item 12 covers binding, not serialisation, and nothing pins this either way. The fix is to extract that fixup loop into a shared helper used by both branches._limit_outbound_calls(), soAGENTFIELD_AGENT_MAX_CONCURRENT_CALLS(itself undocumented) stops boundingcall()once a mesh exists, and there is no recursion guard — a reasoner that calls itself through the mesh recurses in-process until the stack blows. Either wrap the dispatch in the same limiter or document the divergence.MeshTargetNotFoundon an application 404.mesh.pymaps a 404 response toMeshTargetNotFound, but the registry lookup above has already proven the target exists, so the only way to reach it is a reasoner raisingHTTPException(404)— a call that ran and failed, which is exactly the distinction that exception's docstring promises to preserve. That branch should fall through toExecuteErrorlike every other non-2xx.serve()lifespan'sfinally, the handlers are removed beforeawait self._shutdown(), and uvicorn does not restore its own until lifespan shutdown completes — so a second SIGTERM during the drain window kills the process mid-drain. Swapping the two statements closes it._shutdown()gathers the drains concurrently under one budget (per contract item 15) but then awaits_cleanup_async_resources()per member serially, each of which can spend up to 5s, so total shutdown can exceed the single budget by N×5s. Theexcept Exception: passthere also discards failures with no log.Agent._current_agent(a class attribute the autouse_isolate_execution_contextfixture does not reset) assigned on teardown — green today, latent cross-file flake.test_mesh_workflow_event_failure_does_not_break_the_callalso returns before the failing emission is dequeued, so it does not yet prove the dispatcher survived.json.loadssits outside the block that maps failures toExecutionFailedError; a wrapped exception with an empty message loses its type name; a comment cites a hardagent.pyline number that will drift; the docs snippet opens with an unusedimport asyncio.Known limitations by design, all documented in
docs/agent-mesh.md: offline only, no DID signatures on mesh calls, no connection manager or memory-event client, and child executions at depth 0.🤖 Generated with Claude Code