Skip to content

feat(skills): Pick Anything Skill + Other changes - #542

Merged
theo-michel merged 13 commits into
mainfrom
theo/pick-wrist-servo
Jul 31, 2026
Merged

feat(skills): Pick Anything Skill + Other changes#542
theo-michel merged 13 commits into
mainfrom
theo/pick-wrist-servo

Conversation

@theo-michel

@theo-michel theo-michel commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

What

Three things, in dependency order:

  1. The skills authoring API — one rule for everything a skill consumes: annotate it. mobility: Mobility, odom: Odometry, image: MainImage, and now also sub-skills (arm_rest: ArmRestPosition) and physical skills (pick_socks = PhysicalSkill("pick_socks")). The base Skill supplies what every file used to repeat, and robot state arrives as typed values instead of raw ROS-shaped dicts. Every shipped skill is migrated onto it.
  2. Discovery & packaging — skills directories are ordinary Python packages, imported rather than scanned. Defining a Skill subclass is the registration; ids are namespaced by package (innate-os/<name>, local/<name>, <pack>/<name>); a pack installs by dropping (or symlinking) a folder into workspace/. A module that fails to import shows up in the web app marked broken with its error instead of vanishing.
  3. pick_any_object — a wrist-camera fine-alignment grasp. It's the first skill written against the new API at full size, which is what shook most of the API out.

Scope note — things this PR carried and no longer does: a @skill function decorator (built, migrated to, then reverted on team feedback — ctx read as obscure); robot-persisted favorite skills (moved to #561); a live-tuning layer — slider dock, tuning topic, draggable wrist box (removed, #555 folded in); the pick visualization/debug topic (removed end to end; tuning is edit PARAMS + hot-reload); a workspace/skill_lib/ shared library (dissolved — runtime helpers folded into the interfaces and innate.*, skill-side helpers live next to the skills, e.g. innate_skills/arm/arm_utils.py); bare-name skill declaration for agents (reverted); the standalone gripper_open / gripper_close skills (deleted — callers use the Manipulation interface directly, so the claw is no longer its own agent/webapp-callable skill); the unit-test suites written alongside the refactor (removed, see Testing); and demo_pick_anything (moved to gitignored custom_skills/, not shipped). docs/ updates follow separately.


1. Skills API

Declare by annotating

from innate import MainImage, Mobility, PhysicalSkill, Skill
from innate_skills.arm_rest_position import ArmRestPosition

class FindTheDog(Skill):
    """Drive around until the dog is in frame."""   # ← the agent-facing guidelines

    mobility: Mobility          # required: never None inside execute()
    image: MainImage            # required: run fails up front if no frame arrives
    battery: Battery | None     # best effort: None until a reading lands
    arm_rest: ArmRestPosition   # sub-skill: call it like a method
    pick_socks = PhysicalSkill("pick_socks")   # trained policy, same call shape

    def execute(self):
        self.mobility.rotate(0.5)

The type identifies the feed. Plain annotation = guaranteed: the server waits, bounded per feed (cameras 3 s, battery 6 s, everything else 2 s), and fails the run before execute() if nothing arrives — so no skill needs an if self.x is None guard. | None = best effort — and so is a = None class default (image: MainImage | None = None declares the feed too, it doesn't opt out of injection). Reading an undeclared feed raises with the annotation to add, and your editor flags the typo before you ship.

The class name is the skill name (snake_cased), and the class docstring is guidelines() — so a skill states its purpose exactly once.

Composition is a declaration too

A declared sub-skill is constructed per run, wired like a root skill (same run node, interfaces, feeds, invoker, feedback), and shares the parent's cancel latch. It sits on the attribute as a callable: self.arm_rest() raises SkillFailed/SkillCancelled instead of returning a status. PhysicalSkill("id") gives trained/recorded policies the same declaration block and the same call shape, and moves an unknown-id error to run start instead of mid-routine. The old string-based composition path is deleted.

Robot state is typed values, not dicts

odom["pose"]["pose"]["position"]["x"]odom.x. joint_states["position"][5]arm.gripper. These live in their own brain_client/state/ package — they were never skill machinery, only the values skills consume: Odometry, Pose (map frame), Battery, Lidar (+ min_range() sector helper), Arm, Map (lazy .grid), JointStates, HeadState, Image/MainImage/WristImage (the value is the b64 string, .jpeg gives bytes), DepthMap (numpy). Every one is ROS-free and importable outside rclpy.

Old dict access still works everywhere via dictcompat.LegacyMapping — every legacy-injected feed keeps its historical dict shape, soft-deprecated with a warning, no removal scheduled.

One result object, not a tuple

SkillOutput is a real object instead of a str subclass with .data bolted on: .message, .status (a SkillResult enum, never a bare string), .data for structured payloads, and .ok as the success check. str(output) and f-strings still give the message, and legacy message, status = ... unpacking still works behind a deprecation warning.

For skill authors this means a skill returns one value — return "Picked it up" — and signals failure with self.fail(...), rather than hand-assembling (message, SkillResult.FAILURE) tuples and catching SkillCancelled/SkillFailed to convert them. The framework owns those exceptions now. Every shipped skill is migrated onto it.

Also in the base class

self.fail() (NoReturn), self.cancelled / check_cancelled(), per-run self.on_cancel(hook) for braking on the cancelling thread, self.feedback(), self.wait_for(read, timeout), self.storage, and None/str returns treated as success. Skill instances are per-run: constructed for the run, disposed at its end.

Resources — expensive objects a skill owns, with a per-run lifecycle:

class NavigateToPosition(Skill):
    @resource
    def controller(self):           # built on first access, cached for the run
        c = Nav2Controller(self)
        yield c
        c.destroy()                 # teardown runs at run end, interfaces still live

(This means e.g. the Nav2 stack is constructed per navigation rather than kept warm for the process — see Notes for review.)

pyrightconfig.json scopes pyright to the skills tree, which is standard-mode clean — and caught a real bug on the way (move_to_cartesian_pose(duration: int) while every caller passed fractional seconds).


2. Discovery & packaging

  • Import-based discovery: defining a Skill subclass registers it (like a PyTorch nn.Module). The class is the identity, so files organize freely — several skills per file, a skill split across a subpackage, helpers next to it. One-way doors avoided: skill-id collision behavior is unchanged from main.
  • Skill packages: every skills directory under workspace/ is a plain Python package. innate_skills/ (shipped) and custom_skills/ (yours, gitignored) are just the two built-in packs; any other folder dropped in — or symlinked in (ln -s /opt/team/skills workspace/team_skills) — is a pack, ids namespaced by folder name. Folder skills work (innate_skills/wave/, pick_socks/). Packages import each other by bare name (from innate_skills import arm_utils).
  • This replaces the 0.6.x extra_skill_dirs / extra_agent_dirs setting (settings knob, webapp catalog entry, and the media-route serving lanes are gone), and 0.7 no longer scans ~/skills / ~/agents in place — migrate_user_data.sh moves both home lanes into workspace/custom_* so nothing silently stops loading (sudo-safe via ACTUAL_HOME).
  • Broken skills stay visible: a module that fails to import appears in the web-app skills menu as a disabled row with its load error, and clears when fixed. Hot reload (/brain/reload_primitives or save-watcher) covers helper modules too.
  • Shared runtime plumbing that used to be workspace/skill_lib/ now lives in the platform: innate.gemini, innate.geometry, innate.vision, and the Arm* exceptions on the manipulation interface.

3. pick_any_object

After the base parks the object in the head-camera pick box, the grasp:

  1. Search pose — arm to an operator-posed position (up high, elbow ~90°, wrist camera looking down). Captured joints in WRIST_SEARCH_ARM.
  2. Seed — Gemini returns a tight box around the object; its center is the mark.
  3. Servo down — center-in-box (nudge x/y), descend one step, repeat, until wrist_stop_z, where the existing blind ladder finishes the grasp, then close/twist/lift. Persistent tracking loss buys a Gemini re-seed (budget = wrist_steps − 1).

Cancel semantics were tightened on review: a Stop cancels the search-pose move too, and once the fingers commit the skill never releases a grasped object — it folds with the grip kept.

Why segmentation, not optical flow

During the descent the object grows ~2.5× in the wrist image and fabric deforms, which slides LK optical-flow patches onto the carpet — this failed repeatedly in live testing. Tracking is HSV color segmentation + CamShift: a likelihood-ratio color model (object vs the floor immediately around it) back-projected each frame is scale- and deformation-proof, and its score honestly reports occlusion/loss instead of confidently tracking carpet.

Observability

No visualization or debug channel: tuning is by editing PARAMS in pick_any_object.py + hot-reload, and the skill's observable surface is its log lines (localization fixes, wrist-stage exit reasons, the grasp verdict).


Also riding along

  • Gripper current-based position control (mars_arm): closing uses a goal_current cap instead of software force loops, so grip strength is a hardware limit rather than a control loop; strength/percent are clamped to hardware-safe ranges. arm_control's gain_mode_ is atomic with a per-waypoint re-assert (idle-decay TOCTOU).
  • Review-fix rounds (from @karmanyaahm's passes): cancels landing between slot claim and run wiring are latched and applied instead of silently dropped (code + physical paths); cancel dispatch moved outside the execution lock so on_cancel hooks can't deadlock the server; run instances dispose before interfaces stop so @resource teardowns can command the arm; a Stop racing the invoker's timeout watchdog latches the routine cancel; Gemini retries take a cancel predicate; main/wrist frames are memoized per frame in the 50 Hz injection path; the grasp-verify prompt labels images by the camera that actually supplied them.
  • post_update.sh: enforce root:<user> 640 on an existing /etc/innate.env — a hand-created 600 root:root file is unreadable to the non-root launch readers, silently dropping the service key (proxy "not configured").
  • CameraProvider.start(feeds) subscribes only the declared feeds — raw depth (~600 KB/frame) no longer streams for skills that never asked for it.
  • Webapp: press j to hide/show the on-screen joystick (releases any latched command on hide).
  • ruff isort knows workspace, innate, innate_proxy as first-party; a repo-wide pass removed narrating comments.

Testing

  • pre-commit run --all-files (ruff + ruff format + clang-format + config guards) clean; pyright standard-mode clean on the skills tree.
  • No automated coverage for the skills refactor. The unit suites written alongside this work (declaration → injection → gating, composition, packaging, broken-catalog, hot reload, loader helpers) were removed from this PR deliberately, along with their entries in ci/run_integration_tests.sh. The no-ROS CI bucket now runs only test_fake_cloud_selftest.py and test_config_validation.py; test_backwards_compat.py is deleted. Everything below is manual verification — a reviewer should not read this section as "the refactor is covered".
  • innate/geometry.py verified bit-identical to the math it replaced (round-trip + old-vs-new comparison over sample poses).
  • Segmentation tracker validated offline: object drifting while growing 40→80 px tracked to ~1 px; carpet-patch occlusion → clean "lost".
  • Skill developed live on mars (hot-reloaded via /brain/reload_primitives).

Notes for review

  • Gain signs (wrist_kx/wrist_ky) are unverified against the physical wrist-cam mount orientation. If the servo diverges on hardware, flip them in PARAMS + hot-reload; divergence is bounded by wrist_step_max and the reach clamp, so the failure mode is a mis-aimed grasp and a timeout, not a runaway arm. Until confirmed on-robot, treat the wrist stage as experimental; wrist_steps=0 falls back to the proven blind grasp.
  • Needs hardware validation before merge: the per-run resource lifecycle rebuilds the Nav2 controller each navigation, so local_frame=True goals must fill a cold TF buffer within the 2 s lookup timeout — sanity-check local goals and mid-navigation cancels on a robot.
  • Weak spot: an object whose color ≈ the floor gives a flat model → early "lost track" → blind-descent fallback (an honest failure, not false tracking).

@greptile-apps

greptile-apps Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR ships two layered changes: a new annotation-driven Skills API (replacing per-skill boilerplate with class-level type declarations, typed robot-state values, and a @resource lifecycle) migrated across all 24 shipped skills, and pick_any_object — a wrist-camera visual-servo grasp skill that factored shared machinery into skill_lib. It also folds in fixes from the previous review pass and several infrastructure improvements.

  • Skills API: The annotation model (mobility: Mobility, image: MainImage | None) drives injection, per-feed grace-period gating, and pyright type-checking in one declaration. The server now constructs a fresh instance per run, fixing the retired-instance lifetime bug.
  • pick_any_object: Gemini seeds the wrist-camera CamShift tracker; the servo descends to wrist_stop_z, then the proven blind ladder finishes the grasp. Verification uses dynamically labelled images from whichever cameras are present.
  • Arm C++: gain_mode_ is now std::atomic, the idle SCHEDULED→TELEOP decay is blocked during trajectory_executing_, and per-waypoint re-assert handles the TOCTOU race. Gripper goal is seeded from last commanded position to prevent drop-on-move.

Confidence Score: 4/5

Safe to merge with the understanding that the wrist-servo stage and per-run Nav2 TF buffer need hardware validation before relying on them in production.

The major correctness issues from the previous review round are addressed. The one new finding is a stall-counter bookkeeping gap in _wrist_descend that can cause premature handoff to the blind push — the failure mode is a slightly earlier exit to a proven fallback rather than a crash or servo trip.

Files Needing Attention: workspace/innate_skills/pick_any_object.py (stall-counter fix, experimental wrist-servo stage), ros2_ws/src/brain/brain_client/brain_client/nodes/skills_server.py (cancel-latch + slot lifecycle), and the arm C++ files (gain atomics, per-run Nav2 warm-up).

Important Files Changed

Filename Overview
workspace/innate_skills/pick_any_object.py New pick skill; most previously-flagged bugs corrected. One remaining subtle issue: stalled counter not reset during stepped_down phases.
ros2_ws/src/brain/brain_client/brain_client/nodes/skills_server.py Major rewrite with per-run instance model, _pending_cancel_goal latch, and cancel dispatched outside the execution lock.
ros2_ws/src/mars_bot/mars_arm/mars_arm/arm_trajectory.cpp Adds RAII HoldGuard, per-waypoint gain_mode_ re-assert, and gripper goal seeding from commanded position.
ros2_ws/src/brain/brain_client/brain_client/skills/robot_state.py New typed snapshot accessors with per-message caching and per-feed grace-period wait.
ros2_ws/src/brain/brain_client/brain_client/skills/types.py SkillFailed/SkillCancelled hierarchy, _FeedSpec annotation-driven injection, resource lifecycle.
ros2_ws/src/brain/brain_client/brain_client/skills/catalog.py CodeSkillEntry/PhysicalSkillEntry dataclasses, broken-skill tracking, workspace-root hot-reload eviction.
ros2_ws/src/brain/brain_client/innate/vision.py New stateless vision helpers consistent with their callers in pick_any_object.
scripts/update/post_update.sh Idempotent fix for hand-created /etc/innate.env with wrong permissions.

Sequence Diagram

sequenceDiagram
    participant Agent
    participant SkillsServer
    participant RobotState
    participant Skill
    participant Invoker
    participant ArmControl

    Agent->>SkillsServer: ExecuteSkill goal
    SkillsServer->>SkillsServer: _claim_skill_slot()
    SkillsServer->>SkillsServer: _instantiate_for_run(entry)
    SkillsServer->>RobotState: start_subscriptions()
    SkillsServer->>RobotState: wait_for_required_states(skill)
    Note over RobotState: per-feed grace period
    RobotState-->>SkillsServer: injected typed state
    SkillsServer->>RobotState: "begin_continuous_updates @ 50Hz"
    SkillsServer->>Skill: "execute(**inputs)"
    Skill->>Invoker: self.gripper_open()
    Invoker->>Skill: child.execute()
    Skill-->>Invoker: result
    Invoker->>SkillsServer: _dispose_run_instance(child)
    Skill->>ArmControl: manipulation.move_checked()
    ArmControl-->>Skill: ok
    Skill-->>SkillsServer: SkillResult
    SkillsServer->>SkillsServer: _dispose_run_instance(skill)
    SkillsServer->>RobotState: stop_subscriptions()
    SkillsServer->>SkillsServer: _release_skill_slot()
    SkillsServer-->>Agent: ExecuteSkill.Result
Loading

Reviews (51): Last reviewed commit: "fix(skills): address code review — call ..." | Re-trigger Greptile

Comment thread webapp/js/teleop/pickTunePanel.js Outdated
Comment thread webapp/js/teleop/pickTunePanel.js Outdated
Comment thread workspace/innate_skills/pick_any_object.py Outdated
Comment thread workspace/innate_skills/pick_any_object.py
Comment thread webapp/js/teleop/pickPanel.js Outdated
Comment thread workspace/innate_skills/pick_any_object.py Outdated
Comment thread workspace/innate_skills/pick_any_object.py Outdated
@theo-michel

Copy link
Copy Markdown
Contributor Author

Folded #555 in (merge a7515d9c): removes the pick tuning panel (the wrench-button slider dock in pickPanel.js) from the cockpit. The pick overlay (pickOverlay.js — the on-video grasp/box viz with the draggable wrist box) stays, and it still publishes to the skill-side tuning topic, so pick_any_object's TUNING_TOPIC handling is untouched. Net: the 40-slider dock is gone, the visual overlay and live-tuning plumbing remain.

theo-michel added a commit that referenced this pull request Jul 21, 2026
…rator + loader hardening

Decorator (review items from #542):
- ctx.fail() annotated NoReturn so pyright narrows past it
- ctx.on_cancel(cb): per-run hook fired on the cancelling thread right after
  the latch sets — the function-style equivalent of a cancel() override that
  stops the base / cancels a Nav2 goal immediately; hooks clear at run end
- @Skill(on_shutdown=...): retire hook for module-owned long-lived resources
  (the class form's shutdown())

Loader (mechanical guards instead of comments):
- a file defining >1 skill is rejected loudly (catalog id comes from the
  filename — a second skill would silently collide)
- a skill whose name differs from its filename stem gets a load-time warning

Migration — every skill in workspace/innate_skills/ is now a @Skill function
except pick_any_object, which keeps the class form deliberately: it creates
its tuning/debug topic IO at registration time (guidelines()) so the teleop
panel's {} sync handshake acks before any run, and shutdown() mutes the
retired instance; the decorator has no registration-time hook and inventing
one for a single caller is the speculative surface the RFC rejects.

Equivalence check (stubbed-ROS harness, HEAD vs migrated): 24/24 skills have
identical name, execute() signature (schema), guidelines text, and declared
robot states. Two guidelines gained a missing space ("map.If" -> "map. If").
Interfaces now uniformly declare all three per the decorator's design —
injection is a singleton reference assignment, so this costs nothing.
Behavior tests: on_cancel/on_shutdown wiring, cancel latch, hook clearing,
and both loader guards (14/14 pass). ruff check + format clean.
theo-michel added a commit that referenced this pull request Jul 21, 2026
…s/head_position, Camera declaration

Non-camera state no longer needs a declaration: the server already
subscribes every feed per run, so the Skill base now exposes read-through
properties backed by new RobotStateProvider.current_* snapshot accessors
(each read converts the newest cached message — fresher than the 50 Hz
inject). Cameras keep a declaration, renamed to its real job:
image = Camera("main"|"wrist") — the server must know before execute()
to start the camera and warm the first frame.

- self.battery returns a typed innate.Battery (Odometry precedent),
  with the soft-deprecated dict-compat layer for old battery["..."] code
- legacy RobotState descriptors keep working and shadow the ambient
  properties of the same name
- shipped skills migrated (pick_any_object deferred: in-flight on PR #542)
- tests: test_ambient_robot_state.py, wired into ci/run_integration_tests.sh
theo-michel added a commit that referenced this pull request Jul 21, 2026
… Mobility, image: MainImage

The dataclass idiom replaces descriptor assignments as the canonical
declaration form:

    class FetchSock(Skill):
        mobility: Mobility
        head: Head | None      # optional — checkers force the guard
        image: MainImage       # required — run fails if no frame arrives

- Skill.__init_subclass__ scans bare annotations and mints the existing
  Interface/Camera descriptors (type identifies the feed, | None makes it
  optional); annotated constants and non-feed types are untouched; string
  annotations resolve against the skill module. Pure sugar: server,
  warmup, injection, and every legacy form keep working.
- The annotation is real typing: pyright/mypy see the actual interface
  and Image types, so required feeds are non-Optional and self.mobility.
  autocompletes.
- Required cameras now fail the run after the warmup grace instead of
  handing execute() a None — send_picture/record_position/recalibrate/
  follow_aruco drop their manual no-image guards.
- MainImage/WristImage (Image subclasses) and DepthMap (ndarray view)
  carry the feed identity; injected values use them.
- Head state type renamed HeadState; innate exports Mobility/Manipulation/
  Head lazily (PEP 562) since they pull ROS/Nav2 modules.
- All shipped skills migrated (pick_any_object still deferred to PR #542).
theo-michel added a commit that referenced this pull request Jul 22, 2026
Framework:
- exact begin/end pairing for the 50 Hz state slot: the "No data" return
  path exited before begin_continuous_updates, and the unmatched end
  handed a chaining parent's slot back too early, freezing its ambient
  state mid-run
- optional/legacy camera declarations get the same bounded first-frame
  wait as required ones (their frame is just as in-flight; the skill
  merely chose to handle a miss itself); the wait is now cancel-aware
- CameraProvider.start(feeds) subscribes only the declared feeds — raw
  depth is ~600 KB/frame and was streaming for skills that never asked
- loader: private/abstract Skill subclasses are helper bases, exempt from
  the one-skill-per-file rule

Skills:
- detect_opponent_move: cameras are '| None' — detection degrades to
  whichever camera is live (every use is guarded) instead of failing the
  run up front when one is offline
- follow_aruco: '| None' + an explicit 5 s wait_for; the 3 s required
  grace is too tight for a cold camera in sim
- pick_any_object: cancel chaining via on_cancel/check_cancelled (the
  cancel() override skipped child-skill cancellation), whole-word "NO"
  in the floor-clear check (hedges like "CANNOT" read as clear),
  off-image pick box returns None, flow-follow tracks only new frames,
  no per-frame EE fetch (~10 ms each), drop the redundant name property
  and interface guards the server now enforces
- post_update.sh: chown/chmod best-effort under set -e
- pickOverlay: box:null clears the drawn box (skill projects off-image)
theo-michel added a commit that referenced this pull request Jul 22, 2026
- loader: register skill modules in sys.modules during exec so string /
  `from __future__ import annotations` annotations resolve on the REAL
  load path (they silently minted nothing before); leave INNATE_OS_ROOT
  on sys.path so lazy in-function `workspace.*` imports work too
- Interface: hand-built Interface(InterfaceType.X) is tolerant again
  (old behavior, same rule as legacy RobotState); constants/annotations
  keep declaring-is-requiring
- skills_server: a cancel landing during the pre-run state warmup now
  reports CANCELLED instead of a phantom "No data from the ..." failure
- SkillCancelled is a BaseException (asyncio.CancelledError precedent):
  a skill's broad `except Exception` can no longer swallow a cancel
- normalize_skill_result: a forwarded child SkillOutput keeps its .data
- robot_state: memoize the Lidar conversion per scan message (the 50 Hz
  thread was copying ~1100 ranges per tick against a ~10 Hz topic)
- dictcompat: FutureWarning, not DeprecationWarning — the default filter
  hides the latter everywhere except pytest, so authors never saw it
- pick_any_object: migrate to the annotation API (was the last legacy-
  style skill); clamp tuning-topic values into per-knob hard ranges and
  reject non-finite floats before they can reach the servos; retired
  _DebugIO drops its skill ref so reloads don't pin dead instances
- record_position/recalibrate_manual: wrist image back to best-effort —
  it only feeds a debug snapshot and must not abort calibration
- retrieve_emails: don't re-wrap our own SkillFailed in the broad except
- pickOverlay: guard releasePointerCapture on pointercancel so the final
  wrist-box position still publishes
- tests: loader-path future-annotations coverage (red before the fix),
  legacy-interface tolerance, SkillOutput passthrough
@theo-michel theo-michel changed the title feat(pick): wrist-camera visual-servo grasp for pick_any_object + tuning panel feat(skills): annotate-what-you-consume skills API + wrist-camera visual-servo grasp Jul 22, 2026
Comment thread workspace/innate_skills/pick_any_object.py Outdated
theo-michel added a commit that referenced this pull request Jul 22, 2026
Six findings from a recall-oriented review of the branch:

- vision: clamp Gemini's normalized 0-1000 coords (_norm1k). They drift
  out of range, and an off-image/negative box reached seg_model's numpy
  slice and CamShift, raising cv2.error out past execute()'s handlers
  instead of taking the designed "lost track -> blind descent" fallback.

- loader: a file defining two skills no longer loses both. Keep the one
  the filename names (the catalog id), drop the extras with an
  actionable error — rejecting the whole file vanished a
  previously-working skill because of a stray helper class.

- camera_provider: refcount per feed, not just overall. A feed only a
  nested child declared (raw depth, ~600 KB/frame) streamed for the rest
  of the parent's run — exactly the cost start(feeds) exists to avoid.
  Dropping one parks the private spin thread first, since destroying
  entities under a spinning executor races it (#497); survivors miss at
  most one frame interval.

- robot_state: the pre-run warmup now waits for every declared feed, not
  just required ones plus cameras. An optional/legacy declaration's first
  message is just as much in flight, so a skill reading self.odom in its
  opening lines saw None for one publish period and silently took its
  degraded path — pick's rotate_by fell back to open-loop with no log at
  all. The wait still ends as soon as the values land. Also gives
  moblib.rotate_by the open-loop warning drive already had.

- types: reject `resource: SomeResource | None` at class definition.
  The optional flag was computed and then dropped, so the annotation
  built the same eager, never-None resource as the bare form and left
  the author's None guards as dead code.

- pick_any_object: guidelines text moves into the class docstring (the
  API's own rule) and the override goes. It was also building the
  debug_io resource as a side effect, so every catalog metadata refresh
  created a publisher + subscription on the shared node — which _DebugIO
  deliberately never destroys. debug_io now builds on the first _dbg.

Also applies ruff format to pick_any_object.py, which was left
unformatted by eb4852f (one list reflow) — the format gate is red on
the branch without it.

Tests: two new cases in test_ambient_robot_state.py (loader keeps the
filename skill; optional resource annotation rejected). 27 pass.
@theo-michel theo-michel changed the title feat(skills): annotate-what-you-consume skills API + wrist-camera visual-servo grasp feat(skills): Pick Anything Skill + Other changes Jul 23, 2026
Comment thread workspace/innate_skills/pick_any_object.py Outdated
theo-michel added a commit that referenced this pull request Jul 23, 2026
- gripper_open: trust j6 over the driver's return — a False status with
  the claw verifiably open (driver wait timeout) no longer fails the
  skill; the status only decides when j6 gives no evidence.
- pick_any_object: _grasp_verified falls back to the gripper (j6)
  evidence when Gemini returns no verdict at all, instead of reporting
  a demonstrably held object as a missed grasp. A hedged reply still
  counts as not-clear.
- robot_state: the pre-run warmup wait covers required feeds only.
  Optional (| None / legacy) declarations no longer stall the start —
  a dead optional feed used to cost its full grace (wrist camera 3 s,
  battery 6 s) on every run. Skills needing an optional feed in their
  opening lines wait themselves (follow_aruco already does).
theo-michel added a commit that referenced this pull request Jul 23, 2026
- pick: wrist_box_v is live-tuned to 380, but its comment and the
  pickOverlay.js seed both still said 300 — comment rewritten to match
  the tuned value, overlay seed synced.
- types: when the stdlib batch annotation eval fails, _own_annotations
  now retries per name (same eval context), so one typo'd annotation
  costs only its own declaration instead of silently dropping every
  feed on the class — which also blinded the server's up-front
  missing-interface gate.
- types: @resource no longer caches a None factory result. None is the
  documented "unavailable" signal (missing credentials, dead network),
  and skills are long-lived singletons — caching it pinned a transient
  outage for the instance's lifetime; now the next access retries.
- camera_provider: guard the depth-frame reshape like its sibling in
  perception/camera.py — a padded/truncated frame reads as "no frame"
  instead of raising ValueError out of the pre-run state wait.
Comment thread ros2_ws/src/brain/brain_client/innate/gemini.py
@karmanyaahm

Copy link
Copy Markdown
Contributor

First: the PR description no longer matches the code
Two independent reviewers confirmed this. The description advertises 45 live-tunable knobs on /pick_any_object/tuning, 28 clamped by TUNABLE_BOUNDS, and a draggable wrist box that publishes overrides — none of that exists in the branch. grep -rn "tuning|TUNABLE" workspace/ is empty; PARAMS in pick_any_object.py:57 is fixed at load, and pickOverlay.js is pointer-events: none, visualization-only. The shipped state is the safer configuration, but the description promises a safety layer (bounds clamping) that isn't there and describes UI that isn't there. Rewrite the description before anyone else reviews against it.

Major findings
Framework core

Lost cancels via /brain/cancel_skill — skills_server.py:249-256: a service cancel landing between slot claim and _active_code_skill = skill is silently dropped. It invokes cancel_callback directly, which never transitions the action goal to CANCELING, so the _begin_run recovery check of is_cancel_requested stays False and the skill runs to completion after the service replied "Cancellation requested". A parallel hole exists for physical skills (the stale-cancel guard at line 646 eats the request).
= None default silently disables injection — types.py:411: image: MainImage | None = None is a natural authoring idiom, but the class-dict default suppresses descriptor minting, no declaration issue is recorded, and the typo-help getattr never fires — the skill just reads None forever with no warning. This is the sharp edge most likely to bite skill authors on day one.
Resource teardown after interfaces stop — skills_server.py:420-424: stop_subscriptions() (which parks the manipulation executor and nulls state) runs before _dispose_run_instance, so a @resource teardown that commands the arm or reads state fails or hangs every run. Nested resources dispose with feeds live; top-level ones don't. Swapping two lines fixes it.
Compat
4. JointStates and Battery lost dict-compat — joint_states.py:9, battery.py:9: every other legacy dict-injected feed got the LegacyMapping shim; these two didn't. An un-migrated custom skill doing self.joint_states["position"][5] or self.battery["percentage"] — the exact patterns the old API taught — now raises TypeError at run time.

Hardware safety
5. Unclamped gripper inputs — gripper_close.py:28, gripper_open.py:33-35: both skills are agent/webapp-callable and pass strength/percent unclamped to the servo, though gripper_close's own docstring documents that >0.6 overcurrent-trips servo 6 (needing a reboot). manipulation.close_gripper doesn't cap either. One-line clamps enforce the documented limits.

Cancellation latency
6. Uncancellable Gemini retries — gemini.py:39-55: the retry loop takes no cancel predicate; worst case ~186 s (3 × 60 s proxy timeout + backoff) during which "Stop" only halts the base while the run thread stays wedged. Thread a cancelled callable in, checked at least between attempts.

Behavior delta needing hardware validation
7. Per-run Nav2 stack, cold TF — navigate_to_position.py:279-283: main kept a warm process-lifetime Nav2 controller; now it's built per run, so local_frame=True goals must fill a cold TF buffer within the 2 s lookup timeout, and there's a new cancel-ignored window during controller construction (line 300-303). Sanity-check local_frame goals and mid-navigation cancels on hardware before merge.

Test coverage
8. The test file doesn't test the ambient path — test_ambient_robot_state.py: all 47 cases inject state manually; the 50 Hz update thread, the required-feed grace timings, and — most load-bearing — the never-re-inject-None invariant are untested. That invariant is exactly what justified deleting the mid-loop if current is not None guards in move_straight/turn_in_place; a provider regression would AttributeError mid-drive with motors commanded and no test would fail. Also untested anywhere: on_cancel hook dispatch, which five skills now rely on for immediate braking.

Minor findings (worth fixing, not blocking)
arm_control.cpp:128-137 — TOCTOU on gain_mode_: the decay's check-then-write races the trajectory thread's SCHEDULED write; a trajectory could run entirely on soft teleop gains (the exact failure the flag prevents). trajectory_executing_ went atomic; gain_mode_ didn't. Make it atomic or re-assert in the waypoint loop.
invoker.py:105-108 — the cancel re-entry guard returns before latching _cancelled, so a user Stop racing the timeout watchdog is dropped and the routine continues. Set _cancelled = True before the guard.
skills_server.py:205-215 — user on_cancel hooks run under _skill_execution_lock; a hook that waits for run completion deadlocks the server.
robot_state.py:250-256 — main/wrist images are base64-re-encoded at 50 Hz with no memoization (~10 MB/s of allocations on a Jetson); the file memoizes lidar for less. Apply the same (raw, converted) cache.
pick_any_object.py:836-846 — grasp verification labels the first image "head camera" even when the head feed is down and only the wrist frame is sent, which can misread a held object as dropped and open the gripper on it; relatedly (lines 902-921), a cancel between close and verification releases the grip mid-fold.
catalog.py:299 — skill_lib eviction misses the from workspace import skill_lib form (stale attribute on the cached workspace package survives reload).
mobility.py:60-65 — the no-odom open-loop fallbacks sleep through the full motion without checking cancelled.
Guideline drift — head_emotion and follow_aruco hardcode prose that was previously generated from EMOTIONS/STOP_ID; in sync today, silently desyncs later.

@theo-michel
theo-michel force-pushed the theo/pick-wrist-servo branch from 2c045dc to f373ca9 Compare July 29, 2026 20:31

Copy link
Copy Markdown
Contributor Author

Code review — recall pass (xhigh)

Reviewed 4942f37...598f853 (133 files, +7526/−5767). 14 findings, most severe first. The API/packaging design itself reads well; these are the defects I could substantiate against the code.


1. The wrist visual-servo descent has no cancel point on its fast path

workspace/innate_skills/pick_any_object.py:469

_wrist_descend's loop reaches a cancel check only via self.sleep() inside _next_wrist_hsv — but that returns immediately whenever a fresh wrist frame is already buffered, which is the common case after each 0.5 s blocking move_checked. Everything else in the loop is non-cancellable (vision.seg_track, move_to_cartesian_pose(blocking=True) waits on a plain threading.Event in Manipulation._wait_for_future).

Failure: operator presses Stop while the arm is stepping down at 15 Hz wrist frames → SkillCancelled is never raised; the arm keeps descending and nudging for up to WRIST_ALIGN_TIMEOUT_S = 60 s. This contradicts "a Stop cancels the search-pose move too" in the PR description. A self.check_cancelled() at the top of the while body would close it.

2. CLAUDE.md is truncated to 0 bytes

CLAUDE.md

The branch's blob is e69de29 (the empty blob) — all 86 lines removed, nothing added, and the scope note doesn't mention it. AGENTS.md is untouched, so this isn't a consolidation. Almost certainly an accidental blanking.

3. _close_twist_lift ignores move_to_joint_positions' return value — a silently skipped lift, then a reverse with the arm on the floor

workspace/innate_skills/pick_any_object.py:638 and :643

move_to_joint_positions never raises; it returns False for torque-disabled, client-not-initialized, service-not-ready, call exception, or a failed/timed-out motion result. Both the twist and the lift discard it. Every other arm call in this skill goes through go() / move_checked(), which raise ArmFailed / ArmUnhealthy — and the except LookupError fallback right below correctly uses move_checked.

Failure: GotoJS service momentarily unready after the gripper close → the lift is skipped, _grasp_at returns normally, _grasp_verified drives the base back 0.15 m with the end-effector still at floor_z = 0.03 and the object gripped, dragging arm and object across the floor.

4. Fresh-boot ordering: workspace/physical_skills/ is generated after code skills are imported

ros2_ws/src/brain/brain_client/brain_client/skills/catalog.py:112

SkillRepository.__init__ runs _load_code_skills() (the full workspace import) before anything calls publish_skills_list()_write_physical_refs(). workspace/physical_skills/ is gitignored, so on a fresh robot it does not exist during that first import — every skill doing from physical_skills import X is rostered broken for that pass. agents/initializer.py explicitly regenerates the refs before loading agents (_regenerate_physical_refs); the skills server has no equivalent pre-pass.

Failure: self-heals only through the workspace .py watcher; when watchdog isn't installed HotReloadWatcher.start() returns False (WATCHDOG_AVAILABLE guard) and the skills stay broken until a manual /brain/reload_primitives.

5. Two writers of physical_skills/__init__.py with different filters → rewrite ping-pong + reload storm

ros2_ws/src/brain/brain_client/brain_client/agents/initializer.py:22 vs skills/catalog.py:719

  • catalog: for snapshot in (physical_skills_snapshot, in_training_skills_snapshot)every physical entry, whatever its type.
  • brain_client: meta.get("type") in _PHYSICAL_TYPES where _PHYSICAL_TYPES = {"learned","replay","eval","physical"}.

"poses" is a documented SkillInfo.type (SkillInfo.msg:6) and a KNOWN_BEHAVIOR_TYPES member in manipulation/config_validation.py:64, and validate_physical_skill admits unknown types outright. So a poses skill is in one writer's set and not the other's: each process's content-compare sees a difference and rewrites. Each write is a .py change under workspace/_is_workspace_change_pending_reload_all → full reload_all(). The content-compare that is supposed to break the loop can't, because the two sides never agree.

6. say(wait=True) blocks up to 45 s and ignores cancellation

ros2_ws/src/brain/brain_client/brain_client/skills/types.py:968

_wait_for_speech_end uses bare time.sleep(0.05) in two loops with a 15 s start budget and a max(30.0, 0.1*len(text)) finish budget, with no self.cancelled check — unlike say()'s own publisher-match loop, which does check. run_routine_demo uses self.say(..., wait=True).

Failure: Stop during a spoken line → the run keeps the single execution slot for up to 45 s and the next goal is rejected with "Another skill is already running" (TEARDOWN_GRACE_SEC is only 2 s).

7. Backwards-compat coverage deleted while the compat surface grows

ci/run_integration_tests.sh:85, test/test_backwards_compat.py (deleted, 187 lines)

The PR adds a substantial legacy surface — LegacyMapping dict access on 6 state types, SkillOutput.__iter__ tuple unpacking, legacy (message, SkillResult) returns via normalize_skill_result, legacy RobotState/Interface/Camera explicit descriptors, = None class defaults still meaning "declared" — and simultaneously removes the only automated test for backwards compatibility from CI. Nothing now guards any of it. (The PR states this; flagging so it is an explicit merge decision.)

8. Composed children never brake their interfaces at step end

ros2_ws/src/brain/brain_client/brain_client/skills/invoker.py:205

_execute_code_skill's finally calls skill._halt_interfaces() before disposal — the documented framework brake, "so commanded motion never outlives a run". SkillInvoker._run_code's finally only restores _active_code_skill and disposes; no halt.

Failure: a child ending after send_cmd_vel(0.2, 0, duration=5.0) (or any latched command) keeps the base driving into the parent's next step, until the deadman expires or the whole run ends.

9. Per-skill storage collides across namespaces

ros2_ws/src/brain/brain_client/brain_client/skills/types.py:941

SkillStorage(_storage_dir() / f"{self.name}.json") keys on the bare snake_case class name, but this PR makes same-name skills in different packages a first-class supported case (bare_id_candidates prefers local/ over innate-os/, _dedupe_display_names publishes both). A user's custom_skills/wave.py::Wave and the shipped innate_skills/wave::Wave both write workspace/skill_storage/wave.json and clobber each other's state. The skill id is the identity everywhere else; storage should use it too.

10. Run deadlines use time.time() instead of time.monotonic()

workspace/innate_skills/pick_any_object.py:287, :413, :464; turn_in_place.py:38; move_straight.py:37

New code mixes both — Skill.wait_for and SkillsActionServer._wait_for_future use time.monotonic(), these use wall clock.

Failure: the Jetson's NTP step (routine shortly after boot / network up) jumps the clock forward mid-run → time.time() > deadline fires instantly → TurnInPlace reports Stuck: turned only 0 of 90 degrees, _follow_into_box returns "timeout", the wrist stage exits "timeout" mid-descent. A backward step hangs the loop for the size of the step.

11. Wrist-camera pixels are scaled with head-camera constants

workspace/innate_skills/pick_any_object.py:30, :406

_wrist_seed feeds the wrist frame's Gemini reply to vision.parse_det_box, which scales the normalized box by innate.geometry.IMG_W/IMG_H — constants that exist for the head camera's pinhole model (HFOV_DEG, FX, CX/CY). wrist_box_u/v = 320/380 hard-code the same assumption. It happens to work only because arm_camera_driver.cpp:21 defaults to 640×480; that driver has width/height parameters and explicitly warns "Resolution mismatch!" at runtime, so the coupling is silent and load-bearing.

Failure: arm camera reconfigured to any other resolution → the seed box, the CamShift window handed to vision.seg_model (out-of-range numpy slices, silently clipped), and the servo target are all wrong; the descent aims at the wrong place with no error.

12. The gain decay silently never fires when the load read is short

ros2_ws/src/mars_bot/mars_arm/mars_arm/arm_control.cpp:127

bool unloaded = loads.size() > 2 && ... — an empty or short loads vector (a dropped/partial servo read) evaluates to false, so the stiff SCHEDULED hold is never released. The whole point of kScheduledHoldTimeoutS per the new comment is that holding scheduled gains cooked joint 2 to 70 °C; that protection now depends on a sensor read that the same loop tolerates being empty elsewhere. Same for a genuinely-loaded idle pose: kDecayMaxLoad gating means an arm parked holding something never decays and the comment defers to "the temperature warning covers the rare carry-for-hours case" — which is a log line, not a mitigation.

13. Every recorded episode costs two full skill reloads

ros2_ws/src/brain/brain_client/brain_client/skills/catalog.py:719, :960

_write_physical_refs embeds episode_count, and _build_physical_skill_info deliberately re-reads it at publish time. So: episode saved → skills-dir watcher → reload_all()publish_skills_list() → refs content changes → workspace .py watcher → reload_all() again → refs now identical → converges. Two full imports of every workspace package per recorded episode, on a Jetson, during teleop recording. episode_count is display metadata; it doesn't need to be in the generated source.

14. _sweet_box() recomputed every ~30 ms in the follow loop

workspace/innate_skills/pick_any_object.py:310

_sweet_box() calls floor_to_pixel_cam_pose → two _rot matrix products plus sin/cos, and every input (sweet_x, box_y, tilt_deg, box_half_px, accept_frac) is constant for the run. Hoist it above the while in _follow_into_box.


Not flagged (checked and fine)

  • _rest_arm's CARRY_ARM + [...] if keep_grip else ... — conditional binds looser than +, parses as intended.
  • Skill.declared_interface_types() not recursing into sub-skills — _instantiate_for_run.wire() injects per child, so it's covered.
  • Mobility.odom_xyt's isinstance(odom, dict) branch — LegacyMapping is not a dict subclass, so typed Odometry correctly takes the radians path.
  • CameraProvider / Manipulation start/stop refcounting across nested runs — balanced; invoker children reuse the parent's subscriptions.
  • Cancel-latch coverage across the slot-claim → wiring window (_pending_cancel_goal + _begin_run) — the gaps are closed.
  • arm_circle_motion's hard-coded roll/pitch/yaw=0.0 on circle points while the start pose uses live orientation — pre-existing, carried over unchanged.
  • arm_command_mutex_ / joint_state_mutex_ nesting in arm_trajectory.cpp — consistent order everywhere, no inversion.

Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Heads-up on the integration-test failures today — evidence says they are not caused by this PR's code:

  • Morning episode (head 598f853): failed twice (07:41, 07:51), then the same merge ref passed unchanged on attempt 3 (08:13).
  • Tonight (head e6cc7be): failed at 18:49, rerun failed at ~18:55 — while a workflow_dispatch run of byte-identical source on claude/pr542-review-comments-w34h9y (this branch + two CI-only commits) ran concurrently on the same worker pool and passed (18:54–18:59). I verified main (4942f37) is an ancestor of the branch, so the PR merge ref adds no content — the failing and passing runs tested the same tree. Attempt 3 is queued now.

Both failing stretches died in Cloud Build "step 2" (run_integration_tests.sh); the no-ROS stages (exec-bit guard, brain_client/manipulation unit tests, webapp proxy tests) pass locally on both heads, so the flake is in the ROS launch tests — which are timing-sensitive (zenoh router startup, per-feed grace windows) and plausibly load-dependent on the pool VMs.

The blocker for diagnosing further: with logging: CLOUD_LOGGING_ONLY, a failure surfaces in Actions as just build step 2 failed. The two commits on claude/pr542-review-comments-w34h9y fix that — gcloud beta builds submit (+ install_components: beta) streams the Cloud Build logs, test output included, straight into the Actions console. Worth cherry-picking onto this branch (or landing on main) so a red check on merge day is a two-minute log read instead of a guessing game:

git cherry-pick e2153f23 3b716a7b   # from claude/pr542-review-comments-w34h9y (rebased: last two commits)

I'll keep watching; if attempt 3 passes again with no code change, that's the third same-code green today.


Generated by Claude Code

@theo-michel
theo-michel force-pushed the theo/pick-wrist-servo branch from 4f0ce89 to ce0406d Compare July 30, 2026 23:04

@DavidDobas DavidDobas left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Great job

Comment thread workspace/innate_skills/navigate_with_vision.py Outdated
Comment thread ros2_ws/src/brain/brain_client/brain_client/agents/types.py Outdated
Comment thread ros2_ws/src/brain/brain_client/brain_client/robot/mobility.py Outdated
…es by annotation

One rule for all robot state, hardware, and composition: annotate it.
`mobility: Mobility`, `odom: Odometry`, `image: MainImage`, a sub-skill
(`gripper_open: GripperOpen`), or a trained policy
(`pick_socks = PhysicalSkill("pick_socks")`). Plain annotation = required —
the server waits (bounded per feed: cameras 3 s, battery 6 s, else 2 s) and
fails the run before execute() if nothing arrives, so skills need no
`if self.x is None` guards. `| None` (or a `= None` default) = best effort.
Reading an undeclared feed raises with the annotation to add.

The base Skill supplies what every file used to repeat: the class name is the
skill name (snake_cased), the docstring is guidelines(), plus self.fail(),
self.cancelled / check_cancelled(), per-run on_cancel() hooks, self.feedback(),
self.wait_for(), self.storage, and None/str returns treated as success.
Instances are per-run: constructed for the run, disposed at its end —
@resource declares expensive per-run objects with generator-style teardown
that runs while interfaces are still live. Skills no longer override
__init__; the interfaces are plain typed attributes the framework fills in.

Robot state arrives as typed, ROS-free values under brain_client/state/
instead of raw dicts: Odometry, Pose (map frame), Battery, Lidar (+ min_range
sector helper), Arm, Map (lazy .grid), JointStates, HeadState,
Image/MainImage/WristImage (the value is the b64 string, .jpeg gives bytes),
DepthMap (numpy). Legacy dict access keeps working via dictcompat.LegacyMapping,
soft-deprecated with a warning. Main/wrist frames are memoized per frame in the
50 Hz injection path, and CameraProvider.start(feeds) subscribes only the
declared feeds, so raw depth no longer streams for skills that never asked
for it.

Returns collapse onto one object: SkillReturn carries success, message, and
data, replacing the per-skill dict conventions. Hardware interfaces lose the
`Interface` suffix (Mobility, Head, Manipulation) and the public authoring
namespace is `innate` — `from innate import Skill, Mobility, SkillReturn`,
with camera math, vision, and Gemini helpers under `innate.geometry`,
`innate.vision`, `innate.gemini`.
…n drop-in packages

Skills directories under workspace/ are ordinary Python packages, imported
rather than scanned: workspace/ goes on sys.path, every module is imported,
and defining a Skill subclass *is* the registration. The class is the
identity, so files organize freely — several skills per file, a skill split
across a subpackage, helpers next to the skills — and packages import each
other by bare name (from innate_skills import arm_utils).

innate_skills/ (shipped) and custom_skills/ (yours, gitignored) are just the
two built-in packs; any other folder dropped into workspace/ — or symlinked
in (ln -s /opt/team/skills workspace/team_skills) — is a pack, ids namespaced
by folder name (<pack>/<name>). This replaces the 0.6.x extra_skill_dirs /
extra_agent_dirs setting (settings knob, webapp catalog entry, and the
media-route serving lanes), and 0.7 no longer scans ~/skills / ~/agents in
place — migrate_user_data.sh moves both home lanes into workspace/custom_*
so nothing silently stops loading (sudo-safe via ACTUAL_HOME).

Physical (trained) skills join the same surface: the catalog generates a
typed reference class per recording, written as an __init__.py inside the
recording folder, so `from innate_skills.pick_socks import PickSocks` type-checks
against what the robot actually has. Orphaned shims are pruned when the roster
is republished, and the emitted files are already ruff-format clean.

A module that fails to import no longer vanishes: it shows up in the web-app
skills menu as a disabled row carrying its load error (SkillInfo.load_error),
is never registered with the cloud agent, and clears when fixed. An empty
metadata.json — the training node touches one into every recording dir — no
longer rosters a phantom broken skill. Hot reload (/brain/reload_primitives or
the save-watcher) covers helper modules too, survives root-level filesystem
events, and follows symlinked packs. The menu groups skills by folder
(SkillInfo.group).

The old scan-based skill loader, the string-based composition path, and the
legacy import shims are deleted along with test_backwards_compat.py, which
guarded them. dynamic_loader stays for agents and inputs only.
get_skills() and get_inputs() take classes, not id strings: an agent lists
`[MoveStraight, PickSocks]` and `[Joystick]` and the loader resolves them to
ids, so a renamed or deleted skill is a type error in the editor instead of a
silent no-op at runtime. Strings still work — the two forms mix freely — and
physical skills participate through the generated reference classes imported
from their recording folders.

Every shipped agent moves onto the typed surface, and the input-device module
layout settles: brain_client/inputs/{types,loader}.py with the ROS bridge in
nodes/input_manager.py, replacing the flat input_types/agent_types shims.
Each skill drops its __init__, name property, guidelines(), Interface
declarations, and None-guards in favour of annotations, the class docstring,
and SkillReturn. The suites that belong together become packages —
innate_skills/chess/, innate_skills/email/, innate_skills/arm/ — now that a
folder is a legal place for a skill to live and helpers import by bare name.

Behaviour changes that fell out of the migration: navigate_to_position
resolves local goals from the injected odometry instead of re-reading the
topic itself, run_routine_demo calls pick_socks through its typed reference,
and arm_zero_position waits for the arm to actually settle.
Grasp an object named in natural language: Gemini locates it in the wrist
camera, the skill servos the arm onto the target over successive frames, and
closes the gripper under current control once it is within reach. The aim
point sits deliberately short of the detection centroid so the approach stops
on the object rather than pushing past it.

New wrist frames are detected by identity, not by content: the state provider
builds exactly one Image object per ROS message, so `img is raw` means "same
message". Comparing JPEG bytes deadlocked in sim, where MuJoCo renders a
static scene byte-identically and the skill never saw a frame it considered
new.

Teardown is intentionally non-cancellable: once the gripper closes, a Stop
must not unwind mid-grip and drop what the robot is holding.
The gripper (servo 6) moves to Dynamixel control mode 5: it drives toward its
goal position with output capped at goal_current, so a deep close squeezes
whatever it holds at a constant safe force instead of tripping overload — no
software force loop. current_limit is raised above the grip current (it is
the EEPROM overload threshold, not the torque cap) and the close profile is
slowed so the jaws press in rather than impact.

Two things the arm had to stop doing for a grip to survive a move:

- Trajectories now spline the gripper from its last *commanded* goal, not its
  measured position. While gripping, the servo stalls short of the goal and
  that standing position error IS the grip force; re-seeding from the measured
  position zeroed it and dropped the object at the start of every arm move.
- A finished trajectory keeps its gain mode instead of falling back to teleop
  gains, which let the arm sag between the stepped moves a skill sends. The
  control loop decays the hold back to teleop after a quiet timeout so an idle
  arm is never held stiff, and the idle decay is blocked (and re-asserted per
  waypoint) while a trajectory runs.
The stereo depth estimator and the simple filters logged on every frame,
which at stream rate costs measurable CPU and buries anything worth reading.
Throttle them to state changes and errors.
…party isort

Small unrelated fixes that rode along with the branch:

- post_update.sh repairs /etc/innate.env to 640 root:$ACTUAL_USER only when it
  is not already there, and survives a failed chown instead of aborting the
  update (a root-unreadable file made the service key silently vanish).
- The web-app joystick hides/shows on `j`, releasing any latched command on
  hide so the robot never drives from a joystick the operator cannot see.
- ruff's isort knows workspace/, innate, and innate_proxy as first-party, and
  the two files that were sorted the old way follow.
- ci/run_integration_tests.sh drops test_backwards_compat.py from the fast
  unit-test lane, since the shims it guarded are gone.
self.sleep wakes and raises SkillCancelled the moment a Stop lands;
time.sleep blocks to completion, so a skill that uses it keeps the robot
moving after the user pressed Stop. Document the cancellation contract in
AGENTS.md and the rule in CLAUDE.md, including the one deliberate exception:
teardown and already-committed physical actions stay non-cancellable.
@theo-michel
theo-michel force-pushed the theo/pick-wrist-servo branch from 0d38ac8 to 6f62f90 Compare July 31, 2026 18:53
Split the lowercase decorator class into _Resource (descriptor) + a
resource() factory function with overloads; typed __get__ for class vs
instance access. Riding along: list[float] in arm_zero_position, and a
comment guarding arm_utils' invalid-command fail from unreachable-code
cleanup.
The agent existed only to expose the demo skill; removing the skill
alone left it importing a deleted module.
micro_input imported sounddevice at module scope, and sounddevice
dlopen()s PortAudio on import. On any machine without an audio stack
(CI, sim, a laptop) that raised OSError while the loader was merely
*importing* the module, so every agent naming MicroInput in
get_inputs() was discarded at discovery — six of the seven shipped
agents, which is why the brain launch tests failed with
security_guard_agent never registering.

The library turned out to have no consumer at all. MicStreamer, its
only caller, had no call sites here, on main, or on feat/add-mic, whose
LocalMicStreamer wraps ArecordStreamer rather than reviving it. Every
real capture path goes through the arecord subprocess.

So the class goes, and sounddevice with it: no consumer, no dependency,
and no library left for a future module-level import to fail on. DTYPE
was MicStreamer's only user. portaudio19-dev stays in
apt-dependencies.hardware.txt — dropping it is a separate call about
the robot image, and it is harmless there.
Recording folders ship in git as metadata.json plus the generated ref
shim, with the trajectory itself fetched from metadata["downloads"] —
so in a fresh checkout (and in every CI image) wave/ has no
episode_0.h5. Validation read that as damage and dropped the skill from
the roster, which then made the shim look orphaned: the prune pass
deleted a committed __init__.py mid-run, and demo_agent stopped loading
on `from innate_skills.wave import Wave`.

A named replay_file that is simply absent is now (valid, in_training),
the same treatment a learned skill's missing checkpoint already gets:
the skill stays rostered, its typed ref keeps existing, and running it
is refused with a reason instead of "unknown skill". A file that exists
but isn't a usable recording is still invalid.

prune_dir_shims now skips any folder that still holds real metadata,
via the catalog's own has_physical_metadata so both agree on empty
files. It only reclaims folders that stopped being recording folders,
so no transient fault can delete a tracked shim again. in_training's
user-facing reason no longer claims an un-fetched recording is
"training".
@theo-michel
theo-michel force-pushed the theo/pick-wrist-servo branch from 502d4f1 to b4e1c25 Compare July 31, 2026 22:38
@theo-michel
theo-michel merged commit db3e33f into main Jul 31, 2026
6 of 7 checks passed
theo-michel added a commit that referenced this pull request Aug 6, 2026
…it pull / innate update) (#591)

* fix(skills): stop tracking generated physical-skill shims

The dir shims written by physical_refs.py were also committed (PR #542),
giving each path two writers: the running robot and git. Any robot whose
brain code ran ahead of its checkout wrote the shims as untracked files,
and the next git pull / innate update then aborted with 'untracked working
tree files would be overwritten by merge'.

Make the runtime the only owner: untrack the two committed shims and
gitignore */__init__.py under workspace/innate_skills. Robots already
stuck un-stick on their next pull (the target tree no longer contains the
colliding paths); robots that pulled the tracked copies have them deleted
at checkout and regenerated on the next roster publish, with git staying
clean since the paths are now ignored.

* fix(skills): backstop missing dir shims at agent init; pin shim rendering

Review follow-ups for the untracking change:

- initialize_agents now recreates missing recording-folder shims from disk
  (metadata.json marks the folders — no roster needed), closing the window
  where a tracked agent's `from innate_skills.wave import Wave` fails after
  the update that deletes the once-tracked shims: brain_client's watcher only
  covers agent dirs, so an agent broken at boot stayed broken until a manual
  reload. Create-only — the skills server keeps sole ownership of updates
  and pruning, so the two writers can't fight over content.
- test_physical_refs.py pins the rendered shim (exact content, marker prefix,
  collision alias, removal marker) — the committed copies were the only thing
  pinning the template, and they're gone.
- gitignore also covers the atomic-write __init__.py.*.tmp leftover, and its
  comment now names the code-subpackage catch explicitly.
- physical.py's ship-in-git comment updated to the new ownership; the
  identifier-safety rationale for unquoted {name} restored in physical_refs.

* chore(skills): drop the shim render tests, tighten the added comments

Reverts the test_physical_refs.py added in the previous commit and cuts the
comments there back to the load-bearing facts.
theo-michel added a commit that referenced this pull request Aug 7, 2026
 (#621)

* fix(brain): restore agent_types/skill_types compat shims dropped in #542

PR #542 deleted the brain_client.agent_types and brain_client.skill_types
star-import shims, which every pre-#542 custom agent and skill in the field
imports — after an OS update, all of them roster as broken with
ModuleNotFoundError (all 21 custom agents on R7-27 were down).

Restore both shims verbatim, export Agent/SkillRef/InputRef from the innate
facade so new agents author against the stable namespace, and add regression
tests: the legacy and facade import paths must load an agent onto the roster,
and the shims must re-export the same class objects (registration and
isinstance depend on identity).

* test(brain): legacy skill loads end-to-end through workspace discovery

Greptile flagged that the skill shim was only covered by direct-import
identity checks while field skills load via discovery. Add a legacy-authored
skill (old import path, Interface/RobotState declarations, tuple return)
loaded through import_workspace_packages + registered_workspace_skills — the
same functions the catalog calls. The fixture clears Skill._registry (other
test modules register Skills, some deliberately broken, and discovery both
rosters and prunes whatever the live registry holds).
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.

3 participants