Skip to content
AuthorPascal DateJuly 29, 2026 Rev2.4 (grasp FSM in Layer 3; + `grasp()` tool-contract & reason-code tables; self-improvement = RecoveryAgent + ParamStore lessons)

grasp-service — the R1 grasp FSM as Layer-3 fine-grained planning (design spec)

The design we implement next. It supersedes the 1.x "strategy engine drives the robot's coarse chains" design: the grasp state machine + all grasp state + recovery + learning move up into the agnostic Layer-3 service ("fine-grained planning"), and the robot is refactored to expose only stateless primitives below the abstraction layer. Live work against it is tracked in Linear.


The grasp() tool contract

Audience

Skill-service developers extending grasp, and brain/planner authors calling it. The layered rationale (why the FSM lives in L3) follows below; this section is the self-contained wire contract for the one tool the service exposes.

grasp-service (FastMCP("grasp"), SSE on :9210, robot pack via GRASP_STRATEGY_PACK=r1) exposes exactly one MCP tool. It is blocking — it returns only when the object is held or the attempt is exhausted, streaming progress events meanwhile.

Inputs

Arg Type Meaning
object str (required) What to grasp, e.g. "the coke can".
approach str = "" Force a grasp approach (side / top / two_hand on the R1); omit to let selection pick from the object's shape.
place str = "" Environment tag (the named place being worked at) — scopes self-improvement so a lesson at one place never leaks to another.
target_frame str = "" Frame for the close-range grasp; defaults to the robot's odom (GRASP_PICK_FRAME).

Output — a JSON object:

Field Meaning
ok Whether the grasp succeeded.
held Whether the grip verified (verify_held, authoritative); held == ok.
side Arm/gripper that holds it (left / right / both), or "".
approach The strategy that ran (side / top / two_hand).
attempts Static attempts made before returning.
reason "" on success; a failure code (table below) otherwise.
notes (failure, optional) The run's last 6 diagnostic lines (_MAX_NOTES, so the return stays small enough for the planner's turn) — a step_timeout naming the primitive that wedged and the budget that ended it, a slow_step naming one that ate a headline share of the run, a refused retry, a recalled lesson. Present only when the run recorded any.
recovery_reason (failure, optional) Why the recovery tier stopped, when that is its own exhaustion (budget / deadline / no_action / give_up). Reported beside reason, never instead of it.

A success additionally carries object (its pose) + label, and recovered: true when the recovery tier (§5) saved the run. On failure the planner must not place — it should replan.

The rescue attempt's stopwatch is not the grasp's cause (BIN-166)

A recovery reason only replaces reason when it diagnoses the world (e.g. object_gone) — that genuinely tells the planner more than the static code. When the tier simply ran out (budget, deadline, no_action, a bare give_up) it says nothing about the object or the robot, so it is reported as recovery_reason and the FSM's own cause stands. BIN-166 returned {"attempts": 2, "reason": "budget"} for a run whose real cause was grasp_box_failed — which reads as "it kept retrying until time ran out" and sent the diagnosis down the wrong path entirely.

reason codes (set only when ok:false):

code meaning
not_found Object never located, even after the survey sweep.
unreachable Located, but IK/reach rejected it before any grip.
no_strategy The pack has no strategy whose applies accepts this object — nothing was attempted. A pack/robot mismatch, not a grasp failure: the usual cause is GRASP_STRATEGY_PACK left at minimal on a robot whose adapter doesn't expose that pack's tools.
unsupported_approach The caller asked for an approach this pack does not implement (the r1 pack stubs side/top today, so only two_hand runs). notes carries the UnsupportedApproach text naming what is available.
still_gripped A second static attempt was refused because the previous one left the object gripped and L3 cannot release it (§5b). Only surfaces when the first attempt recorded no reason of its own — otherwise the prior reason is re-reported unchanged, deliberately, so a missed stays missed and the engine keeps the wrong-default signal it learns from. Either way notes explains the refusal.
pre_grasp_failed side approach — the open+position pre-grasp step failed.
close_in_failed side approach — the advance+close+attach step failed.
descend_failed top approach — the effort-sensed descent step failed.
grasp_box_failed two_hand/box approach — the dual-arm squeeze IK/move failed.
lift_failed A grip formed but the lift step failed.
missed Reached the grip and closed, but verify_held reports empty (the learnable miss).
timeout A primitive wedged past its budget, or the whole-run deadline tripped. notes names which.
tool_error A primitive returned a hard error.

(Per-step gate failures generalise as <step>_failed; the codes above are the R1 pack's named steps.)

Which budget actually bounds a primitive — three stack, and the smallest wins:

  1. the primitive's declared budget (a Primitives constant — compute_grasp_joints 240 s, plan_execute 180 s — or a Step.timeout_s, else GRASP_CALL_TIMEOUT_S);
  2. what is left of GRASP_DEADLINE_S, floored at GRASP_MIN_STEP_BUDGET_S (15 s): the engine clamps every declared budget to the run's remaining time. A step is never promised time the run doesn't have — before the clamp a long primitive overran the deadline and was then discarded at the next between-steps check, so the overrun bought nothing (BIN-166). The floor is the safety valve: the clamp is there to stop an overrun of minutes, and a deadline lapsing mid-step must not hand the next call — possibly the one that forms or verifies the grip — a zero budget, which would report a failure for a held object;
  3. the tool client's GRASP_CALL_TIMEOUT_S (120 s), raising ToolTimeout. Note this caps (2) and (1) from outside: a declared budget above it — compute_grasp_joints's 240 s — can never take effect.

Whichever fires, the engine records a step_timeout note (tool · stage · elapsed · the budget that cut it off · the budget it declared) and emits it as a progress event, so reason: timeout is never anonymous.

A slow return is not a timeout — bound it separately (BIN-166)

A primitive that returns after eating the budget passes every bound above. BIN-166's compute_grasp_joints(phase="pre") spent 99.3 s of a 480 s run and returned reachable: false: galaxea_agent was exhausting 4 torso candidates × 12 IK seeds × a 2 s MoveIt IK timeout (≈96 s — bounded, but nobody had multiplied it out), and every seed was rejected because collision-aware IK ran against a planning scene that still held a box collision object wrapped around both grippers. No timeout could have caught that. GRASP_SLOW_STEP_FRAC (default 0.15 of GRASP_DEADLINE_S) makes the engine emit a slow_step event + note as it happens, so the expensive call is named while there is still context rather than reconstructed from the robot's logs afterwards.

Progress events — while the call blocks, every engine phase is streamed as an MCP progress notification (ctx.report_progress). progress is elapsed wall seconds (there is no meaningful total) and the message is the event as a JSON object, because an MCP progress notification carries no structured field:

{"object": "the coke can", "approach": "two_hand", "phase": "plan_execute",
 "tool": "plan_execute", "attempt": 1, "group": "upper_body",
 "octomap_guard": true, "stage": "pre"}

phase labels the event — locate · survey · re-perceive · strategy · one per primitive call (approach, compute_grasp_joints, plan_execute, set_gripper, verify_held, …) · escalate · recover · learn · give_up. The service carries the run's context onto every event — object always, and approach from the moment it is chosen (the engine names it once, in the single strategy event) — so a consumer can label the stream without having to remember an earlier notification. The remaining keys vary by phase: attempt, after_attempts, reason, turn, step, recovered, promoted.

Which call is this? (BIN-128) A per-primitive event also carries a small whitelist of the arguments that distinguish this call from the next one with the same tool — otherwise two FSM states are the identical line and a reader cannot tell them apart:

Primitive Extra keys
perceive stage (coarse | fine)
compute_grasp_joints stage (pre | grasp)
plan_execute group, named, octomap_guard, stage
set_gripper side, state
attach_object / detach_object side
turn_by degrees
add_object_to_planning_scene label
allow_arm_vs_object / allow_arm_vs_octomap allow

A primitive's own phase argument is renamed stage on the event so it can't collide with the event's phase. Where the arguments are genuinely identical — the two_hand FSM runs plan_execute("upper_body", joints=…) for both the pre-grasp pass and the squeeze, and the pre-grasp move is exactly where BIN-75's START_STATE_IN_COLLISION bites — the FSM passes an explicit telemetry-only stage=, which is never sent to the robot's tool arguments. Values are short scalars truncated at 24 chars; joint arrays, poses, face normals and prompts are deliberately not on the channel (they would swamp the stream and blow past the brain's 160-char per-event budget), and an unlisted tool simply gets no extra keys. learn / escalate / give_up already carry their own distinguishing fields and are unchanged.

The brain renders this live as a nested sub-module box under the pipeline step that called grasp (see using-the-brain.md), on the one telemetry channel it also renders the surveillance watchdog on (BIN-127). Two consequences worth knowing:

  • A client must pass a progress_callback on call_tool — that is what puts a progressToken on the wire. Without one, ctx.report_progress returns early and the stream is silently empty. It is best-effort on this side either way: a failed notification never fails the grasp.
  • The progress stream is what keeps the call alive. For a progress-reporting tool the brain treats its call_timeout_s as an idle timeout, not a total budget: every notification restarts the clock, and only silence for the full window aborts the call. That is what lets a grasp run to this service's own safety-net GRASP_DEADLINE_S (480 s) regardless of how call_timeout_s is configured — so don't reason about it as a fixed per-call ceiling. The corollary is the real constraint: a phase that goes quiet for longer than call_timeout_s is killed, so every long primitive must keep reporting.

Recovery policy + fleet learning

GRASP_POLICY selects the ladder: tiered (default) runs the static FSM, then escalates a miss to the LLM recovery sub-agent (§5) before giving up; deterministic runs the static FSM only. tiered needs an Anthropic key; without a usable one it degrades to the static path at startup rather than failing grasps (§5). Self-improvement (§6) writes to a local JSON store (GRASP_SELF_IMPROVE) unless MEMORY_URL points at the central robot-mcp-memory, in which case lessons are group-scoped + fleet-shared (optionally bearer-authed with MEMORY_TOKEN).


1. The idea

The brain does coarse mission planning (navigate → grasp → navigate → place). Turning one grasp(object) into a reliable sequence of robot motions is fine-grained planning — and that belongs in Layer 3, above the Binabik Robot Abstraction Layer and below the brain. It must not live in the robot.

So the R1's grasp orchestration — today a 12-state FSM (r1pro_pickplace/orchestrator.py et al.) running on the robot — is ported up into grasp-service. After the port:

  • Layer 3 (grasp-service, agnostic) owns the intelligence + memory: the state machine, all per-object grasp state, the retry logic, escalation to the recovery LLM, approach selection, and self-improvement.
  • The robot (below the abstraction layer) is a stateless calculator: it exposes primitives — perceive → data, plan/IK, move, gripper, nav, scene — each taking explicit inputs and returning data, holding no per-object grasp state.

Where the cut lands (the load-bearing distinction)

Porting the FSM "into L3" does not mean porting TRAC-IK or MoveIt into pure Python. It means moving the orchestration up while the math stays a primitive:

Moves up to L3 (grasp-service) Stays a stateless primitive on the robot
the states + transitions (the state machine) perceive(prompt, phase) → poses/dims/face_normal
all grasp state (coarse pose, fine pose, chosen arm, grasp joints, contact-z, attached-id) compute_grasp_joints(pose, dims, face_normal, approach) → joints + IK diagnosticsthe roll-search + dual-arm IK live inside this
retry / fallback logic plan_execute(group, joints\|pose) · set_gripper · nav/approach · attach/detach/clear_scene
escalation to the recovery LLM (the robot never sequences — it only computes + acts on one explicit request)

The heavy loops (the ~240 s GO_PICK roll-search, the dual-arm squeeze IK) are planning primitives — L3 calls compute_grasp_joints and reacts to its result (reposition, retry, switch approach); it does not loop IK over MCP. This is exactly the decomposition galaxea_isaac_moveit/mcp_tool_design.md already sketched (its 5-new + 3-upgraded tools) — we implement that spec, we don't invent one.

Brain ── grasp(object) ─►  grasp-service (L3, agnostic — "fine-grained planning")
                             • the 3 R1 grasp FSMs (box / side / top) as state machines
                             • holds ALL grasp state in GraspContext
                             • select approach by dims → run FSM → on fail, recovery LLM → learn
                             │  stateless PRIMITIVE calls (canonical names)
   ═══════ Binabik Robot Abstraction Layer (L4 contract) ═══════
   galaxea_agent (robot):  perceive · compute_grasp_joints(+IK diag) · plan_execute ·
                           set_gripper · nav/approach · tilt_for_depth · attach/detach/clear_scene

2. What the robot exposes — the stateless primitive contract

The robot's whole job becomes: be a clean calculator. Each primitive is one MCP tool, stateless (explicit inputs → data out), no hidden per-object grasp state. (The MoveIt planning scene — collision objects, octomap — is legitimate world state the robot holds, manipulated only through explicit attach/detach/add/remove/clear calls.)

Primitive In → out Notes / galaxea source
perceive(prompt, phase) prompt, coarse\|fine{objects:[{pose_odom, dims, face_normal, cluster?, dist, is_top_of_stack}]} detect_objects / estimate_object_pose_fine + face_normal (mcp_tool_design upgrade) — returns data, stores nothing
compute_grasp_joints(pose, dims, face_normal, approach, phase) {joints, reachable, ik:{position_reachable, fix_hint}} the roll-search + dual-arm squeeze IK (upgrade: both phases + IK diagnostics); compute_ik/compute_grasp_joints today
plan_execute(group, target, octomap_guard?) joints or pose (+frame) → {ok} move_upper_body/move_arm/command_joints_direct. octomap_guard=True (two_hand pre-grasp/squeeze/lift, BIN-75): the robot embeds the <octomap> ACM allowance in the MoveGroup goal itself + clears the octomap atomically at dispatch, beating the ~5 Hz head-camera repopulation
set_gripper(side, state\|position) {ok} exists
approach(pose, face_normal) {ok, final_dist_m} compute standoff along the face normal + Nav2 + fine-dock (approach_object today; make it take the pose, not a stored id)
navigate_to(x,y,yaw) / turn_by / drive_by {ok} raw base motion (nav2 goal / cmd_vel) — new tools (see handoff)
tilt_for_depth(box_z) {ok} torso over-look for depth recovery (mcp_tool_design new)
attach_object(id, geom, link) / detach_object(id) / add_collision / remove_collision / clear_octomap {ok} scene management (some exist; attach/detach-as-grasp are mcp_tool_design new). remove_collision is still missing on the R1 — see the warning below
verify_held(prompt) / gripper_load(side) {held} / {load} VLM held-check (authoritative) + load heuristic

Statelessness is the contract change. Today detect_side stores the object in the server and later steps read it (the detect_* boundary). After the port, perceive returns that data to L3, which threads it into compute_grasp_joints, approach, etc. That removes the hidden-state coupling entirely (see §7).

detach_object does not delete the object — and there is no remove_collision yet (BIN-170)

MoveIt's detach re-adds the attached body to the collision world at its current pose instead of removing it. galaxea_agent's own release paths know this and always follow detach_collision_object with remove_collision_object_from_scene; the L4 tool detach_object does only the first half. Two consequences a robot integrator and L3 author both have to know:

  • The world copy inherits none of the attachment's touch_links. A body the arms were allowed to touch becomes an obstacle they are not. Measured on rap-1 (2026-07-28, move_group log, same geometry): 2 contacts while attached — the two wrist cameras, which is separately BIN-166's stale ACM link name — and 11 contacts 50 s after the detach, left_arm_link5/6/7, both gripper link sets, both wrist cameras and right_arm_link7. Every one of the 11 was a link the attachment had exempted. It rejected the next tilt_for_depth with START_STATE_IN_COLLISION and then exhausted a 99 s IK sweep.
  • So detaching while the arms are still around the object is worse than leaving it attached, and remove_object_from_planning_scene is the missing half. BIN-173 closed the L4 gap: r1-abstraction now exposes it (idempotent, so it is safe from a finally path) alongside release_box, the R1's physical release. Until that pair is validated on hardware, grasp-service still calls detach_object nowhere on its retry path — the L3 half of the fix is a follow-up, and a wrong release drops a box.

3. The Layer-3 abstractions

  • GraspContext — the run's complete state (this is where the FSM's memory now lives): object label + perceive results (coarse + fine poses, dims, face_normal, cluster), chosen approach + arm, computed grasp joints, contact-z, attached-object id, attempt count, notes, per-run deadline. Threaded through every primitive call. The robot holds none of this.
  • GraspStrategy — one approach as a state machine (not a linear chain): states + transitions + per-state timeout + the guard/retry rules. applies(dims) says when it's the default. Three for the R1: box/two_hand, side, top. (A robot with no FSM — e.g. the sim minimal pack — uses a degenerate single-line "chain" strategy; the engine supports both.)
  • GraspEngine — agnostic driver: prelude → perceive (classify) → select strategy → run its state machine over the primitives (holding state in GraspContext, per-state timeouts, whole-run safety deadline between states, abort-safe) → verify → on fail, recoverlearn.
  • GraspResult{ok, held, approach, side, attempts, reason, object?}; ok == held is authoritative. reason: not_found | unreachable | no_strategy | unsupported_approach | tool_error | <state>_failed | missed | timeout, or — when the recovery tier diagnoses the world — its own reason (object_gone, model-supplied text). Nothing emits a literal recovery_gave_up. A tier that merely ran out reports beside reason, as recovery_reason: either the kit's blanket budget | deadline | give_up | no_action, or one of the guard's recovery_-prefixed diagnoses (§5a). Optional notes carries the run's last few diagnostic lines (a step_timeout, a slow_step, a blocked recovery call, a skipped retry).

4. Execution flow (GraspEngine.run)

  1. Prelude + presence. Posture; perceive(coarse) to confirm the object is there (survey with turn_by if not) → classify.
  2. Select approach. By dims (applies argmax): >0.18 m wide → two_hand; dims.h ≤ 0.05 m flat → top; else side. A learned override (self-improvement, §6) or an explicit approach arg wins.
  3. Run the approach's state machine over the primitives — e.g. box: coarse-detect → approach → tilt-for-depth → fine-estimate (fuse coarse+fine, reject a fine pose >0.30 m from coarse) → compute-grasp-joints (roll-search inside the primitive) → plan-execute pre-grasp → plan-execute grasp → attach → lift. State lives in GraspContext; each state has its own timeout; the safety-net deadline is checked between states (never mid-motion).
  4. Verify the grip (verify_held, authoritative).
  5. On miss → recovery (§5); on success → learn (§6).

5. Recovery — and why the port makes it cleaner

On a miss the engine escalates to a scoped RecoveryAgent (from robot-mcp-kit) — a bounded LLM loop over the same primitives, with verify_held as the check.

Because the state now lives in L3, the recovery LLM has full visibility of it and can resume the FSM from any state, adjust a value (a different arm, a nudged standoff, a re-perceive), or recompose primitives — without the old "respect the robot's hidden detect_* boundary" constraint (guard #2 dissolves). This is the strongest signal about what works here, so the override the recovery LLM converges on is recorded as a lesson (§6). Give-up returns {ok:false, reason} and the mission brain + surveillance watchdog replan (that tier is unchanged).

5a. The recovery tier's contract (BIN-167)

The tier fires exactly when things are going wrong, so anything it spends per failure scales with the failure rate. That makes three properties part of its contract, not tuning. BIN-166's escalation had none of them: six steps, four perceive, two capture_scene, no corrective action, 55 s, and a reported reason: "budget".

  1. A tier may only reach the tools its strategy names. GraspStrategy.recovery_tools is an allow-list, and grasp-service enforces it by wrapping the tier's tool clients: tool discovery is filtered, and a call by name is refused as well (MCP will execute a tool the client never listed). This matters because the kit's RecoveryAgent is general — it discovers the union of its clients' tools — and on the R1 that union is the entire robot-abstraction surface at :9220: the operator-facing capture_scene / get_frame, the stateful pick / place, stop, the observer VLM. None of those belongs in a grasp rescue. Adding a name to a pack's recovery_tools grants the recovery LLM a capability.
  2. A tier may not spend money by default. capture_scene bills on every call because its grounder always runs, whichever segmenter is selected — the whole capture is the billable unit and gating on the segmenter is not enough (BIN-139's cost model). The tier's paid-call budget is therefore 0 by default (GRASP_RECOVER_MAX_BILLABLE), matching the standing rule that nothing repeatedly hits a paid API without a deliberate opt-in. A permitted paid call records why it was permitted on the run's notes and emits a recover_billable progress event, so it is as visible in the UI as every other billable call.
  3. A tier must act, or terminate with a diagnosis. Repeated identical observation ends the recovery at once — the second identical look is already evidence the loop is stuck — and a run of GRASP_RECOVER_MAX_LOOKS (2) distinct observations with no action in between does the same. "Observation" is a named set (perceive, verify_held, capture_scene, …); everything else in the allow-list counts as progress and reopens the window.

Mechanics: a blocked call is surfaced as a tool error, which the kit's loop already treats as feedback (it lands in the controller's history and it tries something else), so a guard can never crash the tier it is bounding. A stall raises out of the loop and is converted into an explicit reason. Both are recorded on notes and emitted as recover_blocked / recover_stalled events.

And the reported cause must be the real one. A tier's own exhaustion never replaces the grasp's cause — it rides out beside it as recovery_reason (§3's GraspResult) — but it should also say something. The guard's diagnoses are prefixed recovery_ (recovery_looped_on_perceive, recovery_watched_without_acting, recovery_took_no_action, recovery_has_no_action_tools) and replace only the kit's contentless budget / deadline / no_action / give_up; a reason that already names something real (a model's give-up text, an unusable recovery backend) is left alone, and a reason that diagnoses the world (object_gone) still becomes reason.

5c. The controller can see (BIN-292)

Everything above bounds what the tier may spend. This is about what it may know, and the two are not in tension.

The controller was asked to diagnose a physical failure from a text history: goal text, tool specs, and the tail of tool results. From there, three failures are indistinguishable and need opposite fixes:

What actually happened What the controller should do
the object moved re-perceive, then approach the new pose
the arm is in a silly pose reset the arm, then retry
the gripper never closed retry the close, or change approach

It now looks before every decide step — the body snapshot (joints with at_limit called out ahead of everything, EE poses, grippers, base) plus a frame. Seeing the result of the move it just made is the entire point of a recovery loop, and is impossible from text. The loop is capped at max_steps, so a look per step is bounded by construction.

This spends nothing, so §5a's caps are untouched

world_state(refresh=false) is a contract read and get_frame encodes the image the robot already holds — no grounder runs. capture_scene stays filtered out of the tool list and is never reached by the observer. That last part is a separate guard with its own test: a limit bypassed from the side is still bypassed.

Through a wrist camera, not the head one. The head camera cannot see the gripper at the moment it matters, which is exactly when a grasp recovery needs to look. Which cameras exist comes from list_cameras (BIN-302) rather than being assumed, resolved once per run.

It degrades in pieces. A dead world_state still yields the frame; a dead camera still yields the body; an older vision build with no list_cameras falls back to the configured name; and no world service at all means no observer, which leaves the controller exactly as text-only as it was. A rescue that fails because it could not look is worse than one that looks at less.

The mechanism is in the kit (robot_mcp_kit.RecoveryAgent's observe hook, 0.8.0), not in grasp-service, so skill-service-template and every future skill inherit it rather than each re-implementing a world-state client. Note this needed image content blocks in the kit's decide path — it called Anthropic with a plain string — so it was never a prompt tweak.

5b. Retrying is not free either — never retry through a grip (BIN-170)

The static retry has the same "it only runs when something already failed" property. Each FSM closes a gripper and can still fail afterwards (lift_failed, or a missed held-check), and the grip outlives the failed attempt. L3 cannot release it: set_gripper(side, "open") opens fingers, but the two_hand grasp is an arm squeeze with no finger actuation (the robot's own release_box widens the arms to drop a box), and the widening move needs a plan that starts inside the box's collision volume — which the stranded-object problem above makes unplannable.

So a retry through a grip is refused, not attempted: the FSM stops, re-reports the previous attempt's reason (a missed must stay missed or the engine loses the wrong-default signal it learns from) and notes why. BIN-170 measured the alternative on rap-1: approach 17.9 s → 49.1 s as nav2 rotated for a ~1 cm delta while holding the object it was driving to, a silently no-oped tilt_for_depth, and 99.3 s of compute_grasp_joints exhausting IK against a scene it could not plan in — ~180 s of a 480 s budget spent discovering the inevitable, and a base drive with the box in the arms, which is what the pre-abstraction orchestrator's release-before-retry interlock existed to prevent.

The physical release was a robot-side follow-up, and the robot side is now done. BIN-173 added both halves to the contract — remove_object_from_planning_scene (§2) and release_box, the arm-widening release — so a retry-through-a-grip is no longer unreachable, only unvalidated: neither has run on hardware, and a wrong release drops a box. Lifting the refusal is therefore a deliberate L3 change gated on that validation, not a consequence of the tools appearing.

The recovery tier may never fail the grasp it exists to rescue (BIN-156)

Recovery is reached only after something already went wrong, so a rung that can raise turns "the grasp missed" into "the tool is broken" — and takes the diagnosis with it. That is not hypothetical: on rap-1, GRASP_POLICY=tiered met a venv with no anthropic and every grasp() returned Error executing tool grasp: No module named 'anthropic'.

The contract now, at three levels:

  • RecoveryAgent (robot-mcp-kit ≥ 0.7) turns every failure of its default controller — SDK missing, key missing, auth / rate-limit / network error — into a give-up Decision, logged at ERROR. run() never raises for a backend problem. recovery_backend_status() is the same check as a startup probe.
  • grasp-service asks that probe at launch. A tiered launch that cannot recover logs RECOVERY TIER DISABLED once, doesn't wire the tier, and runs the static path exactly as deterministic would — every grasp reporting policy=tiered(no-recovery). Its build_recover also contains anything escaping run(), returning no reason so the engine keeps the static one.
  • Abort-safety is preserved: CancelledError is a BaseException, so a brain abort still unwinds the loop at once — the one thing the degradation may not swallow.

Generalise it to any L3 skill: a recovery rung degrades to "no recovery", loudly, and the skill still returns its own honest {ok:false, reason}.

A corollary for packaging: tiered is the default policy, so its dependency is a plain dependency (robot-mcp-kit[mcp,recovery]), not an extra. It was an llm extra, and r1ctl install's plain uv sync never pulled it — which is how a default path shipped without its dependency for weeks.


6. Self-improvement — RecoveryAgent + ParamStore lessons (WP-A7)

The self-improvement loop is robot_mcp_kit's RecoveryAgent (§5) feeding a ParamStore of lessons. Behind an off-by-default toggle, the engine recalls the best confident override (approach, side, offsets) to bias the run's default, and records the outcome after — including what recovery discovered (the approach it converged to). Lessons key on a hierarchical env-aware signature (object+size+placeobject+size, earned across ≥2 places, never leaking between environments). Store is local (JSON) or the central, group-scoped robot-mcp-memory (fleet-shared; toggled per group / globally from the brain's Fleet admin). Since the approach is now an explicit L3 parameter, learning + override sit naturally in L3.


7. The three R1 FSMs — the port map (nothing dropped)

Each galaxea_agent orchestrator/skill → one L3 GraspStrategy (a state machine over primitives). Every state/param below must land in L3 or a primitive — this table is the migration contract (it replaces the old guard-#5 "audit before delete": the audit is the port).

Approach L3 states (→ primitive calls) verify tuned values to carry (cited)
side (orchestrator_side) detect → [approach, skip if dist ≤ 0.5 m] → fine-estimate → pre-grasp (open+position) → close-in (advance+close+attach) → lift verify_held arm by object-Y sign; per-state timeouts 40/120/90/180/180/60 s; cylinder collision + gripper↔object ACM
top (node-local _td_plan) detect → descend (effort-sensed, no wrist F/T) → grasp (close+attach) → lift verify_held precondition "already in reach, no base drive"; timeouts 40/180/60/60 s; applies only dims.h ≤ 0.05 m
two_hand / box (orchestrator) detect → approach → tilt-for-depth → fine-estimate (fuse coarse+fine; reject fine >0.30 m from coarse) → compute-grasp-joints (roll-search, ≤240 s, inside the primitive) → plan pre-grasp → plan grasp → attach → lift verify_held grasp_box roll-search is a primitive with its own budget — L3 never outer-retries it; cluster-fused OBB + yaw-correct; dual-arm symmetric

State ownership moves to GraspContext: what detect_* used to store server-side (_coarse_object_base, _fine_object_*, _grasp_joints, _td_plan, contact-z, attached id) becomes GraspContext fields, set from primitive returns and passed into later primitive calls.


8. Guards — updated for the new cut

  1. The roll-search is a primitive, not orchestration. compute_grasp_joints owns its ~240 s search + returns joints/diagnostics; L3 reacts to the result, never loops IK over MCP.
  2. The detect_* state boundary — dissolved. State is in L3, so there is no hidden robot state to respect; perceive returns data, L3 owns it.
  3. The port is exhaustive. §7 is the checklist — every orchestrator state/branch/param lands in an L3 state, a GraspContext field, or a primitive. Nothing is dropped or silently re-tuned.
  4. Perception stays approach-appropriate — L3 calls perceive(coarse) for classification, then the approach's own perceive(fine) where the FSM did.
  5. Abort-safe + bounded — per-state timeouts (never one global grasp timeout); the safety deadline is checked between states; CancelGoal/stop aborts in-flight primitive motion (the preemptive stop — handoff WP; galaxea_agent PR #1).
  6. A per-state budget is a ceiling, and it is capped by the run — but never to zero (BIN-166). Declared budgets are clamped to the remaining deadline, so the sum of the declared per-state budgets is allowed to exceed GRASP_DEADLINE_S without any single state being able to overrun the run; the clamp is floored at GRASP_MIN_STEP_BUDGET_S so it can never starve the state that forms or verifies the grip. Guard #1's "~240 s search" is a statement about the primitive, not a promise the engine will wait that long — and under the 120 s tool-client timeout it in fact cannot.
  7. Every give-up names the grasp's own cause. A downstream tier's resource exhaustion is reported alongside (recovery_reason), never substituted for reason. Diagnosing a failure must never require reading the robot's logs to find out which call ended the run — that is what notes carries.
  8. A tier that runs only on failure is bounded in money and progress, not just in steps (BIN-167, §5a). The strategy's recovery_tools is an enforced allow-list; the paid capture budget is zero by default and a permitted call records its permit; a repeated or unproductive observation terminates the tier with a diagnosis instead of consuming the remaining budget.
  9. A retry may never re-run an attempt whose side effects it cannot undo (BIN-170, §5b). A grip outlives a failed attempt, and no L3 primitive releases it — so the retry is refused with a diagnosis rather than attempted. And a "reset" that only appears to clean up (see the detach_object warning in §2) is worse than no reset: verify what a scene call actually does before relying on it.

9. Testing (enterprise bar)

  • Per-FSM tests with a fake primitive layer scripting each perceive/compute_grasp_joints/ plan_execute return: clean run holds; each failure transition converges to {ok:false, reason=<state>_failed}; the drift-gate rejects a bad fine pose; the roll-search-failed → reposition transition fires.
  • State-ownership tests: GraspContext carries coarse→fine→joints; a primitive is never called without the state it needs; no hidden robot state assumed.
  • Selection tests: dims → approach for each of side/top/two_hand; override wins; unsupported override rejected.
  • Recovery tests: miss → escalate; recovery success returns recovered; the discovered override is recorded (learn-back); give-up is honest.
  • Recovery-budget tests (§5a): the allow-list hides and refuses a tool outside it; no paid capture by default, one at most when opted in, with the permit recorded; a repeated or unproductive observation terminates with a recovery_-prefixed diagnosis; that diagnosis reaches recovery_reason and does not displace reason. The regression test replays BIN-167's actual six-step trace and requires it to be cut short.
  • Retry-interlock tests (§5b): a post-grip failure does not start a second attempt and re-reports the prior reason; a pre-grip failure still retries in full; and the interlock issues no gripper or scene command — the assertion that keeps an unvalidated physical release out of a merged change.
  • Budget/abort: per-state timeout terminal + reported; deadline between states only; CancelledError propagates.
  • Mutation-check the guards. A cost or safety guard whose test still passes when the guard is removed is not a guard. Every assertion above was verified by breaking the behaviour it describes (35 mutations: unfiltered discovery, an off-by-one cap, a cleared window, a bypassed interlock, a re-added detach_object, …) and confirming a test fails.

10. Non-goals

  • Not porting TRAC-IK / MoveIt / the roll-search math into Python — those stay primitives.
  • Not re-tuning the R1's grasp mechanics — the port preserves the cited values (§7).
  • Not touching navigation-between-places or place — those stay generic orchestration / their own skill.
  • The minimal/sim pack (a robot with no FSM) is unaffected — it keeps the simple single-line strategy; only the R1 pack becomes the ported state machines.