Packages

Autonomous agents for Beam Bots.

Current section

Files

Jump to
bb_jido lib bb jido plugin robot.ex
Raw

lib/bb/jido/plugin/robot.ex

# SPDX-FileCopyrightText: 2026 James Harton
# SPDX-FileCopyrightText: 2026 Holden Oullette
#
# SPDX-License-Identifier: Apache-2.0
defmodule BB.Jido.Plugin.Robot do
@moduledoc """
Jido v2 plugin that gives an agent the ability to control a Beam Bots robot.
Provides:
- The standard robot-control actions: `BB.Jido.Action.Command`,
`BB.Jido.Action.Reactor`, `BB.Jido.Action.WaitForState`, and
`BB.Jido.Action.GetJointState`.
- Default signal routes for the canonical `bb.*` signal types, including
routes that keep the plugin state current: `bb.state.transition` is
routed to `BB.Jido.Action.UpdateSafetyState` and `bb.safety.error` to
`BB.Jido.Action.RecordSafetyError`.
- Plugin-owned state under `agent.state.robot`: the configured `:robot`
module, a cached `:safety_state` (updated by the routed
`UpdateSafetyState` action whenever a safety transition arrives), the
`:last_safety_error` (updated by `RecordSafetyError` when the
`[:safety, :error]` topic is bridged), and `:last_joint_state` (updated
whenever `GetJointState` runs).
- A supervised `BB.Jido.PubSubBridge` mounted under the agent process that
forwards BB PubSub events to the agent as Jido signals.
- An optional fail-closed safety gate: actions listed in
`:gated_actions` are refused before execution unless the robot's
safety controller reports `:armed`.
## Configuration
Plugin config (passed via `{BB.Jido.Plugin.Robot, %{...}}` when attaching
to an agent) is validated against the plugin's `config_schema` when the
agent is defined, so a missing, mistyped, or unrecognised option fails
fast with a schema error rather than surfacing later at runtime:
- `:robot` — robot module (required).
- `:topics` — list of `BB.PubSub` paths to bridge (default
`[[:state_machine]]`; replaces the default rather than adding to it).
- `:message_types` — payload modules to filter on at subscribe time
(default `[]`, meaning no filter).
- `:throttle_ms` — optional per-signal-type throttle in milliseconds.
- `:gated_actions` — list of action modules refused via
`prepare_action/3` unless `BB.Safety.state/1` reports `:armed`
(default `[]`). See below.
Bridged topics beyond the defaults need matching signal routes on the
agent (or plugin) — signals without a route are reported as routing
errors through the agent's error policy.
## Safety gating
With `gated_actions: [BB.Jido.Action.Command, BB.Jido.Action.Reactor]`,
any routed signal that resolves to one of those actions is refused with
`{:error, {:safety_not_armed, state}}` *before* the action executes,
using an authoritative `BB.Safety.state/1` read (fast ETS). This is the
plugin-level counterpart to the per-action `BB.Jido.Action.SafetyAware`
mixin: the mixin travels with the action module wherever it's used,
while the gate is enforced centrally for signal-routed execution on this
agent. The gate fails closed and cannot be bypassed by the routed
action's own params: a gated action whose params name a robot other
than the configured one is rejected with
`{:error, {:robot_mismatch, details}}` rather than authorised against
the wrong robot's safety state.
The gate only sees signal-routed execution — direct `run/2` calls (e.g.
reactor steps) bypass it, so keep using `SafetyAware` for actions that
must be guarded everywhere.
## Example
defmodule MyRobot.Agent do
use Jido.Agent,
name: "my_robot",
plugins: [
{BB.Jido.Plugin.Robot,
%{robot: MyRobot, gated_actions: [BB.Jido.Action.Command]}}
]
end
The plugin is a singleton: one robot per agent, and `as:` aliasing is
rejected at agent definition. To control several robots, run one agent
per robot.
"""
# Plugin name is `bb` so that Jido v2's plugin-route prefixing yields
# the canonical user-facing signal types: a route declared here as
# `command.execute` ends up as `bb.command.execute` after prefixing.
use Jido.Plugin,
name: "bb",
description: "Beam Bots robot control and observation for Jido agents",
category: "robotics",
tags: ["beam-bots", "robotics"],
vsn: Mix.Project.config()[:version],
capabilities: [:robot_control, :robot_observation],
# One robot plugin per agent: the bridge emits fixed `bb.*` signal
# types and the state-caching actions write to the fixed `:robot`
# slice, so an `as:`-aliased instance (state key :robot_left, route
# prefix left.bb) would receive no bridged signals and write effects
# to the wrong slice. Multi-robot agents need an alias-aware bridge
# and state ops first.
singleton: true,
state_key: :robot,
config_schema:
Zoi.object(
%{
robot:
Zoi.atom(description: "Beam Bots robot module")
|> Zoi.refine({__MODULE__, :validate_robot_module, []}),
topics:
Zoi.list(Zoi.list(Zoi.atom()),
description: "BB.PubSub paths to bridge (replaces the default)"
)
|> Zoi.default([[:state_machine]]),
message_types:
Zoi.list(Zoi.atom(),
description: "Payload modules to filter on at subscribe time ([] = no filter)"
)
|> Zoi.default([]),
throttle_ms:
Zoi.integer(description: "Minimum interval between same-type signals, in ms")
|> Zoi.min(1)
|> Zoi.optional(),
gated_actions:
Zoi.list(Zoi.atom(),
description: "Action modules refused via prepare_action/3 unless the robot is armed"
)
# A misspelt module atom would validate as an atom, never match
# the real routed action, and silently leave it ungated.
|> Zoi.refine({Jido.Plugin, :validate_plugin_actions, []})
|> Zoi.default([])
},
# A typo'd key (e.g. `gated_action:`) must be a hard error, not a
# silently dropped safety control.
unrecognized_keys: :error
),
actions: [
BB.Jido.Action.Command,
BB.Jido.Action.GetJointState,
BB.Jido.Action.Reactor,
BB.Jido.Action.RecordSafetyError,
BB.Jido.Action.UpdateSafetyState,
BB.Jido.Action.WaitForState
],
signal_routes: [
{"command.execute", BB.Jido.Action.Command},
{"reactor.run", BB.Jido.Action.Reactor},
{"safety.error", BB.Jido.Action.RecordSafetyError},
{"state.transition", BB.Jido.Action.UpdateSafetyState},
{"state.wait", BB.Jido.Action.WaitForState}
]
alias BB.Jido.PubSubBridge
@doc false
@spec validate_robot_module(term(), keyword()) :: :ok | {:error, String.t()}
def validate_robot_module(robot, _opts \\ []) do
if is_atom(robot) and not is_nil(robot) and Code.ensure_loaded?(robot) and
function_exported?(robot, :spark_is, 0) and robot.spark_is() == BB do
:ok
else
{:error, "expected a Beam Bots robot module (one that `use BB`), got: #{inspect(robot)}"}
end
end
@impl Jido.Plugin
def mount(_agent, config) do
case Map.fetch(config, :robot) do
{:ok, robot} when is_atom(robot) and not is_nil(robot) ->
{:ok,
%{
robot: robot,
safety_state: :unknown,
last_safety_error: nil,
last_joint_state: %{}
}}
_ ->
{:error, "BB.Jido.Plugin.Robot requires :robot module in config"}
end
end
@impl Jido.Plugin
def child_spec(config) do
agent_pid = self()
robot = Map.fetch!(config, :robot)
topics = Map.get(config, :topics, default_topics())
message_types = Map.get(config, :message_types, [])
throttle_ms = Map.get(config, :throttle_ms)
bridge_opts =
[
robot: robot,
agent: agent_pid,
topics: topics,
message_types: message_types
]
|> maybe_put(:throttle_ms, throttle_ms)
%{
id: {__MODULE__, :pub_sub_bridge, robot},
start: {PubSubBridge, :start_link, [bridge_opts]},
restart: :transient,
type: :worker
}
end
@impl Jido.Plugin
def prepare_action(_signal, action_arg, %{config: config}) do
case Map.get(config, :gated_actions, []) do
[] -> {:ok, %{}}
gated -> gate(action_arg, gated, Map.fetch!(config, :robot))
end
end
# Normalising through Jido's own Instruction.normalize/3 covers every
# action-argument shape Jido accepts (module, 2/3/4-tuples, keyword or
# map params, %Jido.Instruction{}, and lists thereof) — enumerating
# shapes by hand here is how gated forms slip through. An argument Jido
# itself cannot normalise could never execute, but the gate still fails
# closed rather than guessing.
defp gate(action_arg, gated, configured_robot) do
case Jido.Instruction.normalize(action_arg) do
{:ok, instructions} ->
targets =
for %Jido.Instruction{action: module, params: params} <- instructions,
module in gated,
do: {module, params}
case targets do
[] -> {:ok, %{}}
targets -> authorize_targets(targets, configured_robot)
end
{:error, reason} ->
{:error, {:ungateable_action_arg, reason}}
end
end
# The gate authorizes THIS plugin's robot, so a gated action whose params
# name a different robot must be rejected outright — authorizing the
# configured robot would otherwise approve a command aimed at a robot
# whose safety state was never checked.
defp authorize_targets(targets, configured_robot) do
case Enum.find(targets, &robot_mismatch?(&1, configured_robot)) do
{module, params} ->
{:error,
{:robot_mismatch,
%{configured: configured_robot, requested: params_robot(params), action: module}}}
nil ->
authorize_armed(configured_robot)
end
end
defp robot_mismatch?({_module, params}, configured_robot) do
case params_robot(params) do
nil -> false
robot -> robot != configured_robot
end
end
defp params_robot(%{robot: robot}), do: robot
defp params_robot(%{"robot" => robot}), do: robot
defp params_robot(_params), do: nil
defp authorize_armed(robot) do
case BB.Safety.state(robot) do
:armed -> {:ok, %{}}
other -> {:error, {:safety_not_armed, other}}
end
end
defp default_topics, do: [[:state_machine]]
defp maybe_put(opts, _key, nil), do: opts
defp maybe_put(opts, key, value), do: Keyword.put(opts, key, value)
end