Skip to content

refactor(skills): shared skill_lib for arm primitives + camera geometry - #548

Merged
theo-michel merged 1 commit into
theo/pick-wrist-servofrom
theo/skill-lib
Jul 16, 2026
Merged

refactor(skills): shared skill_lib for arm primitives + camera geometry#548
theo-michel merged 1 commit into
theo/pick-wrist-servofrom
theo/skill-lib

Conversation

@theo-michel

Copy link
Copy Markdown
Contributor

Stacked on #542 (base: theo/pick-wrist-servo).

What

Introduces workspace/skill_lib/ — plain Python modules skills import directly. No Skill classes, no roster entries, no invoker round-trip; functions take the interfaces they need as explicit arguments. The idea: short blocking device commands, recovery sequences, and pure math are library functions; anything the agent/operator invokes by name or that runs long enough to need cancellation stays a Skill.

The hardware lessons this robot taught us now live in exactly one place:

  • skill_lib/arm.py — verified gripper open (overcurrent-trip → reboot recovery), close with the strength-cap lesson, health-checked cartesian moves (FK verify → recover + retry → raise ArmUnhealthy), servo recovery, reach clamps.
  • skill_lib/geometry.py — head-camera ↔ floor pinhole projection, moved verbatim from pick_any_object (pure math; the webapp panel mirrors it).

Effect on existing skills

  • gripper_open / gripper_close become thin wrappers over the lib — and gripper_open gains the trip-recovery verification that previously only pick_any_object had.
  • pick_any_object drops ~150 lines of private helpers (_move_arm, _recover_arm, the geometry functions, inline reach clamps) for lib calls, and calls the lib directly instead of round-tripping the gripper skills through the invoker.

Import contract

Skills import the lib at module top: the loader puts the repo root on sys.path only while a skill module executes (namespace-package import — verified live). catalog.reload_all / reload_selective now evict cached workspace.skill_lib modules before reloading, so a normal skills reload picks up lib edits too (otherwise sys.modules keeps serving the pre-edit lib).

Compatibility

No behavior or interface changes: skill IDs, innate.skills chaining, panel debug events, and TUNABLE keys are all unchanged.

Testing

Verified live on the robot:

  • /brain/reload_primitives loads all 23 code skills (same count/IDs).
  • pick_any_object acks its tuning params through the new lib imports.
  • gripper_close @strength=0.1 then gripper_open ran end-to-end via the skill CLI — claw physically cycled and returned to its starting joint value (j6 = 0.463).
  • All files compile; lines within the 120-char ruff limit.

Note for reviewers

The catalog.py change is in brain_client (ROS package), so it takes effect on the next colcon build + skills-server restart. Everything under workspace/ is live-reloadable and already running on mars.

Introduce workspace/skill_lib/ — plain Python modules skills import directly,
no Skill classes, no roster entries. Functions take the interfaces they need
as explicit arguments. The hardware lessons now live exactly once:

- skill_lib/arm.py: verified gripper open (overcurrent-trip reboot recovery),
  close with the strength-cap lesson, health-checked cartesian moves (FK
  verify, recover + retry, raise ArmUnhealthy), servo recovery, reach clamps.
- skill_lib/geometry.py: head-camera <-> floor pinhole projection, moved
  verbatim from pick_any_object (pure math; the webapp panel mirrors it).

gripper_open / gripper_close become thin wrappers over the lib — and
gripper_open GAINS the trip-recovery verification that previously only
pick_any_object had. pick_any_object drops ~150 lines of private helpers
(_move_arm, _recover_arm, geometry, inline reach clamps) for lib calls, and
calls the lib directly instead of round-tripping gripper skills through the
invoker.

Import contract: skills import the lib at module top — the loader puts the
repo root on sys.path only while a skill module executes (namespace-package
import, verified live). catalog.reload_all/reload_selective now evict cached
workspace.skill_lib modules so a normal skills reload picks up lib edits too.

No behavior or compatibility changes otherwise: skill IDs, innate.skills
chaining, panel debug events, and TUNABLE keys are unchanged. Verified live
on the robot: reload loads all 23 skills, pick_any_object acks its params
through the lib imports, and gripper_close/gripper_open ran end-to-end via
the skill CLI (claw physically cycled and returned to open).
@theo-michel
theo-michel merged commit 2b8cfc5 into theo/pick-wrist-servo Jul 16, 2026
4 checks passed
@greptile-apps

greptile-apps Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces workspace/skill_lib/ — a shared Python library of arm primitives and camera geometry that multiple skills can call directly, replacing copy-pasted private helpers. gripper_open gains the overcurrent-trip recovery logic that previously only pick_any_object had, and pick_any_object sheds ~150 lines of duplicated helpers in favor of armlib calls.

  • skill_lib/arm.py centralizes open_checked, close, move_checked, recover, and reach clamping — hardware lessons that previously lived in multiple files.
  • skill_lib/geometry.py moves the pinhole floor-projection math verbatim from pick_any_object, keeping it in sync with the webapp panel.
  • catalog.py adds _evict_skill_lib() so reload_all / reload_selective also flush workspace.skill_lib.* from sys.modules, ensuring lib edits are picked up on the next skills reload.

Confidence Score: 3/5

The consolidation is architecturally clean, but pick_any_object discards the open_checked return value in _open_gripper_checked, meaning a gripper that fails to open after servo recovery will let the skill continue into the grasp with a closed claw rather than aborting.

The refactoring correctly extracts hardware knowledge into one place and gripper_open.py is handled properly. The gap is specifically in pick_any_object._open_gripper_checked, which calls armlib.open_checked without capturing or acting on the bool it returns. If the recovery sequence does not clear the trip, the pick proceeds as if the gripper is open — the robot will attempt to grasp and lift an object with a closed hand.

workspace/innate_skills/pick_any_object.py (_open_gripper_checked return-value handling) and workspace/skill_lib/arm.py (open_checked post-retry j6 re-verification).

Important Files Changed

Filename Overview
workspace/innate_skills/pick_any_object.py Drops ~150 lines of private helpers for armlib calls; _open_gripper_checked silently discards the open_checked return value, meaning gripper failures after recovery no longer abort the pick.
workspace/skill_lib/arm.py New shared library for arm primitives (open_checked, close, move_checked, recover, clamp_reach, ee_xyz, gripper_j6, ArmUnhealthy). Core logic is correct; the j6 post-retry re-verification gap in open_checked is a P2 edge case.
ros2_ws/src/brain/brain_client/brain_client/skills/catalog.py Adds _evict_skill_lib (safe sys.modules snapshot+delete pattern) called from reload_all and reload_selective; eviction fires even for physical-skill-only reloads, which is wasteful but harmless.
workspace/innate_skills/gripper_open.py Gains trip-recovery verification via open_checked; correctly checks the return value and adds joint_states RobotState for j6 reading; init removal is safe (unused _cancelled flag).
workspace/innate_skills/gripper_close.py Thin wrapper over armlib.close; correctly checks return value; init removal is safe since _cancelled was never read in execute().
workspace/skill_lib/geometry.py Pure-math pixel↔floor projection moved verbatim from pick_any_object; constants and logic are unchanged, no issues found.
workspace/skill_lib/README.md New doc clearly explains the import-at-top constraint, the two-rule design contract, and the manual reload workflow for lib edits.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant S as Skill (gripper_open / pick_any_object)
    participant AL as armlib (skill_lib/arm.py)
    participant M as manipulation interface
    participant JS as joint_states (RobotState)

    Note over S,JS: open_checked flow
    S->>AL: open_checked(manipulation, get_j6, ...)
    AL->>M: torque_on()
    AL->>M: "open_gripper(blocking=True)"
    M-->>AL: ok
    AL->>JS: get_j6()
    JS-->>AL: j6 value
    alt "j6 >= 0.10 (opened OK)"
        AL-->>S: bool(ok)
    else "j6 < 0.10 (trip detected)"
        AL->>AL: recover() — reboot_servos + torque_on
        AL->>M: "open_gripper(blocking=True) [retry]"
        M-->>AL: ok2
        AL-->>S: bool(ok2)
        Note over S: return value discarded in _open_gripper_checked
    end

    Note over S,JS: move_checked flow
    S->>AL: move_checked(manipulation, x, y, z, pitch, ...)
    loop attempt 1 then 2
        AL->>M: "move_to_cartesian_pose(blocking=True)"
        M-->>AL: ok
        AL->>M: get_current_end_effector_pose()
        M-->>AL: ee_xyz
        alt "ok and err <= tol"
            AL-->>S: True
        else
            AL->>AL: recover() [attempt 1 only]
        end
    end
    AL-->>S: raises ArmUnhealthy
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant S as Skill (gripper_open / pick_any_object)
    participant AL as armlib (skill_lib/arm.py)
    participant M as manipulation interface
    participant JS as joint_states (RobotState)

    Note over S,JS: open_checked flow
    S->>AL: open_checked(manipulation, get_j6, ...)
    AL->>M: torque_on()
    AL->>M: "open_gripper(blocking=True)"
    M-->>AL: ok
    AL->>JS: get_j6()
    JS-->>AL: j6 value
    alt "j6 >= 0.10 (opened OK)"
        AL-->>S: bool(ok)
    else "j6 < 0.10 (trip detected)"
        AL->>AL: recover() — reboot_servos + torque_on
        AL->>M: "open_gripper(blocking=True) [retry]"
        M-->>AL: ok2
        AL-->>S: bool(ok2)
        Note over S: return value discarded in _open_gripper_checked
    end

    Note over S,JS: move_checked flow
    S->>AL: move_checked(manipulation, x, y, z, pitch, ...)
    loop attempt 1 then 2
        AL->>M: "move_to_cartesian_pose(blocking=True)"
        M-->>AL: ok
        AL->>M: get_current_end_effector_pose()
        M-->>AL: ee_xyz
        alt "ok and err <= tol"
            AL-->>S: True
        else
            AL->>AL: recover() [attempt 1 only]
        end
    end
    AL-->>S: raises ArmUnhealthy
Loading

Comments Outside Diff (1)

  1. ros2_ws/src/brain/brain_client/brain_client/skills/catalog.py, line 296-304 (link)

    P2 _evict_skill_lib is called for physical-skill-only selective reloads

    _evict_skill_lib() is called unconditionally at the start of reload_selective, before checking whether any of the requested skill_ids are code skills. When only physical skills are being reloaded (e.g., hot-reloading a metadata.json), this unnecessarily evicts workspace.skill_lib.* from sys.modules. The README constraint (import at module top) means already-loaded instances are unaffected, so this is harmless in practice — but guarding the eviction on whether any skill_id maps to a code skill would make intent clearer.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Reviews (1): Last reviewed commit: "refactor(skills): shared skill_lib for a..." | Re-trigger Greptile

Comment on lines 978 to +983
def _open_gripper_checked(self):
"""Open the claw, and make sure it actually opened. A prior hard
close can overcurrent-trip the gripper servo — a hardware fault
torque_on alone can't clear — after which open silently no-ops and
the hand stays shut straight into the grasp. Reboot clears the trip."""
gripper_open()
j6 = self._gripper_j6()
if j6 is not None and j6 < 0.10:
self.logger.warning(
f"[PickAnyObject] gripper did not open (j6={j6:.3f}); "
"rebooting servos to clear a trip, then retrying")
self._dbg("gripper_reboot", j6=_r(j6))
self._recover_arm()
gripper_open()
"""Open the claw, verified (trip recovery) — see armlib.open_checked."""
armlib.open_checked(
self.manipulation, self._gripper_j6, logger=self.logger,
on_reboot=lambda j6: self._dbg("gripper_reboot", j6=_r(j6)),
)

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.

P1 _open_gripper_checked silently discards open_checked return value

armlib.open_checked(...) is called without capturing its return value. If the gripper fails to open even after the servo reboot and retry, open_checked returns False but _open_gripper_checked proceeds as if success — the skill continues into _push_to_floor with a closed claw. The parallel call in gripper_open.py (line 48) correctly checks if not ok: return FAILURE, but this in-context call does not. Previously, routing through innate.skills.gripper_open() could raise SkillFailed on a hardware motion failure; now that path is gone and the failure is swallowed entirely.

Suggested change
def _open_gripper_checked(self):
"""Open the claw, and make sure it actually opened. A prior hard
close can overcurrent-trip the gripper servoa hardware fault
torque_on alone can't clearafter which open silently no-ops and
the hand stays shut straight into the grasp. Reboot clears the trip."""
gripper_open()
j6 = self._gripper_j6()
if j6 is not None and j6 < 0.10:
self.logger.warning(
f"[PickAnyObject] gripper did not open (j6={j6:.3f}); "
"rebooting servos to clear a trip, then retrying")
self._dbg("gripper_reboot", j6=_r(j6))
self._recover_arm()
gripper_open()
"""Open the claw, verified (trip recovery) — see armlib.open_checked."""
armlib.open_checked(
self.manipulation, self._gripper_j6, logger=self.logger,
on_reboot=lambda j6: self._dbg("gripper_reboot", j6=_r(j6)),
)
def _open_gripper_checked(self):
"""Open the claw, verified (trip recovery) — see armlib.open_checked."""
ok = armlib.open_checked(
self.manipulation, self._gripper_j6, logger=self.logger,
on_reboot=lambda j6: self._dbg("gripper_reboot", j6=_r(j6)),
)
if not ok:
raise armlib.ArmUnhealthy("gripper failed to open after trip recovery")

Comment on lines +85 to +103
def open_checked(manipulation, get_j6, percent=100.0, duration=1.0,
logger=None, on_reboot=None):
"""Open the gripper and VERIFY it opened. A prior hard close can
overcurrent-TRIP the gripper servo — a hardware error torque_on alone
can't clear — after which open silently does nothing and the hand stays
shut. If j6 says it didn't open, reboot to clear the trip and retry.
on_reboot(j6) is called before the reboot (telemetry hook)."""
manipulation.torque_on() # a torque-disabled servo won't move at all
ok = manipulation.open_gripper(percent=percent, duration=duration, blocking=True)
j6 = get_j6()
if j6 is not None and j6 < 0.10:
if logger:
logger.warning(f"[arm] gripper did not open (j6={j6:.3f}); "
"rebooting servos to clear a trip, then retrying")
if on_reboot:
on_reboot(j6)
recover(manipulation, logger)
ok = manipulation.open_gripper(percent=percent, duration=duration, blocking=True)
return bool(ok)

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.

P2 open_checked does not re-verify j6 after recovery + retry

After rebooting the servos and calling open_gripper a second time, the function returns bool(ok) without reading get_j6() again. If the servo is still tripped, ok may be True (command accepted) while the gripper physically stays closed, and open_checked returns True to its caller. Adding a second j6 check after the retry would close this gap — since gripper_open.py now uses this path too, the silent false-success affects the standalone skill as well as pick_any_object.

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.

1 participant