Skip to content

Queues and consumers

Available · pip install agent-adapter (no extra)

Every queue gives you a message body and a delivery id. handle_envelope takes both, runs one call (or one resume) through the executor, and returns a JSON reply. It imports no broker library, so the same function serves SQS, Pub/Sub, Kafka, RQ, Dramatiq, Arq and Temporal.

File Does
agent_adapter/handlers/envelope.py parse_envelope: strict keys, ids from the delivery id
agent_adapter/handlers/consume.py handle_envelope(executor, payload, *, tools=, message_id=, source=)
examples/backend_queue.py an async consumer, a sync job, a Temporal activity, a Pub/Sub push body
tests/docs/test_backend_handler_examples.py runs them over a fake queue, offline

Data enters as the message body (JSON object, text or bytes), goes to the executor, and leaves as the reply you ack with. The envelope format is on Serverless.

An async consumer

async def consume_once(queue: FakeQueue, *, source: str = "kafka") -> list[dict[str, Any]]:
    """One poll of an async consumer (aiokafka, aiobotocore SQS, Pub/Sub, Arq, Taskiq)."""
    replies = []
    for message in await queue.receive():
        try:
            reply = await handle_envelope(
                backend().executor, message.body, message_id=message.id, source=source
            )
        except AdapterError as exc:
            if not exc.retryable:  # a bad message or a denied call: retrying cannot help
                await queue.dead_letter(message)
            continue  # retryable: no ack, so the broker redelivers it
        await queue.ack(message)  # ok and pending_approval are both done for this message
        replies.append(reply)
    return replies
flowchart TD
  M["message (body, delivery id)"] --> H["handle_envelope"]
  H -- "ok / pending_approval" --> A["ack"]
  H -- "retryable error" --> N["no ack: redelivered"]
  H -- "other AdapterError" --> D["dead-letter, ack"]
Reply or error Do
ok ack
pending_approval ack: the call waits in the store; a resume message runs it
already_done ack: a redelivered resume; its decision already ran, nothing runs again
exc.retryable (ToolTimeout, model rate limits) do not ack; the broker redelivers
any other AdapterError (bad envelope, denied, unknown tool) dead-letter; retrying cannot help

Sync jobs and Temporal

RUNNER = LoopRunner()  # one warm loop per worker process, shared by every job


def tool_job(payload: Mapping[str, Any]) -> dict[str, Any]:
    """A sync job body (RQ, Dramatiq, Huey): errors raise, so the runner records them."""
    return RUNNER.run(lambda: handle_envelope(backend().executor, payload), timeout=TIMEOUT_S)


async def tool_activity(payload: Mapping[str, Any]) -> dict[str, Any]:
    """A Temporal activity: never call the adapter from workflow code (it is replayed)."""
    return await handle_envelope(backend().executor, payload)

Pub/Sub push

async def pubsub_push(body: Mapping[str, Any]) -> dict[str, Any]:
    """A Pub/Sub push request body: base64 `data`, a stable `messageId`."""
    message = body["message"]
    data = base64.b64decode(message["data"])
    return await handle_envelope(
        backend().executor, data, message_id=message["messageId"], source="pubsub"
    )

Per broker

Broker Loop Body and delivery id Notes
SQS on Lambda sync use tool_handler (Serverless) partial batch failures built in
SQS (aiobotocore) async Body, MessageId delete after the reply; visibility timeout > the tool's timeout
Pub/Sub pull async or callback threads message.data, message.message_id callback threads: run_sync, then message.ack()
Pub/Sub push HTTP pubsub_push above keep the push endpoint authenticated (OIDC token)
Kafka (aiokafka, confluent AsyncIO) async msg.value, f"{topic}:{partition}:{offset}" commit the offset after the reply
RQ sync, fork per job tool_job(payload) the executor is built in the child (functools.cache)
Dramatiq, Huey sync threads tool_job(payload) a long-lived loop (LoopRunner) saves a loop per job
Arq, Taskiq async await handle_envelope(...) the job id as message_id
Celery sync see Celery
Temporal async activities tool_activity never in workflow code; a pending approval becomes a signal wait, then a resume activity

Idempotency

Rule Why
pass the delivery id as message_id, or put run_id and call_id in the envelope a redelivered message is the same call: a confirm tool finds its own record instead of asking twice
use one source per broker ids from two brokers never collide
make side-effecting tools idempotent (pass call_id to the external API) a crash after the tool ran, before the ack, runs it again
a resume is safe to redeliver its decision is used once; a duplicate replies already_done and runs nothing

Security

Anyone who can publish to the queue can run the tools you serve. Restrict publish rights, pass tools= to handle_envelope, and keep destructive tools behind confirm: a message never approves anything (framework approvals), only a reviewer's decision in the shared store does. Records the HTTP tool server parked are refused (ToolUsageError), whether a message names their approval_id or their run_id and call_id; they resume over the server's own /approvals routes. Logs name tools, ids and error kinds, never arguments or results.

See also: Serverless, Pipelines, Approval stores.