6 · How interactions travel¶
Build worlds · Chapter 6 of 12 · Path home
When one agent gets another to do something, an interaction carries the
request from start to finish. You met send already; this chapter opens it up,
because once you understand how a request travels, every world in the examples
becomes readable.
The shape of it
send(...) creates an Interaction: "please run this action, on these
streams, and tell me when you're done." The target runs it, results flow back
on the bound streams, and a completion status returns. The whole thing is
paced by the clock, one step per tick.
See both sides, with diagrams
The crucial subtlety, that a request only runs if the target's behavior accepts it, plus worked two-agent and N-agent flows, is laid out with diagrams in How a request actually works and the communication scenarios. Read this chapter for the what, that page for the who does what.
Two ways to send¶
The first decision is whether you are asking the target to run an action, or just handing it data.
Pass an action_name. The target runs that action on what you send. The
chat user asks the broadcaster to run its broadcast_message action:
await self.send(action_name="broadcast_message",
target=[self._broadcaster_peer_id], # the broadcaster it connected to
streams=[my_processor_output_hash], # a handle to its OWN output stream
num_steps=samples, copy_sys=True)
Two identifiers here are worth naming. self._broadcaster_peer_id is just the
peer the user found and connected to earlier (connect_by_role,
Chapter 7). my_processor_output_hash is a handle to
the user's own output stream, the text it produced, which it is asking the
broadcaster to read; you obtain it from get_stream("processor") (its
props carry the hash). You never invent these strings by hand: you look a peer
up by role, and a stream up by name.
Pass no action_name. The target simply stores the data you send,
no action runs, no transition fires on its side. The broadcaster fans a
message out to everyone this way:
await self.send(target=other_users,
data_samples={"proc_output_0": f"**{sender}:** {msg}"},
num_steps=1)
New to proc_output_0? It is an agent's fixed output slot,
An agent's own streams explains the
naming convention in one place.
That single distinction, run something versus here is data, underlies relay, teaching, and peer learning alike.
Focus · what happens on the wire, line by line
Read the first call as four decisions, because every send you ever write is
the same four:
action_name="broadcast_message"names the action you want the target to run. The string must match an action that the broadcaster's class actually defines, and (crucially) one its behavior is currently willing to run, more on that just below.target=[self._broadcaster_peer_id]says who. A list of peer ids, here a list of one.self._broadcaster_peer_idis not a string you typed: the user found the broadcaster earlier by role and stashed its id (Chapter 7).streams=[my_processor_output_hash]binds your own output stream to the request. You are not handing over a copy of the text; you are attaching a live handle to the stream the text lives on, which the framework routes to the broadcaster'sstdin.num_steps=samples+copy_sys=Trueset the rhythm and the payload:num_stepsis how many samples flow (one per tick), andcopy_syslifts the value you just produced (withprocess()) into this fresh interaction so it actually rides along. Withoutcopy_sys, the new interaction would start empty.
When send runs, the framework builds an Interaction object, resolves the
targets, and dispatches it. On the broadcaster, the request arrives, its
broadcast_message action reads your stream off its stdin, and a completion
status travels back to the user. That return value is what lets the user's
behavior move on.
The pure data push drops decision 1 entirely. With no action_name, the
target runs nothing: the framework just deposits your data_samples onto the
target's input stream and that is the end of it. num_steps=1 ships exactly one
sample. Use this when the receiver already has a behavior watching that stream
and you only need the value to be there (the chat broadcaster's downstream
users are exactly that case).
A request only runs if the target accepts it
This is the single most important thing to internalise about send, and it is
easy to miss because nothing in the caller hints at it. Calling
send(action_name="broadcast_message", ...) does not force the broadcaster
to run broadcast_message. The request only fires if the broadcaster's behavior,
in the state it is currently in, has a transition that accepts it, a
self-loop declared with ready=False:
# in the chat world's behavior for the broadcaster role:
behav.add_transit("ready", "ready", action="broadcast_message", args={}, ready=False)
ready=False is what marks a transition as "do not fire this on my own clock;
fire it only when someone sends me a matching request." So the contract has two
sides: the caller names an action and a target, and the target must be sitting in
a state whose behavior is willing to run that action on request. If it is not,
the request simply waits or expires, it never barges in. The full two-agent and
N-agent picture, with diagrams, is in
How a request actually works.
The parameters that matter¶
send has many options; these are the ones you will actually reach for.
action_name- The action the target runs, or
Nonefor a pure data push (above). target- One peer id, a list of them, or a wildcard like
"<valid_cmp>"(the agents that just passed an evaluation). Onesendcan address a whole group. streams- Which streams to bind for this request. They fill the routing slots, see below.
data_samples- Concrete values to ship directly, often a
{stream_label: value}dict, instead of (or alongside)streams. num_steps- How many steps this interaction runs.
-1is a one-shot control message;>0means it spans that many samples over several ticks (a 998-token lesson isnum_steps=998). copy_sys- Lift the output the agent just produced into this new interaction, so a value computed in one step can be shipped in the next. The chat user uses it to forward the reply it just generated.
wait_completion- Keep the request "live", re-checking each tick, until every target reports done. This is how a teacher blocks on "all students finished this lesson".
callback- The name of an action to run automatically when the request completes, the basis of the round coordination in Chapter 9.
volatile- Fire and forget: the target is asked not to send a completion status back.
timeout- Seconds before the interaction is auto-expired, so one slow peer cannot stall a group.
The complete signature, with every parameter, is in the built-in actions reference.
Two of these change who waits for whom
Most parameters describe the request; two describe how the caller behaves afterwards, and they are worth separating in your head:
wait_completion=Truekeeps the calling action returningFalse(the Chapter 5 "retry is the wait" pattern) until every target reports done. The framework re-enters your action each tick, checks the tracked interaction, and only returnsTrueonce it is completed. Use it when the next step genuinely cannot start until the work is finished, a teacher waiting on "all students done".callback="some_action"does the opposite: the caller does not block. It fires off the request and registers an action to run automatically when the completion comes back later. This is how round coordination stays non-blocking (Chapter 9). Note the framework forbids pairingcallbackwithvolatile: a fire-and-forget request sends no completion, so the callback could never run.
If you want neither, you send and move on, and gate a later state on
all_sent_completed
when you eventually need to know everything landed.
Stream routing: where the data goes¶
An interaction connects specific streams for the duration of the request, into named slots:
| Slot | Role |
|---|---|
stdin |
the input the action reads |
stdtar |
the target (e.g. ground truth for learn) |
stdout |
where the result is written |
stdext / stdunk |
extra / auxiliary streams |
You usually do not fill these by hand; you pass streams (or data_samples) and
the framework matches them to the right slots. The teacher's learn request,
for instance, binds the lecture to stdin and the same stream to stdtar so the
student trains to reproduce it:
await self.send(action_name="learn",
streams={"stdin": ["<playlist>"], "stdtar": ["<playlist>"]},
num_steps=998, wait_completion=True)
Walk this one slot by slot, because it shows the explicit form of streams (a
dict keyed by slot, instead of the bare list the broadcaster used):
action_name="learn"asks the student to run itslearnaction, the built-in training step. As always, this only fires if the student's behavior is sitting in a state that accepts alearnrequest.streams={"stdin": [...], "stdtar": [...]}fills two slots by hand. The same"<playlist>"stream is bound to bothstdin(what the student reads) andstdtar(what it should reproduce), so the student trains to output exactly the lecture it is being fed. Binding the same stream to input and target is the essence of "learn to reproduce this".num_steps=998makes this a long, multi-step interaction: 998 samples flow, one per tick, so the lesson is consumed over time rather than in one blob.wait_completion=Trueholds the teacher in place until the student has worked through all 998 steps and reported done, before the teacher moves on (to the exam, the next lesson, and so on).
On the target side, process reads stdin, runs the
processor, and writes stdout; learn does that and then
a backward pass against stdtar.
Focus · what the data_samples key names, and where the value lands
data_samples={"proc_output_0": value} is the one part of send that trips
people up. The key is not the recipient's slot, it is a stream you own.
The framework writes value onto that stream of yours and ships it with the
interaction, where it arrives on the target's stdin for its action to read.
The canonical key is proc_output_0, your own output slot (the very one
process writes to), which is exactly what the chat broadcaster pushes above.
So the rule is simple: push from a stream you own; the target reads it as
input. The pull-versus-push version, worked line by line, is in
Chapter 11.
Multi-step interactions¶
A single interaction can run for many ticks. With num_steps=N, the target
processes N samples, one per step, and the interaction completes after the last
one. This is how a lecture, an exam, or a generated signal flows: not as one big
blob, but as a stream the agent consumes over time, the heart of the
Collectionless AI idea that intelligence
happens over time, not in a single shot.
The lifecycle, and the manager¶
Every interaction moves through a defined sequence of states so both sides always know where it stands:
sequenceDiagram
participant R as Requester
participant T as Target
R->>T: Interaction(action, streams, num_steps)
Note over T: received, then running
loop each tick, num_steps times
T->>T: run one step (read stdin, act, write stdout)
end
T-->>R: completion status (+ optional callback)
Internally an interaction is created, then requested (on the sender) or
received (on the target), then running, then completed with a reason
(ok, timeout, rejected, disconnected). You never drive this by hand: each agent's
InteractionManager registers interactions, matches their streams to the
routing slots, applies timeouts, advances them every tick, and fires callbacks on
completion. Your job is to call send with the right options; the manager does
the rest.
Focus · when does a received request actually get to run?
On the target, an interaction does not run the instant it arrives. Every tick the manager asks whether it is doable, and it is doable only when three things hold at once:
- it is not already completed;
- the requester is a known agent (you cannot be driven by a peer you are not connected to); and
- all of its bound streams carry fresh data, a sample the target has not yet consumed for this interaction.
Point 3 is why nothing fires on a tick where no new sample has arrived: the
request waits, harmlessly, until there is something to act on. Combined with the
ready=False acceptance rule above, this is the whole story of why a request
runs when it does: the right state must accept it, and there must be fresh data to
feed it. Everything else, the timeouts, the per-target completion tracking, the
callbacks, the manager handles so your send stays a single line.
What just happened¶
You can now read any send in the examples and know what travels where: whether
it runs an action or pushes data, which streams bind to which slots, how many
steps it spans, and how completion comes back. That is the language the whole next
half of this path speaks.
Where next¶
-
Discovery, engagement, and these sends in the live
chatworld. -
The full
sendsignature and every related action. -
The reference overview.