Roles, participants, and models
Name protocol seats, choose participant kinds, and keep provider policy at the model boundary.
ap.roles('Writer', 'Reviewer') returns role objects. A role is an address in
the protocol; a participant is how that address is played. Pick the participant
from the work the role must do, not from the model you happen to have available.
Choose by duty
The same projected role can move from Agent to Script to Human without a
protocol rewrite. That substitution is the point of binding participants after
the global contract has been checked.
Agent
Agent lets a language model fill typed payloads and select one of the offered
message classes. It never routes the workflow. repair controls how many decode
corrections it may try; memory supplies an initial Brief when you need
context before the first envelope.
<!-- signature: ap.Agent -->
| Option | Type | Default | What it does |
|---|---|---|---|
model | LanguageModel | required | Fills fields and chooses declared classes. |
brief | str | '' | Instructions for this role. |
repair | int | 1 | Decode corrections offered to the model. |
memory | Brief | None | None | Initial role memory. |
import agentsparty as ap
Reader, Writer = ap.roles('Reader', 'Writer')
class Ask(ap.Message):
"""A question."""
text: str
class Answer(ap.Message):
"""The reply."""
text: str
session = ap.Session(
ap.Protocol(
ap.send(Reader, Writer, Ask),
ap.send(Writer, Reader, Answer),
),
{
Writer: ap.Agent(
ap.replies([Answer('ok')]),
'answer briefly',
repair=2,
)
},
)
answer = session.ask(Reader, Ask('q'), expect=Answer)The model boundary
ap.model('provider:name') is where a provider identifier becomes a
LanguageModel. Keep authentication, transport options, retries, fallback,
usage accounting, and provider-specific request fields at this boundary. The
Agent should know its brief and current offer, not provider credentials or
retry policy.
# requires: api-key
import agentsparty as ap
model = ap.model('openai:gpt-5.6-luna', timeout=30)Model wrappers return another LanguageModel, so written nesting determines
execution order:
| Wrapper | Purpose |
|---|---|
retrying(inner, attempts) | Retry an unavailable provider when another attempt is permitted. |
fallback(primary, *rest) | Try configured models in order. |
metered(inner, tokens=...) | Refuse a new call after the shared meter is spent. |
traced(inner, tracer=...) | Record model events through a tracer. |
# requires: api-key
import agentsparty as ap
primary = ap.model('openai:gpt-5.6-luna', timeout=30)
model = ap.traced(
ap.metered(
ap.retrying(primary, attempts=2),
tokens=8_000,
)
)ModelUnavailable means configuration, transport, or availability failed;
ModelRefused means the provider answered but declined the request. A decode
failure belongs to the message boundary, and an exhausted meter is a budget
failure. Keep those cases distinct when choosing retry or fallback behaviour.
Human, HumanIo, Cli, Desk, and Offer
Use Human when a person should author a choice or payload. Cli connects
that role to a console; Desk is the queue seam for a service.
Offer
An Offer represents what may be sent at a specific decision point:
| Field | Type | Meaning |
|---|---|---|
offer.subject | Role | The role making the decision. |
offer.receiver | Role | The role that will receive the chosen message. |
offer.messages | tuple[type[Message], ...] | Message classes currently permitted to be sent. |
An Offer is passed to HumanIo.choose(offer) and to functional models created with ap.replies(lambda offer: ...).
Desk in an async session
A Desk exposes offered message classes and accepts the selected value with
submit.
import asyncio
import agentsparty as ap
Approver, Reporter = ap.roles('Approver', 'Reporter')
class Incident(ap.Message):
"""An incident report."""
text: str
class Ship(ap.Message):
"""Ship the fix."""
desk = ap.Desk()
protocol = ap.Protocol(
ap.send(Reporter, Approver, Incident),
ap.choose(Approver, Reporter, {Ship: []}),
)
async def main() -> None:
session = ap.Session(
protocol,
{Approver: ap.Human(desk)},
)
run = session.start(Reporter, Incident('database timeout'))
while not desk.offered:
await asyncio.sleep(0.001)
await desk.submit(Ship())
transcript = await run.result()
asyncio.run(main())Use Cli for a terminal-driven program and Desk when another part of your
application will answer later. Do not call submit when no decision is
offered; that is a SessionError, and a class outside the offer is a
SelectionError.
Machine
Machine is a service role answered by a handler table. A handler receives the
request instance and returns a declared reply instance. It runs in the host
process, so Machine is a protocol boundary, not a sandbox.
| Handler entry | Meaning |
|---|---|
Request: handler | Accept this request class. |
handler(message) | Return one class currently on offer. |
| Missing or extra class | Rejected when the session binds. |
import agentsparty as ap
Client, Service = ap.roles('Client', 'Service')
class Lookup(ap.Message):
"""Find a record."""
key: str
class Found(ap.Message):
"""The record."""
value: str
protocol = ap.Protocol(
ap.send(Client, Service, Lookup),
ap.send(Service, Client, Found),
)
transcript = ap.Session(
protocol,
{
Client: ap.Script([Lookup('answer')]),
Service: ap.Machine({
Lookup: lambda message: Found(f'value:{message.key}')
}),
},
).run()Policy and View
Policy is the right fit when the decision depends on prior messages or the
current offer. Its function receives a View, not the machine or runtime, so a
policy can be tested as a pure decision.
View members
| Member | Type | What it returns |
|---|---|---|
view.seen | tuple[Envelope, ...] | All delivered envelopes seen so far. |
view.receiver | Role | This role's own identity. |
view.offered | frozenset[type[Message]] | Message classes currently on offer. |
view.last(Kind) | Kind | None | Newest message of Kind in seen history. |
view.sent(Kind) | tuple[Kind, ...] | Messages of Kind sent by this role, oldest first. |
view.got(Kind) | tuple[Kind, ...] | Messages of Kind received by this role, oldest first. |
view.count(Kind) | int | Total count of messages of Kind in seen history. |
import agentsparty as ap
class Draft(ap.Message):
"""A draft to review."""
text: str
class Approve(ap.Message):
"""Accept the draft."""
class Reject(ap.Message):
"""Reject the draft."""
reason: str
def decide(view: ap.View) -> ap.Message:
draft = view.last(Draft)
if draft is not None and len(draft.text) > 10 and Approve in view.offered:
return Approve()
return Reject(reason='draft is too short')
reviewer = ap.Policy(decide)Choose Policy for a small, history-based rule. Choose Machine for a direct
request-to-reply table; choose Agent when the content itself needs a model.
Script
Script is the deterministic participant for known messages, in order. It is
ideal for offline tests and replay fixtures. It is not a fallback participant:
when the protocol asks for one more message after the list ends, the run raises
SessionError for an exhausted script.
import agentsparty as ap
Author, Editor = ap.roles('Author', 'Editor')
class Ping(ap.Message):
"""A ping."""
cast = {Author: ap.Script([Ping()]), Editor: ap.Script([])}Binding is a contract check
The cast is not a loose bag of objects. Its keys must be exactly the roles in
the protocol, and each specification must be able to perform the projected
duties for that role. A missing role, an extra role, or a handler for the wrong
message is rejected while Session is constructed. The service can therefore
reject an invalid cast before it accepts work or starts a provider call.
Participants are factories in the cast, not long-lived runtime objects. A
Session can be run twice because it binds fresh participants for each run.
Do not keep mutable agent or machine state in a global object if a rerun should
start clean; pass an explicit factory or create the specification with the
right initial state.
Binding is the fail-closed boundary
If a role is missing, extra, or unable to perform its projected duties, fix the cast before accepting work. A later model correction cannot make an incomplete cast valid.
Choosing the smallest participant
| If the role needs to… | Start with | Why |
|---|---|---|
| Return a fixed fixture | Script | No decision logic or provider call. |
| Map a request to a result | Machine | The handler table is visible and direct. |
| Choose from history with a rule | Policy | The decision is a pure function of View. |
| Write or interpret language | Agent | A model fills declared content. |
| Wait for an operator | Human | Cli and Desk provide the I/O seam. |
It is common to mix them in one session: an Agent drafts, a Machine calls a
local service, a Policy decides a retry, and a Human approves the final
branch. They share the same projected endpoint, so the protocol remains
authoritative even when the implementation changes.
Participant state and replay
Agent memory is a brief over the messages it has seen, Machine memory is
whatever its handler closes over, and Policy receives a fresh View derived
from the session history. Journaling records authored decisions, not arbitrary
heap state. If a handler writes to an external system, replay can skip the
handler only when that decision was journaled; the external write still needs
an idempotency key or an application-level reconciliation path.
Related
- agentsparty.participants.Participant: the participant specification protocol.
- Sessions, execution, and limits: bind the cast and execute it.
- Messages: define the classes handlers and scripts carry.
- Test sessions offline: combine
Script,Machine, andrepliesin tests. - Embed and stream a live session: use
DeskandRuninside a service. - Error taxonomy: distinguish model, content, and session failures.