Skip to content

feat(mesh): AckermannRosRobot - Ackermann ROS 2 cars (AWS DeepRacer) as strands robots - #2689

Merged
cagataycali merged 18 commits into
strands-labs:mainfrom
cagataycali:feat/ackermann-ros-robot-current
Aug 24, 2026
Merged

cagataycali merged 18 commits into
strands-labs:mainfrom
cagataycali:feat/ackermann-ros-robot-current

Conversation

@cagataycali

@cagataycali cagataycali commented Aug 24, 2026 •

Copy link
Copy Markdown
Member

Ackermann ROS 2 cars (AWS DeepRacer) as strands robots, brought current with main

Continues @Vivek0712's #1035, whose commits are ancestors of this branch. That
PR is a complete, reviewed feature that has been unmergeable on a textual
conflict since 2026-08-04. This branch carries it forward: main merged in,
the conflict resolved, and the two defects that its stale base was hiding
fixed.

The feature

RosBridgedRobot covers differential-drive bases (a Twist on cmd_vel plus
odometry). An Ackermann car exposes neither. AckermannRosRobot 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,
clamps speed, refuses over-long holds loudly, and always trails a timed command
with a zero servo message so a timed drive cannot leave the car driving. It
validates 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.

The conflict

One file, docs/ros2-integration.md, where both sides appended a new section at
the same anchor: this branch a new ## "Ackermann robots (AWS DeepRacer)"
section, main a ### "Safety-critical command surfaces need operator
approval" subsection. Both are kept. main's block goes first because it is an
h3 that continues the pre-existing ## Safety section - putting the new h2
first would nest main's subsection under "Ackermann robots". Resulting order
is ## Safety -> ### Safety-critical ... -> ## Ackermann robots ... ->
## Sim bridge ....

Two defects the stale base hid

The branch's checks were green, and correctly so - both guards that fail it
landed on main after its base (c1acf21d), and neither guard's file is one
the branch touches, so there is no file overlap to notice:

Guard Landed on main What it found
tests/test_repr_survives_partial_construction.py #2408, 2026-08-17 AckermannRosRobot.__repr__ raises on a half-built instance
tests/mesh/test_bridge_stop_tool_parity.py #2142, 2026-08-11 the drive/stop survey inventory was not triaged for a fourth bridge

1. __repr__ on a half-built instance. __init__ validates node_name
through _check_topic before assigning it, so a refused construction leaves the
instance the raising frame keeps alive rendering as

[AttributeError("'AckermannRosRobot' object has no attribute 'node_name'") raised in repr()]

which sends the reader after node_name when the real failure is the
ValueError: invalid node_name: 'bad name!' they already caused. Now wrapped in
try/except AttributeError delegating to strands_robots.utils.partial_construction_repr,
exactly as RosBridgedRobot, RosbridgeRobot and RtpsRobot do, so the phrase
a reader learns to recognise in a traceback has one owner. Half-built now renders
AckermannRosRobot(partially constructed, id=0x...). The class is triaged into
both halves of that module's survey - the half-built sweep and the
documented-refusal cases - for parity with the other three bridges.

2. drive/stop survey inventory. test_the_survey_finds_the_bridges_it_is_meant_to_cover
is the non-vacuity half of the drive/stop guard: it pins that the scan finds the
bridges it is meant to cover, so a mis-rooted or empty survey cannot read as
compliant. A fourth drive/stop bridge has to be triaged there. The rule itself
already covered the class - the survey walks the mesh package - and renaming its
stop_ tool fails test_every_bridge_owning_a_drive_stop_pair_builds_a_stop_tool.
It stays out of BRIDGES deliberately, with the reason recorded next to the
inventory: those cases assert a zero geometry_msgs/msg/Twist on /cmd_vel, and
an Ackermann car halts with a zero ServoCtrlMsg (angle/throttle) on its
servo topic, which tests/mesh/test_ackermann_robot.py already pins.

Verification

Regression split, on the merged tree, over the two guard files:

tree state result
at the merge commit, before both fixes 3 failed, 60 passed
after 65 passed

The three are the two repr cases plus the inventory case; the delta to 65 is
those three plus the two new parametrized cases the class now contributes.

Each fix is load-bearing, checked by mutation:

mutation fails
drop the try/except from __repr__ the half-built case, and the single-owner count (tolerant == len(EXPECTED_REPR_CLASSES))
return an inline string instead of partial_construction_repr the new documented-refusal case, and the single-owner check
rename the Ackermann stop_ tool to halt_ test_every_bridge_owning_a_drive_stop_pair_builds_a_stop_tool

Zero regressions, over 296 tree-walking guard files plus all of tests/mesh,
run identically on this branch and on a pristine upstream/main worktree:

tree result
this branch 5 failed, 17887 passed, 61 skipped
pristine upstream/main 5 failed, 17840 passed, 60 skipped

The five failing ids are identical on both trees and all environment-bound
(four DID NOT RAISE ImportError where rclpy is importable, and one
auth_mode case sensitive to a mesh env var another test in the selection
sets). The +47 passed is this branch's own suite plus the two new cases.

ruff check and ruff format --check clean over strands_robots tests tests_integ. mypy over the same scope: 19 errors on this branch and 19 on
pristine main, with the branch-only difference empty. assemble_changelog.py --check OK.

No visual artifact: the diff adds a ROS 2 transport bridge and a __repr__
fallback, and changes no simulation, rendering or policy behaviour.

Supersedes #1035. Its changelog fragment is kept and extended with the repr
fix rather than replaced.


Round 2 changes

Two review concerns, both on safety paths in the changed code, one commit each,
plus the rule the second belongs to recorded where the next bridge will read it.

1. The trailing safety stop's verdict was discarded (fcb5ed4). drive sent
its trailing zero servo message from a finally and dropped the result, so when
the main publish succeeded and the halt then failed - node or daemon dying
mid-hold - the caller saw the drive's success for a car still holding the
commanded throttle, on a tool whose description promises that a command with a
duration stops itself. The halt's result is now captured into a local from the
finally (so it still goes out when the main publish raises) and, when the drive
succeeded but the halt did not, returned as an error naming the published
command, the possibly-live throttle, stop as the next action, and the halt's
own cause. A failed drive stays the reported cause; a latching single-shot sends
no halt, so there is no verdict to read.

Three cases in tests/mesh/test_ackermann_robot.py, next to
test_drive_trailing_zero_sent_even_when_publish_errors which covers the inverse
direction: the failed-halt case, the both-failed precedence, and a control that a
single-shot success is not reinterpreted. Restoring the discard fails the first.

2. The bridge's commands were outside the use_ros operator gate (43ee3d5).
Both halves were missing. The gate only fires on a blocklisted surface, and the
shipped DeepRacer wiring matched no entry (/webserver_pkg/manual_drive, and the
/ctrl_pkg mode services that arm the vehicle), 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. And nothing here
carried an operator context, so fixing coverage alone would have made every
command, stop included, a fail-closed refusal.

half change
blocklist coverage /manual_drive, /vehicle_state, /enable_state are default entries, spelled bare so the base-name rule covers the namespaced spellings
context threading drive, stop, enable take a tool_context and thread it into every use_ros call (the trailing halt included); drive_<node> / stop_<node> are @tool(context=True) and forward the injected context, as RosBridgedRobot does. get_scan stays contextless - echo is never gated

Pinned by tests/mesh/test_ackermann_command_gate.py (18 cases), which keeps the
real use_ros and doubles the rclpy transport instead - the boundary
test_ackermann_robot.py patches over, which is why neither half was visible
there. It covers the prompt order over all three surfaces, a decline at the
handshake and at the servo topic, the halt reaching the operator, a timed drive's
trailing halt, both allowlist spellings, the headless refusal naming both
variables, and structural guards on context=True and on every command call
site. TestEveryCommandingMeshBridgeHasAGateSuite derives the mesh modules that
send a command through use_ros from the tree and compares them against an
inventory of gate suites, so a fifth bridge is graded on arrival rather than
shipping ungated the way this one did.

mutation fails
remove the three blocklist entries 7 cases (5 behavioural + both coverage guards)
drop context=True from the drive tool 2 cases (structural + input-schema)
drop one tool_context forward in _publish_servo 5 cases
restore the discarded halt verdict 1 case

Doc drift closed in the same commits, at every surface an operator reads to size
a pre-approval: the blocklist table and the final-path-segment example list in
docs/ros2-integration.md, a per-method gated-surface table plus the
STRANDS_ROS2_COMMAND_ALLOW line in its Ackermann section, the surface
enumeration and the context-forwarding sentence in docs/security.md, the
example's SAFETY block, and the class and module docstrings. The changelog
fragment carries both fixes.

3. The rule, recorded (d44be59). AGENTS.md convention 18: a new bridge
ships un-gated because the gate is keyed on surface names it does not know, and a
bridge suite that patches the module's use_ros symbol cannot see it - so naming
the surfaces and threading the context are one change, either alone being a
different defect. It names the pin above rather than restating the rule, since
that pin's inventory is derived from the tree.

Round 2 verification. Same selection on this branch and on a pristine
main worktree (tests/mesh, tests/tools/test_use_ros.py,
tests/test_use_ros_command_blocklist.py,
tests/test_repr_survives_partial_construction.py,
tests/test_args_docstring_completeness.py,
tests/test_docstring_xref_roles_resolve.py):

tree result
this branch 112 failed, 4412 passed, 18 skipped, 24 errors
pristine main 112 failed, 4347 passed, 18 skipped, 24 errors

The 136 failing/erroring ids are byte-identical between the two trees and all
bound to optional dependencies absent from this runner (90 json5, 44 awsiot,
1 lerobot, and one pre-existing test_iot_camera_ref_delivery assertion). The
+65 passed is this branch's new gate suite (18) plus its own earlier cases. The
968-test sweep of every guard that reads AGENTS.md passes (one lerobot-bound
failure, present before the edit). ruff check and ruff format --check clean
over strands_robots tests; mypy reports zero errors in either changed source
file, and its count on use_ros.py is 11 on both trees; assemble_changelog.py --check OK.


Round 3 changes

No review feedback outstanding; this round is a base merge forced by a sibling
change, plus the absorption its sequencing note called for.

main landed the ROS 2 command gate as a shared owner,
strands_robots/tools/_command_gate.py, so that use_rtps and use_rosbridge
consult the same blocklist as use_ros - a surface refused on one transport can
no longer be sent on another. Round 2 above added three DeepRacer surfaces to
that blocklist and extended the two docs/security.md sentences describing it,
while it still lived in use_ros.py. Both sides therefore edited the same text,
and this branch went CONFLICTING the moment that change merged.

conflict resolution
strands_robots/tools/use_ros.py main's version wholesale. This branch's only edit to it was the blocklist and its comment, and both now live in the shared module.
docs/security.md, both sentences main's widened subject (all three transports, one shared blocklist) plus what this branch added to them.

The three surfaces move to _command_gate.COMMAND_BLOCKLIST unchanged, which
widens what round 2 achieved: /manual_drive, /vehicle_state and
/enable_state are now gated on all three transports rather than only on
in-process rclpy. Their justification moves with them, minus the
bare-entry/final-segment rule the shared comment already states. The two
docs/security.md sentences regain the Ackermann servo topic and arming
services in the enumeration, and AckermannRosRobot beside RosBridgedRobot as
a context forwarder. docs/ros2-integration.md merged with no conflict and
carries both the transport axis and this branch's surface table.

Three symbols this branch referenced moved with the gate, so its own suite and
the rule recorded in round 2 name them at the new owner:

was is
use_ros._DEFAULT_COMMAND_BLOCKLIST _command_gate.COMMAND_BLOCKLIST
use_ros._is_command_blocked _command_gate.command_block_message
use_ros._COMMAND_ALLOW_ENV _command_gate.COMMAND_ALLOW_ENV

A tree-wide sweep confirms no reference to any of the old names survives, in
source, tests or AGENTS.md.

Round 3 verification. The 12 suites covering the gate, the Ackermann bridge
and both sibling transports: 545 passed, 1 skipped. The whole-tree graders
that read the edited docs and AGENTS.md: 111 passed.

Full tests/ on the composition and on a pristine main worktree, same
interpreter and same environment:

tree result
this branch merged with main 627 failed, 27511 passed, 1871 skipped, 981 errors
pristine main 627 failed, 27443 passed, 1870 skipped, 981 errors

Read as a delta, per the rule that an absolute count in a partial environment
invents regressions: the failing-and-erroring id sets are compared directly and
the branch-only set is empty, with identical 627/981 totals on both trees.
Every one is an absent optional dependency here (torch, mujoco, lerobot,
rclpy, device_connect_edge, json5, awsiot). The +68 passed is this
branch's own tests.

ruff check and ruff format --check clean over strands_robots tests
(1469 files already formatted).

Vivek0712 and others added 14 commits July 3, 2026 08:15
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…stop, scan

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ling-zero stop

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ngelog

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d-command trailing zero, validation before handshake

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
main now assembles CHANGELOG.md from changelog.d/ news fragments, and AGENTS.md
forbids appending to ## [Unreleased] directly - that anchor is exactly what made
this branch conflict. The entry moves to changelog.d/1035-ackermann-ros-robot.md
and CHANGELOG.md is taken verbatim from main, so the branch no longer touches a
shared anchor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	docs/ros2-integration.md
…omains

Merging main brought in the structural pin that every mesh class defining
drive() must validate its numeric inputs through the shared domain helpers
rather than a hand-rolled copy. AckermannRosRobot is exactly the fourth
transport that pin was written for, and it shipped the inline math.isfinite
test the helpers replaced:

  - bool was treated as a number, so drive(linear=True) published a servo
    message commanding 1/1.5 throttle and returned success, and
    AckermannRosRobot(max_speed=True) installed a silent 1.0 m/s clamp.
  - a value that cannot be coerced raised a bare TypeError, escaping the
    structured tool-result contract of the bound drive_<node> agent tool
    instead of naming the parameter.
  - count was not checked at all: count=0 published nothing and still
    reported success, count=2.7 surfaced a raw publish-loop error attributed
    to a transport the caller never invoked.

drive() and the constructor limits now call finite_number_error,
positive_finite_number_error and positive_whole_number_error, so the car
refuses an unusable velocity, hold or count with byte-identical text to the
three differential-drive bridges. The max_duration ceiling is ordered after
the finiteness guard because every comparison against nan is false.

The bridge is added to the cross-bridge parity table so the contract is pinned
behaviorally and not only by the AST scan. Two changes make room for it: the
table now carries a keyword-taking constructor, replacing a label dispatch
whose fallback branch would have silently re-checked an already-covered bridge
instead of the new one; and the expected wire fields move into the table, since
the Ackermann car publishes a normalized servo pair where the others publish a
Twist. The servo values are re-derived in the test from the documented bicycle
model rather than read back from the converter under test. Removing the count
guard fails 10 checks in this file, so the added coverage is not vacuous.
Absorbs a base-branch test-fixture fix the branch was missing. The only red
check on this PR was `call-test-lint` failing one isaac test:

    tests/simulation/isaac/test_deferred_physics_and_warmup.py::
      TestWarmupCameraResumesTimeline::test_resumes_stopped_timeline_and_warms_up
    assert sim._warmup_camera("wrist_image", n_steps=5) is True -> False is True

That file is not in this branch's diff (which touches strands_robots/mesh/,
examples/ros2/, docs/ros2-integration.md, tests/mesh/ and one changelog.d/
fragment). The `IsaacSimulation` skeleton those pins drive omitted
`_world_created`; the warm-up loop breaks on the resulting AttributeError and
returns False. `main` hardened the skeleton to set it, so the branch was simply
running the pre-fix fixture - ordinary merge-base staleness on a PR open since
2026-07-03, not a defect in the Ackermann bridge.

Merge is clean: zero conflicts, no hand resolution, no production file touched
by this commit.

Verified in one environment:
- The previously-failing file: 11 passed.
- tests/mesh/test_ackermann_robot.py: 40 passed.
- tests/mesh/test_drive_command_numeric_domains.py (the semantic-clash canary,
  since the merge pulls in further structural scanners): 86 passed, 1 skipped.
- Full tests/ failure set diffed against a clean checkout of main in the same
  environment: identical, zero regressions (255 failed / 639 errors on both,
  all missing-optional-dependency noise), with passes 11624 -> 11665.
# Conflicts:
#	docs/ros2-integration.md
…n repr

The constructor validates node_name via _check_topic before assigning it, so a
refused construction left the half-built instance the raising frame keeps alive
rendering as [AttributeError ... 'node_name' raised in repr()] - naming the
attribute rather than the ValueError the caller already caused.

Delegate to the single owner of the fallback wording,
strands_robots.utils.partial_construction_repr, exactly as RosBridgedRobot,
RosbridgeRobot and RtpsRobot do, and triage the class into the survey in
tests/test_repr_survives_partial_construction.py - both the half-built sweep
and the documented-refusal cases, for parity with the other ROS/RTPS bridges.
…tory

test_the_survey_finds_the_bridges_it_is_meant_to_cover is the non-vacuity half
of the drive/stop guard: it pins that the scan finds the bridges it is meant to
cover, so a mis-rooted or empty survey cannot read as compliant. A fourth
drive/stop bridge therefore has to be triaged there rather than silently
widening the set the scan reports.

The rule itself already covered the class - the survey walks the mesh package,
and renaming its stop_ tool fails
test_every_bridge_owning_a_drive_stop_pair_builds_a_stop_tool. It stays out of
BRIDGES on purpose: those cases assert a zero geometry_msgs/msg/Twist on
/cmd_vel, and an Ackermann car halts with a zero ServoCtrlMsg on its servo
topic, which tests/mesh/test_ackermann_robot.py pins.

@yinsong1986 yinsong1986 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Carries #1035 forward onto current main: a fourth mobile-base transport, AckermannRosRobot, presenting Ackermann-steering ROS 2 cars (AWS DeepRacer reference wiring in from_deepracer()) with the fleet-standard drive(linear, angular, duration) contract, a bicycle-model conversion to normalized ServoCtrlMsg servo pairs, a declarative init_services enable handshake, and a trailing zero-servo message after timed/multi-message commands so a hold cannot leave the throttle latched. The merge also fixes the two guard failures the stale base was hiding (__repr__ on a half-built instance, and the drive/stop survey inventory), triages the class into the shared numeric-domain parity suite, and resolves the one documentation conflict keeping both sides. The two inline comments are both on the new bridge's command path: its motion surfaces sit entirely outside the use_ros operator-approval gate the adjacent doc section documents, and the trailing safety stop's outcome is discarded.

What's good

  • Validation goes through the shared finite_number_error / positive_finite_number_error / positive_whole_number_error domains, and the parity suite (test_drive_command_numeric_domains.py) was extended to compare the fourth transport against the other three rather than adding a hand-rolled copy.
  • The __repr__ fix delegates to partial_construction_repr (single owner of the wording) and the class is triaged into both halves of that survey, per AGENTS.md rule 14.
  • The deliberate exclusion from BRIDGES in test_bridge_stop_tool_parity.py is recorded next to the inventory with its reason, and the zero-servo halt is pinned in the bridge's own test file.
  • Changelog fragment under changelog.d/, docs conflict resolution keeps main's safety subsection in the right heading hierarchy, and the atan2 quadrant handling for reverse motion is both explained and tested.

Comment thread strands_robots/mesh/ackermann_robot.py
Comment thread strands_robots/mesh/ackermann_robot.py Outdated
strands-robots-agent added 3 commits August 24, 2026 14:24
`AckermannRosRobot.drive` sent its trailing zero servo message from a
`finally` and discarded the result. `use_ros` reports a transport failure
as an error dict rather than raising, so when the main publish succeeded
(car driving for `duration`) and the trailing zero then failed - node or
daemon dying mid-hold, transport error - the caller saw the drive's
`success` for a car still holding the commanded throttle. The tool
description promises "a command with duration stops automatically
afterwards", so an agent reading that success never issues `stop`, and a
servo command latches on this platform.

The halt's verdict is now captured and, when the drive succeeded but the
halt did not, returned as an error naming the published command, the
failed halt and its cause. A failed drive stays the reported cause, and a
latching single-shot command sends no halt so there is no verdict to
read.

Pinned by three cases in tests/mesh/test_ackermann_robot.py, next to
`test_drive_trailing_zero_sent_even_when_publish_errors`, which covers
the inverse direction: the failed-halt case, the both-failed precedence,
and a control that a single-shot success is not reinterpreted. Restoring
the discard fails the first.
…gate

The bridge's motion commands sat entirely outside the `use_ros`
operator-approval gate, in both halves that gate needs.

Blocklist coverage. `_gate_command` only fires for a surface matching
`_DEFAULT_COMMAND_BLOCKLIST` exactly or by base name, and the shipped
DeepRacer wiring matched no entry: `/webserver_pkg/manual_drive` (base
`/manual_drive`), `/ctrl_pkg/vehicle_state` and `/ctrl_pkg/enable_state`.
Every servo publish and every handshake call therefore proceeded with no
prompt, no allowlist check and no audit row - while `/motor_enable` and
`/enable_motor` were blocked and the functionally identical
`/enable_state`, which is what makes the car act on a servo message at
all, was not. `/manual_drive`, `/vehicle_state` and `/enable_state` are
now default entries, spelled bare so the base-name rule covers the
namespaced spellings.

Context threading. Coverage alone would have turned every command,
`stop` included, into a fail-closed refusal, because nothing here carried
an operator context. `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 driving the car prompts the operator exactly as the
differential-drive bridge does, and a programmatic or headless call is
refused with the two variables that lift it named.

`docs/ros2-integration.md` (blocklist table, the final-path-segment
example list, and a gated-surface table in the Ackermann section),
`docs/security.md` and `examples/ros2/deepracer_agent.py` state that
posture where an operator sizes a pre-approval.

Pinned by tests/mesh/test_ackermann_command_gate.py, which keeps the real
`use_ros` and doubles the rclpy transport instead, so the gate and the
bridge wiring run together: the prompt order over all three surfaces, a
decline at the handshake and at the servo topic, the halt reaching the
operator, the trailing halt of a timed drive, both allowlist spellings,
and structural guards on `context=True` and on every command call site.
`TestEveryCommandingMeshBridgeHasAGateSuite` derives the bridges owing
such a suite from the tree, so a fifth one is graded on arrival rather
than shipping ungated the way this one did.

Removing the three entries fails 7 cases; dropping `context=True` fails
2; dropping one forward fails 5.
The gate is keyed on the graph name of the surface a command targets, so
it knows nothing about a transport added later, and a bridge suite that
patches the module's `use_ros` symbol cannot see the bypass. Convention
18 states both halves as one change - name the surfaces on the blocklist
(bare, so the final-segment rule covers namespaced instances) and thread
`tool_context` into every `use_ros` call with the command tools declared
`@tool(context=True)` - because either alone is a different defect: no
coverage is a silent bypass, no context is a fail-closed refusal of the
whole command surface including `stop`. Names the pin whose inventory is
derived from the tree, so the rule is enforced at the next bridge rather
than restated.
The ROS 2 command gate moved to strands_robots/tools/_command_gate.py so
that use_rtps and use_rosbridge consult the same blocklist as use_ros; a
surface refused on one transport can no longer be sent on another. This
branch added three DeepRacer surfaces to the blocklist and extended the
two docs/security.md sentences describing it, so both sides edited the
same text.

Resolved by taking the shared owner in every case:

- The three surfaces (/manual_drive, /vehicle_state, /enable_state) move
  to _command_gate.COMMAND_BLOCKLIST unchanged, so they are now gated on
  all three transports rather than only on in-process rclpy. Their
  justification moves with them, minus the bare-entry/final-segment rule
  the shared comment already states.
- use_ros.py takes main's version wholesale. This branch's only edit to
  it was the blocklist and that comment, both of which now live in the
  shared module.
- Both docs/security.md sentences keep main's widened subject (all three
  transports, one shared blocklist) and regain what this branch added to
  them: the Ackermann servo topic and arming services in the enumeration,
  and AckermannRosRobot beside RosBridgedRobot as a context forwarder.
- tests/mesh/test_ackermann_command_gate.py and AGENTS.md rule 18 name
  the moved symbols: _DEFAULT_COMMAND_BLOCKLIST becomes
  COMMAND_BLOCKLIST, _is_command_blocked becomes command_block_message,
  and _COMMAND_ALLOW_ENV becomes COMMAND_ALLOW_ENV.

docs/ros2-integration.md merged with no conflict and carries both the
transport axis and this branch's surface table.

Verified: the 12 suites covering the gate, the Ackermann bridge and both
sibling transports report 545 passed, 1 skipped. Full tests/ run on the
composition and on a pristine main worktree report an identical
627 failed / 981 errors, every one an absent optional dependency in this
environment, with the branch-only failing-id set EMPTY and +68 passes
that are this branch's own tests. ruff check and ruff format clean.

@yinsong1986 yinsong1986 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Carries #1035 forward onto current main: a fourth mobile-base transport, AckermannRosRobot, presenting Ackermann-steering ROS 2 cars (stock AWS DeepRacer wiring in from_deepracer()) with the fleet-standard drive(linear, angular, duration) contract, a bicycle-model conversion to normalized ServoCtrlMsg servo pairs, a run-once init_services arming handshake, and a trailing zero-servo halt on every timed command. Both must-fix concerns from the earlier review round are resolved on this head: the trailing halt's verdict is captured from the finally and a drive whose halt fails now returns an error naming the possibly-live throttle and stop as the next action, and the bridge's command surfaces are gated - /manual_drive, /vehicle_state and /enable_state are default _command_gate.COMMAND_BLOCKLIST entries (so round 3's merge widens the gate to all three transports), drive/stop/enable thread tool_context into every use_ros call including the trailing halt, and the command tools are @tool(context=True) while get_scan stays contextless. The round-3 base merge correctly hands use_ros.py back to main's shared-gate refactor and moves the three entries and their justification to the new owner.

What's good

  • The gate suite keeps the real use_ros and doubles the rclpy transport - exactly the boundary the bridge suite patches over, which is why the original gap was invisible there - and TestEveryCommandingMeshBridgeHasAGateSuite derives the commanding-bridge inventory from the tree, so a fifth bridge is graded on arrival.
  • Validation goes through the shared numeric domains, and tests/mesh/test_drive_command_numeric_domains.py is extended to compare all four transports against each other, with the Ackermann wire contract (servo pair) pinned separately from the shared caller-facing domain.
  • The half-built-repr and drive/stop-survey triage both land with parity entries in the whole-tree guards (test_repr_survives_partial_construction.py, test_bridge_stop_tool_parity.py), with the reason AckermannRosRobot stays out of the Twist-asserting BRIDGES cases recorded next to the inventory.
  • Operator-facing posture is stated everywhere it is read: the blocklist table and final-segment examples in docs/ros2-integration.md, the per-method gated-surface table, the STRANDS_ROS2_COMMAND_ALLOW line, docs/security.md, and the example's SAFETY block, with both allowlist spellings pinned by tests.
  • All new/changed strings are plain ASCII; changelog fragment present and assemble_changelog.py --check passes.

Verification suggestions

Beyond CI: pytest tests/mesh/test_ackermann_robot.py tests/mesh/test_ackermann_command_gate.py tests/mesh/test_bridge_stop_tool_parity.py tests/mesh/test_drive_command_numeric_domains.py tests/test_repr_survives_partial_construction.py (212 passed, 1 skipped on this head locally), plus tests/test_use_ros_command_blocklist.py tests/tools/test_use_ros.py tests/mesh/test_ros_bridge_command_gate.py (173 passed) to confirm the widened blocklist regresses nothing on the sibling transports.

@cagataycali
cagataycali merged commit 3a4c607 into strands-labs:main Aug 24, 2026
15 checks passed
cagataycali pushed a commit to cagataycali/robots that referenced this pull request Aug 24, 2026
…r sets with AckermannRosRobot

main landed a fourth drive-owning mesh class (strands-labs#2689) and the operator-approval
gate for every ROS 2 transport (strands-labs#2693) after this branch's last base merge. All
four conflicts are set-membership: both sides added an entry to a list of the
classes that own a drive contract, so each resolution is the union plus this
branch's consolidation semantics.

  strands_robots/mesh/__init__.py            both sides added exports
  tests/mesh/test_bridge_stop_tool_parity.py EXPECTED_DRIVE_STOP_CLASSES
  tests/mesh/test_drive_command_numeric_domains.py  the drive-owner scan
  tests/test_repr_survives_partial_construction.py  the surveyed classes

AckermannRosRobot keeps its own drive/stop pair - it is not layered on
MobileBaseRobot - so it joins the owning sets rather than the inheriting ones,
and RosBridgedRobot/RtpsRobot stay asserted absent, which is what pins the
consolidation instead of merely tolerating it.

Measured on the composed tree, 14 affected files: 753 passed, 1 skipped, 0
failed. tests/mesh/test_mobile_base.py alone is 101 passed, including the
gate-forwarding parametrization that was 2 failed before the tool_context fix.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants