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 chatuserconnects to whoever is thebroadcaster.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
learnrequest 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
broadcasteris 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.
@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:
- On its first run it resolves
self._broadcaster_streamby callingget_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. - It pulls new samples from its own output stream
(
self._user_stream.get("check_messages", ...)) and appends them to a short rollingself._last_turnshistory. This is why a member sees its own messages in context, not just other people's. - 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. - 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 withself.stdin.set("proc_input_0", ...). - 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.
@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:
- Run the model.
await self.process()takes whatevercheck_messagesstaged instdinunder"proc_input_0", runs the member's own model, and writes the result tostdout. If it is not ready yetprocess()returnsFalse, so the action returnsFalseand the transition simply retries on the next tick, the wait costs you nothing. - 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 thestreamsargument of asend.
@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:
- Identifies the sender. It looks up
interaction.requesterinself.all_agentsand reads itsnode_namefrom the static profile, so the relayed line can be prefixed with a readable name. (If the requester vanished mid-flight, it returnsTrueand lets the framework close the interaction cleanly.) - 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. - Fans out. It computes
other_usersas every world agent except the original sender and itself, then callssendwithdata_samplesand noaction_name.
@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¶
-
Point-to-point work between a teacher and a student: teach, exam, grade.
-
connect_by_role,engage,subscribe, and the rest. -
The reference on what flows between agents.