Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
972841b
feat(mesh): bicycle-model twist-to-servo conversion for Ackermann robots
Vivek0712 Jul 3, 2026
a6f8007
feat(mesh): AckermannRosRobot core - construction, enable handshake, …
Vivek0712 Jul 3, 2026
d99f29e
feat(mesh): AckermannRosRobot.drive - enable gate, duration cap, trai…
Vivek0712 Jul 3, 2026
9580197
feat(mesh): AckermannRosRobot agent tools + package export
Vivek0712 Jul 3, 2026
c1636f2
docs: DeepRacer example + Ackermann section in ros2-integration + cha…
Vivek0712 Jul 3, 2026
667db13
fix(mesh): harden AckermannRosRobot drive - finite-input guards, time…
Vivek0712 Jul 3, 2026
2be59da
Merge branch 'main' into feat/ackermann-ros-robot
cagataycali Jul 28, 2026
b0f99c3
merge: bring branch up to main, move changelog entry to a news fragment
Vivek0712 Jul 29, 2026
42d8d3e
Merge remote-tracking branch 'origin/main' into ack
Jul 31, 2026
185c123
fix(mesh): route AckermannRosRobot through the shared drive-command d…
Jul 31, 2026
8d6a4c4
Merge branch 'main' into feat/ackermann-ros-robot
cagataycali Aug 1, 2026
4c7aebb
Merge remote-tracking branch 'upstream/main' into HEAD
strands-agent Aug 24, 2026
38bf62d
fix(mesh): render a half-built AckermannRosRobot instead of raising i…
strands-agent Aug 24, 2026
be6be04
test(mesh): triage AckermannRosRobot into the drive/stop survey inven…
strands-agent Aug 24, 2026
fcb5ed4
fix(mesh): report a failed trailing halt instead of the drive's success
Aug 24, 2026
43ee3d5
fix(mesh): route the Ackermann bridge's commands through the use_ros …
Aug 24, 2026
d44be59
docs(agents): record that a new use_ros bridge ships un-gated
Aug 24, 2026
02732fa
Merge main into feat/ackermann-ros-robot-current
Aug 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,28 @@ hatch run format # ruff check --fix, ruff format
has to be pinned structurally. Pinned by
tests/mesh/test_zenoh_transport_bypasses_backend_routing.py.

18. **A new bridge is un-gated until its surfaces are named, and refused until
it forwards the context** - `use_ros`'s operator-approval gate is keyed on
the graph name of the surface a command targets, so it knows nothing about
a transport added later: a bridge whose topics and services match no
`_command_gate.COMMAND_BLOCKLIST` entry sends LLM-initiated motion with no
prompt, no allowlist check and no audit row, and its tests stay green
because a bridge suite patches the module's `use_ros` symbol at the
boundary the gate lives behind. The two halves are one change and neither
ships alone: naming the surfaces without threading `tool_context` through
every `use_ros` call and declaring the command tools `@tool(context=True)`
converts the silent bypass into a fail-closed refusal of the whole command
surface, `stop` included, and threading the context without naming the
surfaces changes nothing at all. Spell blocklist entries bare - matching is
on the final path segment, so one `/manual_drive` entry covers every
namespaced instance - and state the posture where an operator sizes a
pre-approval (`docs/ros2-integration.md`, `docs/security.md`, the example),
because `STRANDS_ROS2_COMMAND_ALLOW` is what makes a headless run work and
it cannot be discovered from a refusal that has not happened yet. Pinned by
tests/mesh/test_ackermann_command_gate.py, whose inventory of bridges owing
a gate suite is derived from the tree, so the next transport is graded on
arrival rather than at the review that happens to look.

## PR Workflow

1. Create the feature branch **on your fork**. Branch creation in the base
Expand Down
55 changes: 55 additions & 0 deletions changelog.d/1035-ackermann-ros-robot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
### Added: `AckermannRosRobot` - Ackermann ROS 2 cars (AWS DeepRacer) as strands robots

`RosBridgedRobot` covers differential-drive bases (Twist on `cmd_vel` +
odometry); Ackermann cars expose neither. The new bridge keeps the same
`drive(linear, angular, duration)` agent contract and converts through a
bicycle model to normalized servo commands (`ServoCtrlMsg` on the DeepRacer),
runs a declarative `init_services` handshake once before the first command
(the DeepRacer's manual-mode two-step, preconfigured in `from_deepracer()`),
clamps speed, rejects over-long holds loudly, and always trails timed
commands with a zero servo message so a timed drive cannot leave the car
driving (a bare single-shot command latches until stop, matching raw servo
semantics). Conditional `get_scan` tool; no `get_pose` (the stock platform has
no odometry).

`drive` and the constructor limits validate through the same
`finite_number_error`, `positive_finite_number_error` and
`positive_whole_number_error` domains the three differential-drive bridges
call, so the fourth transport refuses an unusable velocity, hold or count with
byte-identical text rather than a hand-rolled copy of the contract.

`__repr__` renders a half-built instance rather than raising. The constructor
validates `node_name` before assigning it, so a refused construction left an
instance whose `repr` raised `AttributeError: 'AckermannRosRobot' object has no
attribute 'node_name'` - sending a reader after the attribute instead of the
`ValueError: invalid node_name` they already caused. It now delegates to the
single owner of that wording, `strands_robots.utils.partial_construction_repr`,
as the three differential-drive bridges do, and the class is triaged into the
survey in `tests/test_repr_survives_partial_construction.py` (both the
half-built sweep and the documented-refusal cases).

A timed `drive()` whose trailing halt fails now reports that failure instead of
the main publish's success. `use_ros` reports a transport failure as an error
dict rather than raising, and the halt's verdict was discarded - so a node dying
mid-hold left the call returning `success` for a car still holding the commanded
throttle, on a tool whose description promises that a command with a duration
stops itself. The result now names the published command, the failed halt and
its cause, so the agent's next action is `stop`.

Its command surfaces now reach the `use_ros` operator-approval gate. Two halves
were missing. The gate only fires on a blocklisted surface, and the stock
DeepRacer wiring - `/webserver_pkg/manual_drive` plus the `/ctrl_pkg` mode
services that arm the vehicle - matched no entry, so every servo publish and
handshake call went out with no prompt, no allowlist check and no audit row while
the functionally identical `/motor_enable` was blocked; `/manual_drive`,
`/vehicle_state` and `/enable_state` are now default blocklist entries, spelled
bare so the base-name rule covers the namespaced spellings. And the bridge
forwarded no context, so a fix to the first half alone would have made every
command - `stop` included - a fail-closed refusal: `drive`, `stop` and `enable`
now take a `tool_context` and thread it into every `use_ros` call, and the
`drive_<node>`/`stop_<node>` tools are declared `@tool(context=True)`, so an
agent prompts the operator exactly as the differential-drive bridge does and a
headless run pre-approves the three surfaces via `STRANDS_ROS2_COMMAND_ALLOW`.
`docs/ros2-integration.md`, `docs/security.md` and
`examples/ros2/deepracer_agent.py` state that posture where an operator sizes a
pre-approval.
69 changes: 66 additions & 3 deletions docs/ros2-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ have and what you want to do:
| **`use_rtps`** tool | participant / **act as a robot** | pure `cyclonedds` (pip) | **no** | Join a graph as a DDS peer and publish topics a real stack consumes; works on macOS/CI/Jetson, all distros |
| **`use_rosbridge`** tool + **`RosbridgeRobot`** | ROS1 / remote robots over a rosbridge WebSocket | pure-pip `roslibpy` | **no** | Drive ROS1 robots (e.g. the NASA Curiosity Gazebo sim) or any remote rosbridge robot from a machine with no ROS install - see [rosbridge integration](rosbridge-integration.md) |
| **`RosBridgedRobot`** | a ROS 2 robot as a strands `Robot` | `use_ros` | yes | `drive()`/`get_pose()` a `cmd_vel`/odom base with the same `Agent(tools=[robot])` UX as sim/hardware |
| **`AckermannRosRobot`** | an Ackermann ROS 2 car as a strands `Robot` | `use_ros` | yes | `drive()`/`get_scan()` a steering-geometry car (AWS DeepRacer servo stack) with bicycle-model conversion and an automatic enable handshake |
| **`SimEngine(ros2_bridge=True)`** | the **simulation as a ROS node** | `rclpy` | yes | Publish a running MuJoCo sim's `joint_states` + camera `image_raw` so rviz/nav2/agents can subscribe |
| **`Robot(ros2_bridge=True)`** | a **real robot as a ROS node** (full duplex) | `rclpy` | yes | Publish a physical arm's live `joint_states` + camera `image_raw` so rviz/nav2/agents subscribe to the hardware, **and** subscribe to `joint_command` to drive the arm - symmetric to the sim bridge, plus an inbound command path the sim does not need |

Expand Down Expand Up @@ -179,15 +180,18 @@ unenforceable. These surfaces are blocked by default:

| Surface | Usually reached by |
|---------|--------------------|
| `/cmd_vel`, `/cmd_vel_unstamped` | `publish` |
| `/cmd_vel`, `/cmd_vel_unstamped`, `/manual_drive` | `publish` |
| `/joint_command`, `/joint_trajectory`, `/joint_trajectory_controller/joint_trajectory` | `publish` |
| `/emergency_stop`, `/e_stop` | `service_call`, sometimes `publish` |
| `/motor_enable`, `/enable_motor`, `/disable_motor` | `service_call` |
| `/vehicle_state`, `/enable_state` | `service_call` |
| `/navigate_to_pose`, `/follow_path` | `action_send_goal` |

Matching is on the final path segment, so a namespaced form
(`/my_robot/cmd_vel`, `/fleet/robot1/emergency_stop`) is caught while a lookalike
(`/cmd_vel_evil`, `/joint_trajectory_status`) is not. The name is compared in the
(`/my_robot/cmd_vel`, `/fleet/robot1/emergency_stop`, and the DeepRacer's
`/webserver_pkg/manual_drive`, `/ctrl_pkg/vehicle_state`,
`/ctrl_pkg/enable_state`) is caught while a lookalike (`/cmd_vel_evil`,
`/joint_trajectory_status`) is not. The name is compared in the
form rclpy resolves it to, so the unrooted `cmd_vel` and the trailing-separator
`/cmd_vel/` are the same surface as `/cmd_vel`. Case is deliberately **not**
folded: ROS 2 graph names are case-sensitive, so `/CMD_VEL` is a genuinely
Expand Down Expand Up @@ -238,6 +242,65 @@ surface, so telemetry stays available to the agent. The gate also runs *after* t
action's required arguments are validated, so an operator is never asked to
approve a call that could not have run.

## Ackermann robots (AWS DeepRacer)

Differential-drive bases take `geometry_msgs/msg/Twist`; Ackermann cars do
not. The AWS DeepRacer's stock stack subscribes to normalized servo pairs
(`deepracer_interfaces_pkg/msg/ServoCtrlMsg`, `angle`/`throttle` in [-1, 1])
and acts on them only after a two-step manual-mode service handshake
(`/ctrl_pkg/vehicle_state` with `state=1`, then `/ctrl_pkg/enable_state` with
`is_active=true`). `AckermannRosRobot` absorbs both differences:

from strands_robots.mesh import AckermannRosRobot

car = AckermannRosRobot.from_deepracer(node_name="deepracer")
car.drive(linear=0.5, angular=1.0, duration=2.0)
car.get_scan()

`drive()` keeps the same `(linear, angular)` contract as `RosBridgedRobot` -
a bicycle model (`atan(wheelbase * angular / linear)`, clamped to the steering
limit) converts to servo values internally. The handshake declared in
`init_services` runs once, automatically, before the first command; a failed
handshake aborts the drive. Timed and multi-message commands are always
followed by a zero servo message - even when the publish fails - so a timed
drive cannot leave the car with a live throttle, and a halt that itself fails is
reported: the call returns an error naming the throttle that may still be live
instead of the drive's success, so the agent's next action is `stop()`. A bare
single-shot `drive()`
(no `duration`) latches like any raw servo command until `stop()`. Commands
are clamped to `max_speed`; holds longer
than `max_duration` are rejected loudly rather than silently truncated. The
`linear`/`angular`/`duration`/`count` values themselves are checked against the
same shared domains the differential-drive bridges use, so an unusable value is
refused with identical text on every transport. The
stock platform publishes no odometry, so there is deliberately no
`get_pose`.

Like `RosBridgedRobot`, the bridge inherits the [command
gate](#safety-critical-command-surfaces-need-operator-approval): the servo topic
(`/manual_drive`) and both mode services (`/vehicle_state`, `/enable_state`) are
blocklisted surfaces, so every command this bridge sends is gated.

| Method / tool | Reaches | Gated |
|---------------|---------|-------|
| `drive()` / `drive_<node>` | `publish` to the servo topic (plus the handshake on the first call) | yes |
| `stop()` / `stop_<node>` | `publish` to the servo topic | yes - the gate is keyed on the surface, not the payload |
| `enable()` | `service_call` to both mode services | yes |
| `get_scan()` / `get_scan_<node>` | `echo` | never gated |

The `drive_<node>` and `stop_<node>` agent tools forward the operator context, so
an agent driving the car prompts rather than failing closed. A programmatic
`car.drive(...)` / `car.stop()` has no operator to prompt, so pre-approve the
three surfaces for a headless run (bare names cover the namespaced DeepRacer
spellings):

```bash
export STRANDS_ROS2_COMMAND_ALLOW=/manual_drive,/vehicle_state,/enable_state
```

See `examples/ros2/deepracer_agent.py`.


## Sim bridge: publish a simulation on a ROS 2 domain

The simulator can advertise its own live state on ROS 2. Construct any
Expand Down
4 changes: 2 additions & 2 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Defense-in-depth controls the SDK already provides, and which you should rely on

- **Tool scoping.** The single most effective mitigation is to give the agent only the tools a task actually needs. An agent that never receives the `robot_mesh`, `serial_tool`, or `Robot(mode="real")` tools cannot be coerced into a fleet broadcast, a raw serial write, or a physical actuation no matter what the injected text says.
- **Out-of-band human approval for physical actuation** (see [Operator approval](#operator-approval-for-fleet-wide-actions)) - the approval is delivered outside the LLM's tool-argument flow, so an injected prompt that tries to set an "approved" flag in the command body cannot bypass the gate.
- **Operator approval for ROS 2 command surfaces.** Every tool that reaches a ROS 2 graph - `use_ros` over in-process rclpy, `use_rtps` over raw RTPS and `use_rosbridge` over a WebSocket - gates each verb it offers that can carry a command to a robot - a topic `publish`, a `service_call` and an `action_send_goal` - against a blocklist of safety-critical surfaces (`/cmd_vel`, the joint command/trajectory topics, e-stop, motor enable/disable, `/navigate_to_pose`, `/follow_path`). Reading the same surfaces stays ungated. See [safety-critical command surfaces](ros2-integration.md#safety-critical-command-surfaces-need-operator-approval).
- **Operator approval for ROS 2 command surfaces.** Every tool that reaches a ROS 2 graph - `use_ros` over in-process rclpy, `use_rtps` over raw RTPS and `use_rosbridge` over a WebSocket - gates each verb it offers that can carry a command to a robot - a topic `publish`, a `service_call` and an `action_send_goal` - against a blocklist of safety-critical surfaces (`/cmd_vel`, the joint command/trajectory topics, e-stop, motor enable/disable, the Ackermann servo topic `/manual_drive` and the mode services that arm such a vehicle, `/navigate_to_pose`, `/follow_path`). Reading the same surfaces stays ungated. See [safety-critical command surfaces](ros2-integration.md#safety-critical-command-surfaces-need-operator-approval).
- **Payload validation** of every mesh command, so an injected instruction still cannot smuggle an out-of-bounds duration, an attacker-controlled inference host, or an arbitrary model path.

## Robot mesh authentication
Expand Down Expand Up @@ -168,7 +168,7 @@ Reference: `strands_robots.tools.gr00t_inference`.

- **DDS Security gate (pure-RTPS transport).** When `ros2_transport="rtps"` and commands are enabled, the bridge **refuses to start** unless given a `dds_security_config` (identity CA, participant certificate + private key, signed governance + permissions) or the explicit `STRANDS_ROS2_BRIDGE_I_KNOW_THIS_IS_INSECURE=1` opt-out. The credentials are wired into the cyclonedds participant QoS so the whole graph is authenticated and access-controlled. The rclpy transport gets its DDS Security from the ROS 2 RMW keystore/env (`ROS_SECURITY_*` / `sros2`) instead. See the [RTPS integration guide](rtps-integration.md#securing-the-inbound-command-surface).
- **Joint position bounds.** Pass `joint_limits={"<motor>.pos": (min, max)}` to reject any inbound command whose any joint falls outside its declared range - the whole command is dropped, never partially applied, so one out-of-range value cannot drive part of the arm. Keys are matched against the joint names the command carries - the same `<motor>.pos` names the bridge publishes in `joint_states`, so a controller can echo them straight back - and a key that names no commanded joint constrains nothing. Every bound must be a finite number; a non-finite one declares a range that admits nothing, and the bridge refuses it at construction rather than dropping every command for that joint mid-run.
- **Agent-side command gate (`use_ros`).** The bridge protections above harden the *inbound* surface a robot exposes. The three graph tools - `use_ros`, `use_rtps` and `use_rosbridge` - are the other direction, an agent publishing onto someone else's graph, and each gates the safety-critical surfaces behind an operator interrupt, keyed on the surface name so a topic publish, a service call and an action goal are all covered. One shared blocklist serves all three, so a surface refused on one transport cannot be sent on another. `RosBridgedRobot` forwards the operator context into it, so a bridged robot driven by an agent prompts too, while a programmatic `robot.drive(...)`/`stop()` needs the surface pre-approved. Pre-approve surfaces with `STRANDS_ROS2_COMMAND_ALLOW` - matched by base name as well as exactly, so a `/cmd_vel` entry lifts the gate on every namespaced `cmd_vel` and not only the robot being driven; name the namespace to scope it to one - or bypass with `BYPASS_TOOL_CONSENT=true`; with neither set and no interrupt reachable it fails closed. See [safety-critical command surfaces](ros2-integration.md#safety-critical-command-surfaces-need-operator-approval).
- **Agent-side command gate (`use_ros`).** The bridge protections above harden the *inbound* surface a robot exposes. The three graph tools - `use_ros`, `use_rtps` and `use_rosbridge` - are the other direction, an agent publishing onto someone else's graph, and each gates the safety-critical surfaces behind an operator interrupt, keyed on the surface name so a topic publish, a service call and an action goal are all covered. One shared blocklist serves all three, so a surface refused on one transport cannot be sent on another. `RosBridgedRobot` and `AckermannRosRobot` both forward the operator context into it, so a bridged robot driven by an agent prompts too, while a programmatic `robot.drive(...)`/`stop()` needs the surface pre-approved. Pre-approve surfaces with `STRANDS_ROS2_COMMAND_ALLOW` - matched by base name as well as exactly, so a `/cmd_vel` entry lifts the gate on every namespaced `cmd_vel` and not only the robot being driven; name the namespace to scope it to one - or bypass with `BYPASS_TOOL_CONSENT=true`; with neither set and no interrupt reachable it fails closed. See [safety-critical command surfaces](ros2-integration.md#safety-critical-command-surfaces-need-operator-approval).
- **Telemetry-only is ungated.** `ros2_commands=False` is publish-only (no inbound surface) and needs no security config. That posture is only as good as how the flag is read, so `ros2_bridge` / `ros2_commands` (and `enable_commands` on either bridge class directly) are **checked** against the shared boolean domain rather than read by truthiness: a non-boolean is refused at construction, so a deployment config that spells the flag `"false"` cannot select the surface it asks to close.

## Credentials and secrets
Expand Down
43 changes: 43 additions & 0 deletions examples/ros2/deepracer_agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""Drive an AWS DeepRacer with natural language - Ackermann bridge demo.

Goal: Show AckermannRosRobot presenting a steering-geometry ROS 2 car as
strands agent tools. The bridge converts (linear, angular) commands through a
bicycle model to the DeepRacer's normalized servo interface, runs the car's
manual-mode service handshake automatically before the first command, and
always trails sustained commands with a zero so the car cannot be left
driving.

Dependencies:
pip install strands-robots strands-agents
A sourced ROS 2 environment (rclpy) on the same network/domain as the car,
with the DeepRacer core stack running (ctrl_pkg, webserver_pkg, rplidar).

SAFETY: put the car on blocks or a clear track before running. The servo topic
and both mode services are gated command surfaces, so this agent prompts for
operator approval before each command it sends. For an unattended run,
pre-approve exactly those three surfaces instead (bare names cover the
namespaced DeepRacer spellings):

export STRANDS_ROS2_COMMAND_ALLOW=/manual_drive,/vehicle_state,/enable_state

Expected output: the agent drives a short pattern and summarizes the lidar.
Runtime: ~20 seconds (depends on LLM latency).
"""

from strands import Agent

from strands_robots.mesh import AckermannRosRobot

# Stock DeepRacer wiring: servo topic, lidar, and the two-step manual-mode
# handshake are preconfigured; override any keyword for a modified car.
car = AckermannRosRobot.from_deepracer(node_name="deepracer")

agent = Agent(tools=car.tools)

result = agent(
"Drive forward at 0.3 m/s for two seconds, then arc gently left for two "
"seconds, stop, and read the lidar to tell me the nearest obstacle."
)

print(f"Agent completed: {result}")
Loading
Loading