Skip to content

Compose agents with Python

Pilota does not introduce a graph or workflow language. An agent call is a normal expression, so Python remains the place for sequencing, branching, loops, retries, and concurrency.

Sequence typed stages

Each stage should have a narrow task and a result contract:

from dataclasses import dataclass

from pilota import agent


@dataclass
class Plan:
    steps: list[str]


@dataclass
class Review:
    approved: bool
    concerns: list[str]


plan = agent(
    "Plan the requested change",
    model=model,
    context={"request": request},
    returns=Plan,
)

review = agent(
    "Review the plan for correctness and missing risks",
    model=model,
    context={"request": request, "plan": plan},
    returns=Review,
)

There is no hidden shared memory. The second call knows the first result only because Python passes it explicitly.

Branch on a result

if review.approved:
    publish(plan)
else:
    plan = agent(
        "Repair the plan using the review concerns",
        model=model,
        context={
            "request": request,
            "plan": plan,
            "concerns": review.concerns,
        },
        returns=Plan,
    )

This keeps the transition visible, testable, and type checked.

Use ordinary retries for provider failures

Pilota retries invalid final values through Limits.result_retries. Application-level retries for provider availability remain application policy:

from time import sleep

from pilota.errors import ProviderError


def create_plan(request: str) -> Plan:
    for attempt in range(3):
        try:
            return agent(
                "Plan the requested change",
                model=model,
                context={"request": request},
                returns=Plan,
            )
        except ProviderError:
            if attempt == 2:
                raise
            sleep(2**attempt)

    raise AssertionError("unreachable")

The retry loop is intentionally outside Pilota because backoff, idempotency, and provider failover are application decisions.

Run independent calls concurrently

The synchronous API composes with standard executors:

from concurrent.futures import ThreadPoolExecutor

requests = ["Add caching", "Add metrics", "Add rate limits"]

with ThreadPoolExecutor(max_workers=3) as pool:
    plans = list(pool.map(create_plan, requests))

Each call receives its own execution ID, limits, trace, and workspace snapshot. Do not share a mutable workspace between concurrent executions unless your application provides synchronization.

Make memory application data

history = load_project_history(project_id)

decision = agent(
    "Choose the next implementation task",
    model=model,
    context={
        "open_tasks": open_tasks,
        "recent_decisions": history[-10:],
    },
    returns=Decision,
)

save_decision(project_id, decision)

Pilota does not silently persist conversation state. Your application chooses what to retain, summarize, remove, and pass into the next call.

Choose the right boundary

Prefer multiple calls when stages have different:

  • result contracts,
  • authority,
  • budgets,
  • failure policies,
  • persistence requirements.

Prefer one call when intermediate tool actions serve one outcome and should share one model conversation.