Skip to content

feat(sdk-python): AgentMesh — host several agents in one process with in-process call resolution - #1026

Merged
AbirAbbas merged 5 commits into
mainfrom
fix/ext-py-agentmesh
Aug 31, 2026
Merged

feat(sdk-python): AgentMesh — host several agents in one process with in-process call resolution#1026
AbirAbbas merged 5 commits into
mainfrom
fix/ext-py-agentmesh

Conversation

@AbirAbbas

Copy link
Copy Markdown
Contributor

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 resolves agent.call("other-node.reasoner", ...) 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 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 SDK CallLocal parity).

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.call onto 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) raises NotImplementedError — so the issue should stay open for the online mode.

Changes

  • feat(sdk-python): add AgentMesh for hosting several agents in one process — new agentfield/mesh.py (mount-based single-port hosting, in-process ASGI dispatch, one drain/signal owner), Agent.call_local(), the new MeshTargetNotFound exception, and the call() mesh branch. The ASGI scope/receive/send are built by hand rather than through httpx.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 over TestClient) rather than mesh internals. Also adds agentfield.mesh to the pytest --cov list, because scripts/coverage-surface.sh runs 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 limitationsdocs/agent-mesh.md plus a short pointer in the Python SDK README. Every limitation listed was verified against the code rather than assumed. No new environment variable, so docs/ENVIRONMENT_VARIABLES.md is untouched.

Three hazards the implementation is built around, since they are the parts most likely to look like over-engineering in review:

  1. ExecutionContext.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 {"status": "processing"}. agentfield_server has a non-empty default even with AGENTFIELD_SERVER unset, so the header is popped unconditionally, not conditionally on the env.
  2. The target's _execute_reasoner_endpoint ends in _clear_current(). Dispatch therefore runs in a child context (asyncio.create_task) so the caller's contextvars survive, and separately snapshots/restores the Agent._current_agent class attribute, which a child context cannot protect.
  3. 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 binding improvement here would be a dev/prod trap.

Validation contract

# Behaviour Covering test(s)
1 AgentMesh([a, b]).run() starts with AGENTFIELD_SERVER unset; one app serves both members and GET /health lists both node ids test_mesh_mounts_all_agents_on_one_app, test_mesh_offline_two_agents_call_each_other
2 await a.call("agent-b.greet", ...) returns the reasoner's dict with no control-plane round trip, dispatched through the target's own ASGI app test_mesh_offline_two_agents_call_each_other (asserts client.execute / execute_async are never called), test_mesh_custom_reasoner_path_resolves
3 Dispatch runs in a child context, so the target's _set_as_current/_clear_current cannot null the caller's contextvars test_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_set
4 Child sees the caller's run_id and parent_execution_id == caller.execution_id; X-Execution-ID is popped unconditionally test_mesh_child_context_lineage, test_mesh_does_not_forward_execution_id_header (captures the headers actually handed to the ASGI app)
5 depth is dropped from the contract rather than half-transported: to_headers() never emits it and from_request() never reads it, so a header-transported child lands at depth 0 — same as the control-plane HTTP path Documented in docs/agent-mesh.md; no code change, so no test
6 await a.call("agent-a.hello") (same node, inside a mesh) resolves in-process with agentfield_connected False — the case that raises today test_mesh_same_node_short_circuit
7 Unknown node raises MeshTargetNotFound listing 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 all test_mesh_unknown_node_raises_named_error
8 Known node, unknown reasoner/skill raises MeshTargetNotFound naming the target, not a 404 ExecuteError test_mesh_unknown_reasoner_on_known_node_raises_named_error
9 A type-violating input raises what the control-plane path raises (ExecuteError(422)); a raising reasoner propagates a typed exception with the original as __cause__ (raise_app_exceptions=False equivalent, mapped from the 500 body) test_mesh_validation_error_parity, test_mesh_reasoner_exception_maps_to_execution_failed_error
10 The returned dict never contains the reserved usage-envelope key; the target's usage is merged into the caller's CostTracker, matching what the control plane strips test_mesh_result_has_no_usage_envelope
11 Workflow events are emitted per mesh execution with the CP-routed payload shape; with no control plane reachable the emission fails silently and the call still returns test_mesh_emits_workflow_events, test_mesh_workflow_event_failure_does_not_break_the_call
12 Keyword binding is identical in mesh and CP mode, and positional binding matches the CP path (including the arg_0 degradation) rather than silently improving on it test_mesh_positional_binding_matches_control_plane
13 With no AgentMesh constructed, call() is unchanged: same headers, same fallback ladder, same AgentFieldClientError text test_call_semantics_unchanged_without_mesh (pins the exact existing message)
14 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 events test_call_local_without_mesh_or_cp, test_call_local_rejects_foreign_node, test_call_local_forwards_parent_vc_id
15 serve() installs exactly one SIGTERM/SIGINT pair for the process regardless of member count; every member drains under a single AGENTFIELD_SHUTDOWN_TIMEOUT budget that the mesh assigns itself (agent.router.lifespan_context is only assigned inside AgentServer.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_removal
16 AgentMesh(register=True) raises NotImplementedError pointing at the callback-URL path stripping instead of registering members the control plane cannot route to; members get auto_register=False test_mesh_register_true_is_refused, test_mesh_sets_auto_register_false_on_members
17 httpx is not reached for at all — it is only a dev extra, so dispatch speaks raw ASGI and the SDK gains no runtime dependency test_mesh_raw_asgi_and_defensive_dispatch_branches; sdk/python/pyproject.toml dependency list unchanged
18 Docs record the v1 limitations: no DID signatures on mesh calls (a member with local_verification=True would 401 its own traffic), 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 docs/agent-mesh.md

How it was tested

Gates were re-run against the rebased branch (base d48f40f3), using the literal CI commands for the surfaces this PR touches:

cd sdk/python && ruff check .                              # PASS — "All checks passed!" (ruff 0.15.22)
cd sdk/python && ./scripts/run_pytest.sh -q -p no:cacheprovider   # PASS — exit 0, no failures or errors

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):

Surface Touched lines Patch coverage Threshold
sdk-python 157 100.00% 80%

The rebase over origin/main moved 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:

  • Payload serialisation drift. Mesh dispatch serialises with json.dumps(input_data, default=str), while the control-plane branch of Agent.call does per-key fixups first (enum → .value, object → __dict__, then str()). So call("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.
  • The outbound-call semaphore does not apply in mesh mode. The mesh branch returns before _limit_outbound_calls(), so AGENTFIELD_AGENT_MAX_CONCURRENT_CALLS (itself undocumented) stops bounding call() 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.
  • MeshTargetNotFound on an application 404. mesh.py maps a 404 response to MeshTargetNotFound, but the registry lookup above has already proven the target exists, so the only way to reach it is a reasoner raising HTTPException(404) — a call that ran and failed, which is exactly the distinction that exception's docstring promises to preserve. That branch should fall through to ExecuteError like every other non-2xx.
  • Signal-handler gap during drain. In the serve() lifespan's finally, the handlers are removed before await 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.
  • Serial member cleanup. _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. The except Exception: pass there also discards failures with no log.
  • Two tests leave Agent._current_agent (a class attribute the autouse _isolate_execution_context fixture does not reset) assigned on teardown — green today, latent cross-file flake. test_mesh_workflow_event_failure_does_not_break_the_call also returns before the failing emission is dequeued, so it does not yet prove the dispatcher survived.
  • Smaller: json.loads sits outside the block that maps failures to ExecutionFailedError; a wrapped exception with an empty message loses its type name; a comment cites a hard agent.py line number that will drift; the docs snippet opens with an unused import 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

AbirAbbas and others added 3 commits August 31, 2026 13:07
…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>
@AbirAbbas
AbirAbbas requested a review from a team as a code owner August 31, 2026 17:08
@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Performance

SDK Memory Δ Latency Δ Tests Status
Python 9.0 KB - 0.31 µs -11%

✓ No regressions detected

@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

📊 Coverage gate

Thresholds from .coverage-gate.toml: per-surface ≥ 84%, aggregate ≥ 85%, max per-surface regression ≤ 1.0 pp, max aggregate regression ≤ 0.50 pp.

Surface Current Baseline Δ
control-plane 87.70% 87.40% ↑ +0.30 pp 🟡
sdk-go 93.10% 92.00% ↑ +1.10 pp 🟢
sdk-python 94.72% 93.73% ↑ +0.99 pp 🟢
sdk-typescript 91.72% 90.42% ↑ +1.30 pp 🟢
web-ui 84.76% 84.79% ↓ -0.03 pp 🟡
aggregate 85.84% 85.75% ↑ +0.09 pp 🟡

✅ Gate passed

No surface regressed past the allowed threshold and the aggregate stayed above the floor.

@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

📐 Patch coverage gate

Threshold: 80% on lines this PR touches vs origin/main (from .coverage-gate.toml:thresholds.min_patch).

Surface Touched lines Patch coverage Status
control-plane 0 ➖ no changes
sdk-go 0 ➖ no changes
sdk-python 163 100.00%
sdk-typescript 0 ➖ no changes
web-ui 0 ➖ no changes

✅ Patch gate passed

Every surface whose lines were touched by this PR has patch coverage at or above the threshold.

@AbirAbbas
AbirAbbas merged commit 164fb7a into main Aug 31, 2026
29 checks passed
@AbirAbbas
AbirAbbas deleted the fix/ext-py-agentmesh branch August 31, 2026 23:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant