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:
- the primitive's declared budget (a
Primitivesconstant —compute_grasp_joints240 s,plan_execute180 s — or aStep.timeout_s, elseGRASP_CALL_TIMEOUT_S); - what is left of
GRASP_DEADLINE_S, floored atGRASP_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; - the tool client's
GRASP_CALL_TIMEOUT_S(120 s), raisingToolTimeout. 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_callbackoncall_tool— that is what puts aprogressTokenon the wire. Without one,ctx.report_progressreturns 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_sas 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-netGRASP_DEADLINE_S(480 s) regardless of howcall_timeout_sis 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 thancall_timeout_sis 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 diagnostics — the 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_grouplog, 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 andright_arm_link7. Every one of the 11 was a link the attachment had exempted. It rejected the nexttilt_for_depthwithSTART_STATE_IN_COLLISIONand 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_sceneis the missing half. BIN-173 closed the L4 gap:r1-abstractionnow exposes it (idempotent, so it is safe from afinallypath) alongsiderelease_box, the R1's physical release. Until that pair is validated on hardware,grasp-servicestill callsdetach_objectnowhere 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 +perceiveresults (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 simminimalpack — 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 inGraspContext, per-state timeouts, whole-run safety deadline between states, abort-safe) → verify → on fail, recover → learn.GraspResult—{ok, held, approach, side, attempts, reason, object?};ok == heldis 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 literalrecovery_gave_up. A tier that merely ran out reports besidereason, asrecovery_reason: either the kit's blanketbudget | deadline | give_up | no_action, or one of the guard'srecovery_-prefixed diagnoses (§5a). Optionalnotescarries the run's last few diagnostic lines (astep_timeout, aslow_step, a blocked recovery call, a skipped retry).
4. Execution flow (GraspEngine.run)¶
- Prelude + presence. Posture;
perceive(coarse)to confirm the object is there (survey withturn_byif not) → classify. - Select approach. By dims (
appliesargmax):>0.18 mwide →two_hand;dims.h ≤ 0.05 mflat →top; elseside. A learned override (self-improvement, §6) or an explicitapproacharg wins. - 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 inGraspContext; each state has its own timeout; the safety-net deadline is checked between states (never mid-motion). - Verify the grip (
verify_held, authoritative). - 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".
- A tier may only reach the tools its strategy names.
GraspStrategy.recovery_toolsis an allow-list, andgrasp-serviceenforces 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'sRecoveryAgentis 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-facingcapture_scene/get_frame, the statefulpick/place,stop, the observer VLM. None of those belongs in a grasp rescue. Adding a name to a pack'srecovery_toolsgrants the recovery LLM a capability. - A tier may not spend money by default.
capture_scenebills 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 therefore0by 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'snotesand emits arecover_billableprogress event, so it is as visible in the UI as every other billable call. - 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-upDecision, logged at ERROR.run()never raises for a backend problem.recovery_backend_status()is the same check as a startup probe.grasp-serviceasks that probe at launch. Atieredlaunch that cannot recover logsRECOVERY TIER DISABLEDonce, doesn't wire the tier, and runs the static path exactly asdeterministicwould — every grasp reportingpolicy=tiered(no-recovery). Itsbuild_recoveralso contains anything escapingrun(), returning no reason so the engine keeps the static one.- Abort-safety is preserved:
CancelledErroris aBaseException, 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+place → object+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¶
- The roll-search is a primitive, not orchestration.
compute_grasp_jointsowns its ~240 s search + returns joints/diagnostics; L3 reacts to the result, never loops IK over MCP. The— dissolved. State is in L3, so there is no hidden robot state to respect;detect_*state boundaryperceivereturns data, L3 owns it.- The port is exhaustive. §7 is the checklist — every orchestrator state/branch/param
lands in an L3 state, a
GraspContextfield, or a primitive. Nothing is dropped or silently re-tuned. - Perception stays approach-appropriate — L3 calls
perceive(coarse)for classification, then the approach's ownperceive(fine)where the FSM did. - 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 preemptivestop— handoff WP; galaxea_agent PR #1). - 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_Swithout any single state being able to overrun the run; the clamp is floored atGRASP_MIN_STEP_BUDGET_Sso 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. - Every give-up names the grasp's own cause. A downstream tier's resource exhaustion is
reported alongside (
recovery_reason), never substituted forreason. Diagnosing a failure must never require reading the robot's logs to find out which call ended the run — that is whatnotescarries. - A tier that runs only on failure is bounded in money and progress, not just in steps
(BIN-167, §5a). The strategy's
recovery_toolsis 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. - 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_objectwarning 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_executereturn: 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:
GraspContextcarries 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 reachesrecovery_reasonand does not displacereason. 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;
CancelledErrorpropagates. - 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.