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:
- Check and charge the applicable step and unfolding limits.
- Read a recorded decision from the journal, or ask the live sender.
- Append a new decision before delivery.
- 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
| Method | Caller | Return |
|---|---|---|
run() | all roles are in the cast | Transcript |
arun() | all roles, inside async code | awaitable Transcript |
ask(role, message, expect=...) | host plays one caller role | expected message |
aask(...) | async form of ask | awaitable expected message |
start(role, message) | host opens a live run | Run 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:
| Field | Type | Meaning |
|---|---|---|
envelope.sender | Role | The role that authored and sent the message. |
envelope.receiver | Role | The role that received the message. |
envelope.label | Label | The message's wire label. |
envelope.message | Message | None | The typed Message instance. |
envelope.payload | object | The 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 -->
| Field | Type | Default | What it bounds |
|---|---|---|---|
steps | int | None | None | Delivered protocol steps. |
unfoldings | int | None | None | Loop iterations. |
tokens | int | None | None | Model input plus output tokens. |
wall_clock | float | None | None | Elapsed 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.
Related
- agentsparty.runtime.Session: constructor,
run(), andask(). - Protocols and composition: the closed value a session executes.
- Journals, replay, and tracing: resume decisions and observe events.
- Embed and stream a live session: keep a
Runhandle in a service. - Test sessions offline: test sessions without a provider key.