Skip to content

Data pipelines

Available · pip install agent-adapter (no extra)

Airflow, Prefect, Dagster, Ray and Spark run your code on workers, often in other processes. Build the executor on the worker, call tools from the task, return a small JSON result.

File Does
examples/backend_pipeline.py a per-process backend(), an async task, a sync task, a Spark partition body
tests/docs/test_backend_handler_examples.py runs them offline

Data enters as task arguments, goes through run_many to the tools as one run, and leaves as the task's return value (rates and the run's cost).

One executor per worker process

@functools.cache
def backend() -> Backend:
    """One per worker process: never pickle an Executor into a task, build it there."""
    return build_backend()

An Executor or Registry holds locks, so it cannot be pickled. Never pass one into a task, a Ray remote function or a Spark closure: pass names and arguments, and call backend() inside.

Task bodies

async def shipping_rates(regions: list[str]) -> dict[str, Any]:
    """An async task body (Airflow 3 `@task`, Prefect 3 `@flow`/`@task`, Dagster async op)."""
    executor = backend().executor
    calls = [ToolCall(name="shipping_rate", arguments={"region": r}) for r in regions]
    async with executor.run_scope() as ctx:  # one run: one budget and one cost line
        results = await executor.run_many(calls, ctx, concurrency=8)
        cost = backend().ledger.total_usd(ctx.run_id)
    rates = {region: r.value if r.ok else None for region, r in zip(regions, results, strict=True)}
    return {"rates": rates, "cost_usd": cost}  # small and JSON-safe: fine for XCom/results


def shipping_rates_sync(regions: list[str]) -> dict[str, Any]:
    """The same for a sync task, a Ray task or a Dagster sync op."""
    return run_sync(shipping_rates(regions), timeout=TIMEOUT_S)
Tool Write Runs
Airflow 3 @task async def ... (async PythonOperator) await shipping_rates(...) on the worker's loop
Airflow, sync @task def ... shipping_rates_sync(...)
Prefect 3 @flow / @task on async def await shipping_rates(...)
Dagster async @op / @asset await shipping_rates(...); sync ops shipping_rates_sync
Ray @ray.remote def ... shipping_rates_sync(...); an actor can hold backend() as its tool host
Spark mapPartitions / pandas_udf rates_for_partition below

Spark partitions

def rates_for_partition(regions: Iterable[str]) -> Iterator[float | None]:
    """A Spark `mapPartitions` / `pandas_udf` body: one loop per partition, not per row."""
    rows = list(regions)
    rates = shipping_rates_sync(rows)["rates"]
    yield from (rates[region] for region in rows)

One run_sync per partition, not per row: each run_sync builds a loop. Each Python worker builds its own backend() once.

Rules

Rule Why
build the executor in the worker (functools.cache) executors do not pickle; forked workers must not share loop-bound clients
one run per task BudgetHook caps it; the ledger sums its cost
return JSON-safe values XCom, Prefect results and Dagster outputs serialise them
set concurrency= on run_many a big batch must not open hundreds of connections
approvals in a pipeline use AutoDeny, or park with DeferredApproval and a shared store, then resume in a later run
retries the task framework retries the task; the tool's retries= retries one call

See also: Queues, Scripts and batch jobs, Runtime.