Interactions¶
If data streams are what agents exchange, interactions
are how they ask each other to act. Every time one agent wants another (or
itself) to do something, process an input, learn from it, send a message, an
Interaction is created to track that request from start to finish.
Interactions are the common thread under every learning path: a human typing, a lone wolf answering, members chatting in a community, all of it is interactions flowing each clock tick.
In one sentence
An interaction = "please run this action, on these streams, and tell me when you're done." The HSM decides which interactions to fire; the clock advances them.
The loop: perceive, process, act¶
UNaIVERSE is time-stepped (see the clock). On each tick, an agent's behavior advances one step of the same cycle:
graph LR
P[Perceive<br/>read input streams] --> T[Process<br/>run forward / decide]
T --> A[Act<br/>write output streams<br/>or send a request]
A -->|next tick| P
You don't write this loop. You declare behavior as a hybrid state machine, and the HSM fires the right interaction at the right time.
The core actions¶
Most interactions invoke one of a few built-in agent actions. You reference them by name from an HSM transition (or call them directly).
-
send()
Ask one or more target agents to run an action, optionally carrying data. The backbone of agent-to-agent communication.
-
process()
One inference step: read
stdin, call the processor'sforward(), writestdout. -
learn()
One learning step: process, then a backward pass with the configured optimizer and loss. The basis of continual learning.
-
find_agents()·connect_by_role()
Discover peers by role and open connections, how an agent finds who to interact with.
Each built-in action receives the Interaction it is running as its argument,
e.g. async def process(self, interaction=None), so the action can read the
exact streams and uuid bound to that request.
Custom actions¶
Beyond the built-ins, you can give an agent its own actions by subclassing
Agent and decorating a method with @action. This is how
world roles get role-specific behavior: the method name is what an
HSM transition references by action=....
from unaiverse.agent import Agent, action
from unaiverse.interaction import Interaction
class Relay(Agent):
@action
async def broadcast_message(self, interaction: Interaction | None = None) -> bool:
# read the sender's text bound to THIS interaction's uuid
msg = self.stdin.get(uuid=interaction.uuid, requested_by="broadcast_message")
# push it on to other peers (a pure data send, no action_name needed)
return await self.send(target=self._others,
data_samples={"proc_output_0": msg}, num_steps=1)
An action returns True when it succeeds (advance the state machine) or False
to retry on the next tick. It can read and write streams, call self.process()
or self.learn(), and dispatch further interactions with self.send(...).
Stream routing: stdin, stdtar, stdout¶
An interaction connects specific streams for the duration of the request. The routing keys mirror a familiar idea, standard in/out, extended for multi-peer exchange:
| Key | Role |
|---|---|
stdin |
the input stream(s) the action reads |
stdtar |
the target stream(s), e.g. ground truth for learn() |
stdout |
where the result is written |
stdext / stdunk |
extra / unknown auxiliary streams |
# Conceptually, an interaction says:
# "run `process`, reading stream A into stdin, writing the result to stdout"
{"action_name": "process", "streams": {"stdin": ["stream_A"], "stdout": ["stream_B"]}}
You rarely build these by hand, the HSM and built-in actions assemble them for you. It's worth knowing the shape, because world behaviors reference these stream hashes directly.
The request lifecycle¶
Every interaction moves through a defined status sequence, so both sides know where a request stands:
sequenceDiagram
participant R as Requester
participant T as Target
R->>T: Interaction(action, streams, timeout)
Note over T: queued, then running
T->>T: run the action across one or more ticks
T-->>R: completion status (+ optional callback)
Key properties of an Interaction:
action_name/action_kwargs, which action to run, and its arguments. Leaveaction_nameempty for a pure data push: the target just stores the data, no transition required on its side.target, one or more peers the request is addressed to.streams, thestdin/stdtar/stdext/stdunkrouting above.data_samples, data to ship directly, optionally as a{stream_hash: value}dict, instead of (or alongside) referencing streams.num_steps,-1for a single call, or>1for a multi-step interaction that spans several clock cycles.from_state/to_state, the HSM transition this interaction is tied to.timeout, max seconds before the interaction is auto-expired.callback, a method to call automatically on completion.volatile, fire-and-forget: don't wait for a completion status.
Internally each interaction also carries a status (InteractionStatus), a
type (InteractionType: sent, received, or lazily-registered), and, once
finished, a CompletionReason (ok, timeout, rejected, disconnected, …) so
both sides always know where a request stands.
The InteractionManager registers each interaction, stamps the requester,
matches streams to the right routing slots, applies global timeouts, and drives
the queue every tick.
Where next¶
-
The engine that decides which interaction to fire, and when.
-
What flows through
stdin/stdout, the typed channels. -
Behaviors reference these actions and stream routes by name.
-
Every parameter, status, and completion reason.