Skip to content

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's forward(), write stdout.

  • 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. Leave action_name empty 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, the stdin / stdtar / stdext / stdunk routing above.
  • data_samples, data to ship directly, optionally as a {stream_hash: value} dict, instead of (or alongside) referencing streams.
  • num_steps, -1 for a single call, or >1 for 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