Calling other nodes & fan-out
Call other capabilities, run child work concurrently, record progress, handle cancellation and configure retries. These examples use the SDK and a graph server. nested_concurrency needs no additional provider; the resilience example also needs its fixture model service, as noted below.
traverse, call and submit
Choose one of these Agent methods to submit work (python/atfgraph/agent.py):
| Method | Returns | Use |
|---|---|---|
await app.traverse(target, **input) | the child's output | Call and wait. Also accepts one positional dict: app.traverse("fanout.square", {"n": 3}). Mixing both raises TypeError. |
await app.call(target, ...) | same | Retained name for traverse. |
await app.submit(target, input) | Handle | Submit without waiting; wait later with handle.result(). |
A target without a dot uses the current node's label: square resolves to fanout.square. To call another node, include its label, such as await app.traverse("pricing.quote", fare="Y"). The server checks access policies before admitting the call; see Identity and access. Select a version with submit(..., version="1.0.0").
Inside a handler, submissions become children of its current step. The SDK sends the parent step ID, lease token and operation key from ExecutionContext. The server records the relationship, inherits the root and session, and wakes the parent's worker to claim child work promptly. Outside a handler, the same methods use app.client to create ordinary root traversers.
The module-level traverse function uses the Agent in the current ExecutionContext and has the same behavior.
How a cross-node call travels
Every cross-node call goes through the graph server. Workers need no inbound ports or direct connections to one another. A call proceeds as follows:
- A handler on node A calls
await app.traverse("pricing.quote", fare="Y"). The label selects the registeredpricingnode. - The SDK submits a child with the parent step ID, lease token and operation key. The server checks access and rate policies, records the relationship and queues the task for
pricing. - An outbound worker for node B claims the task under a lease, runs
quoteand returns its result to the server. - Node A's handler receives the result. Parent and child share the root and session, keeping state and usage associated with the same run.
The server authenticates requests, enforces policy, leases tasks and records calls. It retries eligible RetryableError failures and requeues expired leases. Nodes coordinate through that common authority.
Both skills and traversals are addressed as label.capability. Skills conventionally perform one operation; traversals can call more capabilities or models, creating branches in the path. The caller uses the same method for a local-node or remote-node capability.
Fan out with asyncio.gather
This example from examples/python/nested_concurrency/agent.py creates one child step per app.traverse call. asyncio.gather runs each batch concurrently and returns results in input order.
from __future__ import annotations
import asyncio
from atfgraph import Agent
app = Agent(node_id="fanout", version="1.0.0", capacity=4)
@app.skill()
async def square(n: int) -> dict:
await asyncio.sleep(0.05)
return {"n": n, "square": n * n}
@app.traversal()
async def fan_out(count: int = 6, batch: int = 3) -> dict:
results = []
for start in range(0, count, batch):
chunk = list(range(start, min(start + batch, count)))
squares = await asyncio.gather(*[app.traverse("fanout.square", n=n) for n in chunk])
results.extend(s["square"] for s in squares)
await app.progress({"completed": len(results), "of": count})
return {"squares": results, "sum": sum(results)}
The example tests use count=6, batch=3, check squares [0, 1, 4, 9, 16, 25] and sum 55, and expect two progress events. handle.path() includes every square as a child of fan_out.
A DAG across nodes
Write dependencies with ordinary Python control flow. Here, three supplier searches run concurrently; ranking waits for their results, and holding a fare waits for ranking. The server records the execution graph as these calls occur.
┌── fares.search ──┐
trip.plan ───────┼── hotels.search ─┼──► trip.rank ──► fares.hold
└── weather.today ─┘
Each supplier node runs a worker. The caller uses its label rather than a network address:
# fares_node.py
from atfgraph import Agent
app = Agent(node_id="fares", version="1.0.0")
@app.skill()
async def search(origin: str, dest: str, date: str) -> dict:
return {"offers": [{"offer_id": "F1", "total": 412.0}, {"offer_id": "F2", "total": 388.0}]}
@app.skill()
async def hold(offer_id: str) -> dict:
return {"offer_id": offer_id, "pnr": "ABC123", "expires_in_minutes": 30}
if __name__ == "__main__":
app.run()
# hotels_node.py
from atfgraph import Agent
app = Agent(node_id="hotels", version="1.0.0")
@app.skill()
async def search(city: str, checkin: str, nights: int) -> dict:
return {"properties": [{"property_token": "H9", "name": "Quay West", "total_rate": 640.0}]}
if __name__ == "__main__":
app.run()
# weather_node.py
from atfgraph import Agent
app = Agent(node_id="weather", version="1.0.0")
@app.skill()
async def today(city: str) -> dict:
return {"city": city, "summary": "22°C, light rain in the afternoon"}
if __name__ == "__main__":
app.run()
The coordinating node combines them:
# trip_node.py
import asyncio
from atfgraph import Agent
app = Agent(node_id="trip", version="1.0.0", capacity=2)
@app.skill()
async def rank(fares: dict, hotels: dict, weather: dict) -> dict:
best = min(fares["offers"], key=lambda offer: offer["total"])
return {"fare": best, "hotel": hotels["properties"][0], "note": weather["summary"]}
@app.traversal()
async def plan(origin: str, dest: str, date: str, nights: int = 3) -> dict:
# Level 1: three independent calls to three nodes, one child step each.
fares, hotels, weather = await asyncio.gather(
app.traverse("fares.search", origin=origin, dest=dest, date=date),
app.traverse("hotels.search", city=dest, checkin=date, nights=nights),
app.traverse("weather.today", city=dest),
)
await app.progress({"stage": "searched", "offers": len(fares["offers"])})
# Level 2: depends on all three.
choice = await app.traverse("trip.rank", fares=fares, hotels=hotels, weather=weather)
# Level 3: depends on the ranking.
held = await app.traverse("fares.hold", offer_id=choice["fare"]["offer_id"])
return {"choice": choice, "hold": held, "requires_review": True}
if __name__ == "__main__":
app.run()
Start each worker against the same server, then submit the root run:
python -m atfgraph.worker --module fares_node:app
python -m atfgraph.worker --module hotels_node:app
python -m atfgraph.worker --module weather_node:app
python -m atfgraph.worker --module trip_node:app --capacity 2
atfg traverse trip.plan --in '{"origin": "BNE", "dest": "SYD", "date": "2026-10-03"}'
The recorded execution has these properties:
- Each call inside
plancarries its parent step and lease. The path shows the calls underplan, including concurrent searches; inspect them withatfg path <id>. - Workers serving
fares,hotelsandweatherexecute the searches. The orchestrator does not need their addresses. Access policies determine whether the caller may invoke them. - While waiting for children,
planreleases its worker permit. This letstrip.rankrun on the same worker even at--capacity 1, subject to the waiting bound below. - A retryable failure in
fares.holdretries that step within itsmax_attemptsbudget. Cancelling the root also cancels unfinished children.
The shapes a path can take
You do not need to declare a fixed DAG before execution. Each traversal decides which calls to make; the server records the graph they produce. Ordinary Python expresses these common patterns:
| Shape | How you write it | What the path shows |
|---|---|---|
| Sequence | a = await app.traverse(...), then b = await app.traverse(..., a) | A chain of child steps |
| Fan-out, fan-in | await asyncio.gather(*[app.traverse(...) for x in items]) | Parallel children under one parent, outputs in order |
| Conditional branch | An if, or a @app.decision() whose structured answer carries confidence | Only the taken branch exists as steps |
| Dynamic routing | app.ai(..., tools="self") or tools="discover" lets the model choose the next capability | One child step per tool call |
| Retry | RetryableError with max_attempts | Attempts on the same step |
| Pause | await app.request_approval(...) | The traverser waits, resumes on the decision |
| Scheduled or event start | triggers=[ScheduleTrigger(...)] or @on_schedule on a capability | A traverser with no caller |
| Composition across teams | Routers per desk on one node, or one node per team | examples/python/co_consult: one root, three parallel desks |
Step records, attempts and leases survive worker failure. Completed children remain recorded while unfinished work can be recovered. Permit release helps a small worker execute nested calls without its waiting parents occupying all capacity; the next section explains its configured bound.
When the DAG is not known in advance
In examples/python/research_path, a model proposes tasks and dependencies. The example rejects dependency cycles and groups tasks into topological levels using parallelizable_groups. Its scheduler is:
for level in plan["parallelizable_groups"]:
batch = await asyncio.gather(*(
app.call("research_path.execute_research_task",
task=by_id[key],
dependencies=[results[dep] for dep in by_id[key]["dependencies"]])
for key in level))
results.update({item["task_id"]: item for item in batch})
Tasks within a level execute concurrently, each receiving its dependencies' outputs. Use a discovered target instead of the fixed target to route tasks to different nodes.
Capacity and deadlock avoidance
A worker has capacity concurrent handler permits, default 8, configurable with --capacity. A parent waiting for a child on a one-permit worker would block that child if it kept the permit. waiting_on_child in worker.py handles this case:
- On the first active child wait, the worker calls
worker_suspendand releases the parent's permit. Several concurrent child waits in that handler cause only one release. - When the last child wait ends, it reacquires a permit and calls
worker_resume, retrying429 capacity_exhaustedresponses. - Released parents are limited by
max_waiting, defaultcapacity * 4, configurable with--max-waiting. Beyond that bound, parents keep their permits while waiting.test_bounded_waiting_keeps_permit_beyond_max_waitingcovers this limit.
The example completes at --capacity 1, as checked by tests/python_test_examples.py and the real-daemon test_python_worker_nested_call_at_capacity_one:
.venv/bin/python -m atfgraph.worker --module examples.python.nested_concurrency.agent:app --capacity 1
.venv/bin/python -m examples.python.nested_concurrency.call
Two other rules affect throughput and recovery. Root turns in the same session run sequentially, while their child calls may run concurrently (test_root_session_turns_are_serialized_children_bypass). If a lease expires, the server requeues work and rejects results from the old lease (test_lease_expiry_requeues_and_stale_result_is_discarded).
Progress events
Inside a handler, await app.progress(data) writes a durable progress event. await app.note("text", tags=["phase"]) writes a progress payload with note and tags. Both require the active execution context and task lease. A caller can read them through its Handle:
from __future__ import annotations
import asyncio
import os
from atfgraph import Client
async def main() -> None:
async with Client(os.getenv("ATFGRAPH_SERVER")) as client:
handle = await client.submit("fanout.fan_out", {"count": 6, "batch": 3})
async for event in handle.events():
print(f"event {event.sequence:>3} {event.type:<10} {event.data}")
print("result", await handle.result(timeout=120))
path = await handle.path()
print(f"path: {len(path.items)} steps under root {path.root_id}")
if __name__ == "__main__":
asyncio.run(main())
This comes from examples/python/nested_concurrency/call.py, without its cancellation section. handle.events() streams SSE events until the terminal event; handle.event_log() returns a snapshot list. Progress payloads appear in event.data["data"]. Model usage also appears as progress with a usage key; see Observability.
Cancellation
await handle.cancel() and atfg cancel cancel a traverser and its children (test_cancel_propagates_to_children). The worker notices on lease renewal, marks the context as cancelled and cancels the handler task. The nested-concurrency example checks cancellation in two ways:
from __future__ import annotations
import asyncio
from atfgraph import Agent
app = Agent(node_id="fanout", version="1.0.0")
@app.skill(timeout_seconds=600)
async def slow_step(seconds: float) -> dict:
"""Sleeps in small slices so a server cancel interrupts it promptly."""
waited = 0.0
while waited < seconds:
if app.cancel_requested():
break
await asyncio.sleep(0.1)
waited += 0.1
return {"waited": round(waited, 1)}
@app.traversal(timeout_seconds=600)
async def long_parent(seconds: float = 30) -> dict:
child = await app.traverse("fanout.slow_step", seconds=seconds)
return {"child": child}
Use app.cancel_requested() in cooperative loops. An asynchronous handler may also receive asyncio.CancelledError at an await. The caller's handle.result() raises TraversalCancelled. A progress write racing with cancellation can raise ConflictError; the resilience example catches it and stops. Synchronous threads cannot be interrupted, so they finish and their result is discarded.
Retries
An ordinary exception ends the attempt with its class as the error code and retryable: false, failing the run. To request another attempt, raise RetryableError or HandlerError(..., retryable=True) and configure max_attempts on the capability:
from atfgraph import Agent, RetryableError, current_context
app = Agent(node_id="fanout", version="1.0.0")
@app.skill(max_attempts=3)
async def flaky_step(succeed_on_attempt: int = 2) -> dict:
"""Fails with a retryable error until the server has scheduled ``succeed_on_attempt`` attempts."""
ctx = current_context()
if ctx.attempt < succeed_on_attempt:
raise RetryableError(f"attempt {ctx.attempt} failed on purpose")
return {"attempt": ctx.attempt}
The example and feature tests check these retry behaviors:
ctx.attemptstarts at 1 and increases on each scheduled attempt.PathStep.attemptspreserves each attempt and its error's retryable flag.- Each retry writes a
requeuedevent with its error.client.get(id).rawexposesattemptandmax_attempts. - Exhausting the budget fails the run with the last error code, available as
TraversalFailed.code. - Parent retries reuse stable child operation keys derived from target, input and call count. The server can therefore return an existing child rather than create it again (
test_operation_keys_are_stable_across_parent_retries).
The resilience example catches a provider ModelError and raises RetryableError for a transient failure. A bad request expected to fail again remains an ordinary exception. Start its fixture provider with python -m examples.python.features.resilience.fixture_model --port 18899 and point ATFGRAPH_MODEL_URL and ATFGRAPH_MODEL_NAME to it. Without a configured provider, app.ai raises ModelNotConfigured.
Timeouts
Distinguish the handler, server and client deadlines:
| Where | Set by | What happens |
|---|---|---|
| Handler budget | timeout_seconds on the capability (default 300) | The worker stops the handler with asyncio.wait_for and completes the task failed with code handler_timeout, retryable: false. |
| Server deadline | The graph server, from the same registered budget | If the worker itself is gone, the server's sweep marks the traverser timed_out; callers see TraversalTimedOut. |
| Client wait | timeout= on app.traverse, handle.result, client.traverse, client.wait | Raises WaitTimeout in the caller after polling with jittered backoff. The traverser keeps running; cancel it if you no longer want it. |
The stalled-call test submits disruption.stalled_note with a two-second capability budget to a provider that stalls for 30 seconds. It accepts failed/handler_timeout or timed_out/timed_out, completing within 20 seconds. A nested call's WaitTimeout fails the parent like another exception unless your handler catches it. A client wait expiring does not itself cancel the child.
Next
- Share state using Sessions and memory.
- Configure resilience controls.
- Find other capabilities through Multi-node systems.