Skip to content

7 · Agents communicating

Build worlds · Chapter 7 of 12 · Path home

You have roles, behaviors, actions, and interactions. This chapter ties them together into the thing a world is really about: agents talking to each other. We will name the three shapes that communication takes, then watch all of them in the chat world, line by line.

First, find someone to talk to

Agents do not hard-code each other's names. They find peers by role.

  • connect_by_role(role) finds agents whose role matches and opens a connection to them. The chat user connects to whoever is the broadcaster.
  • find_agents(role) searches the peers already known, without necessarily connecting, useful when a master is gathering a group.

Both are built-ins; you call them by name from a behavior.

Focus · after connect_by_role, who did you actually reach?

connect_by_role(role) empties self._found_agents and refills it with the peers it newly connected to, then returns True if it opened at least one connection. So right after a successful call, self._found_agents holds the matches, and next(iter(self._found_agents)) is the usual way to grab one. Because every call clears the set, when you need several distinct peers resolve them one role at a time, connect to "sensor" and keep its id, then connect to "valve", rather than passing a list and losing track of which is which. The greenhouse controller does exactly this.

Engagement: forming a working pair

For back-and-forth work (a teacher and a student, a requester and a provider), agents do a light handshake called engagement before exchanging tasks: send_engage offers it, engage accepts it, disengage ends it. Engagement is what lets a teacher say "you and I are paired for this lesson" without either hard-coding the other. You will see it drive the teaching worlds in Chapter 8. The concept-level reference for finding, connecting, and engaging, and for why a request runs only when the target's behavior accepts it, is Connections and engagement.

The three shapes of communication

Almost everything agents do is one of these:

  • Point-to-point


    One agent sends directly to one other. A teacher sending a learn request to a student; a user sending its reply to the broadcaster.

  • Relay


    A hub agent receives from one peer and fans it out to many. The chat broadcaster is exactly this: every message passes through it to reach the room.

  • Pub/sub


    An agent publishes to a topic; any number of subscribers receive it without a direct link. Used for peer teaching in Chapter 9 (subscribe / send_subscribe).

Each shape (plus the engage handshake) has a worked, two-sided example in the communication scenarios, showing both agents' behaviors.

Streams the world itself owns

Agents are not the only source of data. A world can own and publish streams of its own, the shared environment every member can read. A teaching world uses this to be the dataset its students learn from:

# inside the World subclass
self.add_stream(DataStream.create(name="cats", public=False, ...))           # one stream
self.add_streams([DataStream.create(group="albatross", public=False, ...)])  # a group

Members address a world-owned stream by reference as <world>:<group> (the framework fills in <world>) and read it through stdext, exactly the way they read stdin (see An agent's own streams). cat_library publishes a cats stream this way; animal_school publishes one group per animal class.

A world-owned feed is often a topic: mark the stream pubsub=True and any number of members can subscribe to it and each receives every sample, the pub/sub shape above. That is how social_learning lets students learn from one chosen peer's stream without a direct link to it.

The chat world uses the point-to-point and relay shapes; let's read it.

The chat world, unrolled

chat is a star: every member connects to a single broadcaster, which has no model (proc=None) and exists only to pass messages along. The world's assign_role makes the very first master the broadcaster and everyone else a user, so the hub is elected automatically. Here is the whole round trip, and at each step we tie the narrative to the exact line of code that makes it happen.

The two behaviors driving this

There are only two state machines. The user moves init to waiting_handshake to ready, and from ready it loops out to message_sent and straight back. Its ready state runs check_messages on every tick, and the ready to message_sent transition runs generate_and_send. The broadcaster has a single ready state whose self-transition runs broadcast_message each time a request lands. Everything below is those few transitions firing in order.

1. A user connects to the broadcaster

The user behavior starts in init and its first transition runs connect_to_broadcaster. The action does two distinct things before it can return True. First it grabs a handle to its own processor output stream with get_stream("processor", ...), it needs this later both to read back its own messages (so they show in its local history) and to tell the broadcaster which stream carries the text it generated. If that stream cannot be found the action returns False and is retried next tick. Second, it calls the connect_by_role built-in, which finds peers matching the role and opens connections to them. On success the action reaches into self._found_agents (which connect_by_role just refilled, see the Focus box above) and remembers the broadcaster's peer ID, that single identifier is what every later send will target.

chat/src/user.py
@action
async def connect_to_broadcaster(self, role: str) -> bool:
    self._user_stream = self.get_stream("processor", data_type="text")
    if self._user_stream is None:
        return False
    if await self.connect_by_role(role):                  # find + connect
        self._broadcaster_peer_id = next(iter(self._found_agents))  # remember it
        self._last_msg_time = tm.time()
        return True
    return False

Once connected confirms the handshake, the behavior moves to ready and the back-and-forth begins.

2. The user watches the room

New to proc_input_0 / stdin?

These are an agent's fixed input/output slots. If the names are unfamiliar, read An agent's own streams first, it explains the whole convention in one place.

The ready state itself carries an action: check_messages runs on every clock tick while the member is idle in the room. It is pure reading and deciding, it sends nothing. Walking through what it does:

  1. On its first run it resolves self._broadcaster_stream by calling get_stream("processor", peer_id=self._broadcaster_peer_id, ...), the same built-in as step 1, but pointed at the broadcaster's peer ID rather than its own, so now it can read what the hub publishes.
  2. It pulls new samples from its own output stream (self._user_stream.get("check_messages", ...)) and appends them to a short rolling self._last_turns history. This is why a member sees its own messages in context, not just other people's.
  3. It pulls new samples from the broadcaster's stream the same way and appends those too. The requested_by="check_messages" tag gives single-delivery: each broadcast is handed to this reader once, so the member reacts to genuinely new messages, not the same one repeatedly.
  4. It then decides whether this member should speak. The condition (only for non-human, model-backed members) fires when the member's name appears in the message, when the room is down to just this pair, or on a random nudge below talk_probability. When it decides yes, it builds an instruction prompt that includes the recent turns and writes it with self.stdin.set("proc_input_0", ...).
  5. If the broadcaster's stream had no new messages and the room has gone quiet for longer than max_silence_seconds, it instead stages a conversation-promoting prompt, this is the "break the silence" skill every AI member inherits.

The key point: check_messages only ever stages text into stdin. It does not run the model and does not send. That work is deliberately split off into the next transition, so the ready state stays cheap to run every tick.

chat/src/user.py (trimmed)
@action
async def check_messages(self, max_silence_seconds=10.0, talk_probability=0.333,
                         history_len=3) -> bool:
    msgs = self._broadcaster_stream.get("check_messages", all_uuids=True)
    if msgs and should_reply(msgs):             # named, quiet, or random nudge
        self.stdin.set("proc_input_0", build_prompt(self._last_turns, msg))  # stage it
    return True                                 # never sends; only prepares stdin

3. The user generates and sends its reply

The ready to message_sent transition runs generate_and_send. This is the action that actually produces and ships the message, in two moves:

  1. Run the model. await self.process() takes whatever check_messages staged in stdin under "proc_input_0", runs the member's own model, and writes the result to stdout. If it is not ready yet process() returns False, so the action returns False and the transition simply retries on the next tick, the wait costs you nothing.
  2. Send it to the broadcaster. It builds a handle to its own output stream with DataProps.build_user_hash(self.get_peer_id(), self._user_stream.props.get_name()), a canonical name for "the text I just generated", and passes that as the streams argument of a send.
chat/src/user.py
@action
async def generate_and_send(self, samples: int = 1) -> bool:
    if not await self.process():                # stdin -> model -> stdout
        return False
    my_processor_user_hash = DataProps.build_user_hash(
        self.get_peer_id(), self._user_stream.props.get_name())
    return await self.send(action_name="broadcast_message",
                           target=[self._broadcaster_peer_id],   # found in step 1
                           streams=[my_processor_user_hash],     # handle to my own output
                           num_steps=samples, copy_sys=True)

This is a point-to-point send with a named action: it says "broadcaster, run your broadcast_message action, and the text to run it on lives in this stream of mine." Three pieces make it work, and none are conjured on the spot. target is self._broadcaster_peer_id, the peer ID remembered in step 1. streams is the hash of the user's own processor output, the stream located in step 1, the broadcaster will read the generated text from there. And copy_sys=True lifts the output process() just wrote (which is bound to the internal system interaction id) into this send's interaction, so the broadcaster receives exactly the text that was generated rather than a stale value. After the send, message_sent to ready fires on a nop and the member is back to watching.

4. The broadcaster relays to everyone

The user's send from step 3 lands on the broadcaster as an interaction, which triggers its lone ready to ready transition and runs broadcast_message. Because the user named the action and pointed streams at its own output, the framework binds that text into the broadcaster's stdin under the interaction's uuid. The action then:

  1. Identifies the sender. It looks up interaction.requester in self.all_agents and reads its node_name from the static profile, so the relayed line can be prefixed with a readable name. (If the requester vanished mid-flight, it returns True and lets the framework close the interaction cleanly.)
  2. Reads the text. self.stdin.get(uuid=interaction.uuid, requested_by="broadcast_message") pulls the user's generated message, scoped to this interaction's uuid, which is why the broadcaster always reads the right sender's text even with many requests in flight.
  3. Fans out. It computes other_users as every world agent except the original sender and itself, then calls send with data_samples and no action_name.
chat/src/broadcaster.py
@action
async def broadcast_message(self, interaction: Interaction | None = None) -> bool:
    sender = self.all_agents[interaction.requester].get_static_profile()["node_name"]
    msg = self.stdin.get(uuid=interaction.uuid, requested_by="broadcast_message")
    others = list(set(self.world_agents.keys()) - {interaction.requester, self.get_peer_id()})
    return await self.send(target=others,
                           data_samples={"proc_output_0": f"**{sender}:** {msg}"},
                           num_steps=1)

This is the relay, and the contrast with step 3 is the whole lesson. The user's send carried action_name="broadcast_message", it asked the hub to do something. The broadcaster's send carries no action_name: it is a pure data push. The payload {"proc_output_0": ...} is simply written into each recipient's local copy of the broadcaster's output stream slot. No transition fires on the receivers, nothing is "called" on them, the message just appears in the stream that their check_messages is already polling. One message in, a fan-out to many, with the broadcaster owning no model at all (proc=None); the relay is entirely manual.

5. Everyone reads it, and the loop turns

Now the circle closes. Because step 4 wrote into each recipient's copy of the broadcaster's stream, the next tick of each member's check_messages (step 2) finds a new sample waiting: it appends the line to self._last_turns, and may decide to reply, which sends us straight back to step 3. There is no separate "receive" action, the relay's data push is the input that the watcher loop was already reading. A human member is identical from the broadcaster's point of view: they type instead of running a model, but they still arrive through the same broadcast_message request, and the broadcaster cannot tell the difference.

graph LR
    U1[User / Human] -->|broadcast_message| B[Broadcaster<br/>proc = None]
    B -->|data push| U2[Other users]
    B -->|data push| U3[Other humans]
    U2 -->|their replies| B

What just happened

You watched a complete world communicate: members found the broadcaster by role, connected, and exchanged messages through a relay, using both a named-action send and a pure data push. The same three shapes, point-to-point, relay, and pub/sub, are all you need to build any world, including the teaching worlds we turn to next.

Where next