Guide

Messages

A message is a class. Its fields are the payload; its docstring is the intent.

A protocol message is a subclass of ap.Message. The class is the wire value: its fields are the payload, its docstring is the intent a model reads, and name = '…' in the class body overrides the wire label (default: the class name).

Use a message when a value crosses a protocol edge. Keep routing decisions in the protocol and business data in fields; that split makes both sides inspectable before a session starts.

A message is a boundary, not a bag of metadata

The class names the legal operation, the instance carries one occurrence, and the codec decides what can cross the wire. Keeping those roles separate is why handlers can work with typed attributes instead of raw dictionaries.

import agentsparty as ap


class Draft(ap.Message):
    """The chapter as drafted."""

    text: str


draft = Draft('hello')

Control and content

A message class sits on the boundary between protocol control and participant content:

ConcernOwnerConstraint
TopologyProtocolA participant cannot add a role or route.
VisibilityprojectionA role cannot react to an unseen branch.
Message classsend or chooseA participant must select a class on offer.
Field valueparticipantThe protocol does not invent payload data.
Refinementmessage codecInvalid data does not reach a handler.

The same rule applies to every participant kind. A model may choose Approve or Reject when both are offered; a machine handler may return one declared reply; a human may submit one offered class. None of them can create a third route at runtime.

Fields

Field count determines the wire shape: no fields carry None, one field carries that field's value, and several fields form a closed object.

import agentsparty as ap


class Ready(ap.Message):
    """Planning is finished."""


class Write(ap.Message):
    """Relative path and the full file contents."""

    path: str
    content: str


Ready().raw()
Write(path='a.py', content='x').raw()

A field may not be called name; that name overrides the label:

import agentsparty as ap


class GiveUp(ap.Message):
    """Why this will not converge."""

    text: str
    name = 'Give up'

Refinements

Annotated plus ap.where or ap.between defines boundary constraints. codec_of applies the predicate when the message is declared, so a value that fails it is refused at construction and decode.

HelperContract
ap.where(requirement, holds)Accept a value only when holds(value) is true.
ap.between(low, high)Accept a numeric value in the inclusive interval.
from typing import Annotated

import agentsparty as ap

RelPath = Annotated[str, ap.where('stays relative', lambda p: '..' not in p)]
Percent = Annotated[float, ap.between(0, 100)]


class Read(ap.Message):
    """A path to read."""

    path: RelPath


class Score(ap.Message):
    """A review score."""

    value: Percent


Read(path='src/main.py')
good = Score(87.5)

The predicate is not a routing rule. If a value is structurally valid but the workflow should take a different path, declare that path with choose rather than hiding business logic inside a refinement.

Intent

The class docstring becomes Message.__intent__. Agents use it as the meaning of a choice or payload, while the protocol still decides which class is legal. This is separate from a field's runtime constraint: intent guides the participant, while ap.where rejects a value that violates the contract.

import agentsparty as ap


class Ship(ap.Message):
    """Accept the current revision for delivery."""

Wire forms

raw() has one predictable shape at the boundary:

Declared fieldsPython valueWire value
0Ready()None
1Draft('text')'text'
NWrite(path='a', content='x'){'path': 'a', 'content': 'x'}
import agentsparty as ap


class Ready(ap.Message):
    """No payload."""


class Draft(ap.Message):
    """One text field."""

    text: str


class Write(ap.Message):
    """Two fields."""

    path: str
    content: str


Ready().raw()
Draft('text').raw()
Write(path='a', content='x').raw()

The message in a session

The same class travels through the whole path: ap.send names the protocol node, Draft('…') is the value, Machine keys a handler, expect=Draft types a host call, and transcript.last(Draft) reads the delivered history.

import agentsparty as ap

Author, Editor = ap.roles('Author', 'Editor')


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

    text: str


class Review(ap.Message):
    """The editor's response."""

    text: str


protocol = ap.Protocol(
    ap.send(Author, Editor, Draft),
    ap.send(Editor, Author, Review),
)
transcript = ap.Session(
    protocol,
    {
        Author: ap.Script([Draft('first')]),
        Editor: ap.Agent(
            ap.replies([Review('ship it')]),
            'review the draft',
        ),
    },
).run()

Class, instance, and wire value

These three forms answer different questions:

FormExampleQuestion it answers
ClassDraftWhich operation is legal here?
InstanceDraft('chapter 1')What content is being delivered?
Wire value'chapter 1'What crosses the serialization boundary?

Do not pass an instance to send; the protocol needs the class so it can project duties before execution. Do not pass a raw dictionary to a handler; the runtime decodes it into the declared class first.

Naming and compatibility

The class name is a useful default label because it gives renderings and transcripts a stable vocabulary. Use name only when the wire-facing label needs to be different, such as a human-facing Give up choice. Keep the Python class name stable as well: code, type checks, and transcript.last() use the class object, while journals store the encoded label and payload.

Adding a field changes the wire form from one value to an object and therefore changes the protocol identity. Treat it as a protocol revision, not a harmless implementation detail. In 0.2.x, start a new journal after that revision. An application that must retain old decisions owns and tests its conversion against both protocol versions; the library does not promise a stable journal migration format yet.

Stable

On this page