Guide

Sessions, execution, and limits

Construct a Session, execute a fresh run, and bound steps, loops, time, and model usage.

A Session is a protocol, a cast, and a budget. It is a value, not a state machine: run twice on one object gives two independent runs. The constructor checks well-formedness, projection, cast coverage, and participant compatibility before any model is called.

Construction is the safe boundary

Build a Session while registering work, then start it only after the cast, limits, journal, and tracer are in place. Provider calls belong to execution, not object construction.

Use run for a complete cast, ask when the host program owns the caller role, and start when a service needs a live handle before the conversation finishes.

import agentsparty as ap

Customer, Support = ap.roles('Customer', 'Support')


class Ticket(ap.Message):
    """A support question."""

    text: str


class Resolution(ap.Message):
    """The support reply."""

    text: str


protocol = ap.Protocol(
    ap.send(Customer, Support, Ticket),
    ap.send(Support, Customer, Resolution),
)
session = ap.Session(
    protocol,
    {
        Customer: ap.Script([Ticket('q')]),
        Support: ap.Agent(ap.replies([Resolution('ok')]), ''),
    },
)
first = session.run()
second = session.run()

Session construction and run lifecycle

Construction is the design-time boundary. The session checks the protocol, projects every role, compares the cast with those roles, and stores the allowance, journal, tracer, and session id. No model call belongs in this phase. A call begins only when run, ask, aask, or start is invoked.

The synchronous terminals drive the run to completion and return a Transcript. The asynchronous terminals use the current event loop; they are the right choice in an async web handler or worker. start differs from arun: it returns while the run is still active, which lets the application store a handle and serve status or human-decision requests.

One interaction step

For each send or choose, the runtime performs the same ordered work:

  1. Check and charge the applicable step and unfolding limits.
  2. Read a recorded decision from the journal, or ask the live sender.
  3. Append a new decision before delivery.
  4. Deliver the envelope to the receiver and continue along its branch.

Recording before delivery lets a resumed run recall an authored decision instead of paying for it again. It does not roll back an HTTP request, database write, or file change performed outside the journaled protocol decision.

ask is a host-owned caller

ask is not a shortcut for running a session with one missing participant. It binds a special caller that authors exactly the supplied opening message and then listens on that role's projected endpoint. The caller role therefore must be absent from the cast, while every other role must be present. expect is checked against the delivered class before the value is returned.

If the protocol has more than one opening message or the host must participate later as well, use start with a Desk or bind a Human instead. Keeping ask to one opening message makes its return type and failure mode predictable.

run, arun, and aask

MethodCallerReturn
run()all roles are in the castTranscript
arun()all roles, inside async codeawaitable Transcript
ask(role, message, expect=...)host plays one caller roleexpected message
aask(...)async form of askawaitable expected message
start(role, message)host opens a live runRun handle

ask(..., expect=Article) authors exactly one opening message and listens until the expected class arrives. The caller role is absent from the cast; supplying it is a projection error rather than a second participant.

import asyncio
import agentsparty as ap

Reader, Writer = ap.roles('Reader', 'Writer')


class Question(ap.Message):
    """The question."""

    text: str


class Article(ap.Message):
    """The answer."""

    text: str


async def answer() -> Article:
    return await ap.Session(
        ap.Protocol(
            ap.send(Reader, Writer, Question),
            ap.send(Writer, Reader, Article),
        ),
        {Writer: ap.Agent(ap.replies([Article('done')]), '')},
    ).aask(Reader, Question('why?'), expect=Article)


article = asyncio.run(answer())

Run state is observable, not editable

A Run exposes state that the application may read: id, done, the current transcript, and events. It does not expose a method for changing the next protocol step. Cancellation stops the run and records the cancellation signal; it cannot undo a tool call or an external write that already happened. Treat a run id as a lookup key, not as a lock held by the protocol.

start and Run

start requires a running event loop and schedules the session in the background. The returned Run exposes done, transcript, result(), cancel(), and the async events() iterator. A failed run reports its error from result(); it does not crash the host process.

import asyncio
import agentsparty as ap

Reporter, Approver = ap.roles('Reporter', 'Approver')


class Incident(ap.Message):
    """A ticket to review."""

    text: str


class Ship(ap.Message):
    """Ship the ticket."""


async def serve() -> None:
    desk = ap.Desk()
    run = ap.Session(
        ap.Protocol(
            ap.send(Reporter, Approver, Incident),
            ap.choose(Approver, Reporter, {Ship: []}),
        ),
        {Approver: ap.Human(desk)},
    ).start(Reporter, Incident('timeout'))
    while not desk.offered:
        await asyncio.sleep(0.001)
    await desk.submit(Ship())
    transcript = await run.result()


asyncio.run(serve())

Transcript and Envelope

A Transcript is the sequence of delivered envelopes in canonical order. Iterating it yields Envelope objects; messages contains the message classes in that history, and last(Kind) returns the newest instance or None. ended_at identifies the last receiver when the run ends.

Envelope

An Envelope is the routed carrier of a message. While a Message defines domain data, an Envelope records its transmission in the conversation:

FieldTypeMeaning
envelope.senderRoleThe role that authored and sent the message.
envelope.receiverRoleThe role that received the message.
envelope.labelLabelThe message's wire label.
envelope.messageMessage | NoneThe typed Message instance.
envelope.payloadobjectThe decoded message payload.
import agentsparty as ap

User, Assistant = ap.roles('User', 'Assistant')


class Ping(ap.Message):
    """A ping."""

    text: str


class Pong(ap.Message):
    """A pong."""

    text: str


transcript = ap.Session(
    ap.Protocol(
        ap.send(User, Assistant, Ping),
        ap.send(Assistant, User, Pong),
    ),
    {
        User: ap.Script([Ping('hi')]),
        Assistant: ap.Agent(ap.replies([Pong('yo')]), ''),
    },
).run()

envelope = list(transcript)[-1]

An empty transcript has no ended_at; a completed session always has a last receiver because a non-empty protocol delivers at least one message. Comparing messages with classes is safer than parsing str(transcript), which is meant for logs and people.

Allowance

The four limits are independent. None means unbounded, so an empty Allowance() does not impose a budget; steps and unfoldings stop before the next unit is paid, while token and wall-clock limits stop the next step after the receipt is known.

<!-- signature: ap.Allowance -->

FieldTypeDefaultWhat it bounds
stepsint | NoneNoneDelivered protocol steps.
unfoldingsint | NoneNoneLoop iterations.
tokensint | NoneNoneModel input plus output tokens.
wall_clockfloat | NoneNoneElapsed run time in seconds (e.g. 30.0).
import agentsparty as ap

User, Assistant = ap.roles('User', 'Assistant')


class Ping(ap.Message):
    """A ping."""


try:
    ap.Session(
        ap.Protocol(ap.send(User, Assistant, Ping)),
        {
            User: ap.Script([Ping()]),
            Assistant: ap.Script([]),
        },
        allowance=ap.Allowance(steps=0, wall_clock=10.0),
    ).run()
except ap.AllowanceExceeded as error:
    print(error.limit, error.spent)

The limits measure different resources. steps counts protocol work, unfoldings counts loop entries, tokens records provider usage, and wall_clock compares elapsed time. A token or wall-clock limit may be noticed after the current operation because the runtime needs the provider receipt; the next step is then refused. Design a user-facing retry or partial-result path around that distinction.

Per-step deadlines

within= on send or choose bounds the time one sender has to author that interaction. It accepts a datetime.timedelta and raises DeadlineExceeded when the selection does not finish within the window. Allowance.wall_clock measures the whole run instead.

from datetime import timedelta

import agentsparty as ap

Writer, Reviewer = ap.roles('Writer', 'Reviewer')


class Draft(ap.Message):
    """A draft to review."""

    text: str


protocol = ap.Protocol(
    ap.send(
        Writer,
        Reviewer,
        Draft,
        within=timedelta(seconds=5),
    )
)

Neither mechanism is a prepaid cost ceiling. Step and unfolding limits refuse work before the next unit starts. Token usage and elapsed time are observed after an operation has consumed them, so the runtime refuses the following step. Use provider-side limits when one completion itself must stay within a specific token or time bound.

Defaults and invariants

Omitted journal is in-memory, omitted tracer is a no-op, and omitted id is generated. A Session may be run twice; each call binds fresh participants. A live run may be cancelled, but cancellation does not make an external side effect exactly once.

Stable

On this page