Skip to content

Layer 5–8 — Reasoning, WAM, Safety, Observability

Part of the OpenRAL public-symbol inventory. Hand-curated; (LNN) markers are refreshed by tools/refresh_methods_linenos.py.

Layer 8 (Observability) is fully shipped — traces + metrics + structlog→OTLP log bridge, with W3C TraceContext propagation helpers for cross-process correlation (Python ↔ ROS 2 ↔ C++ safety kernel). Layer 4 (Reasoner) ships the live ReasonerCore direct-dispatch loop below; Layers 5–6 (WAM, C++ safety kernel) are still planned.

python/reasoner/src/openral_reasoner/tool_use.py

Typed LLM tool-use clients (direct-dispatch surface). CLAUDE.md §6.2 / §7.6 amended in the same PR. The direct typed ReasonerToolCall surface is the sole planner output.

  • module constant DEFAULT_SYSTEM_PROMPT: str — Factual system prompt for the S2 reasoner: one-tool-per-tick semantics, goal fidelity, robot/scene-matched skill selection, the go-see-then-act ladder, progress evaluation, observe-but-never-bypass safety/e-stop handling, exact-field-name discipline, and the wait no-op guidance while a skill is in flight or a mission is complete. Concrete deployments may override. (L94)
  • module constants ANTHROPIC_BASE_URL / OPENROUTER_BASE_URL / OLLAMA_BASE_URL / VLLM_BASE_URL / GEMINI_BASE_URL / XAI_BASE_URL / DEEPSEEK_BASE_URL / HUGGINGFACE_BASE_URL: str — the named-endpoint base URLs. Moved to openral_core.schemas (single source shared with openral doctor); re-exported here unchanged because this module was their public home.
  • module constant _ENDPOINT_PRESETS: dict[str, ReasonerEndpointPreset] — back-compat alias of openral_core.REASONER_ENDPOINT_PRESETS (with _EndpointPreset aliasing openral_core.ReasonerEndpointPreset): named values accepted by OPENRAL_REASONER_ENDPOINT (anthropic / openrouter / gemini / xai / deepseek / huggingface / ollama / vllm). Each preset carries url, dialect, auth_required, timeout_s and tool_choice — the five properties the retired PROVIDER enum knew per vendor. Both builder paths consult it: the uncurated path takes all five (so DIALECT is needed only for a bare URL), and the curated path takes the four endpoint properties while the registry keeps the model properties (served id, dialect, token cap), raising when a preset's dialect conflicts with the model's. Moved to core after the doctor-side hand mirror drifted twice (2fe732a, 131a489).
  • module constant SYSTEM_PROMPT_ENV_VAR: str = "OPENRAL_REASONER_SYSTEM_PROMPT" (L433) — env var that overrides the base operating brief; honoured by resolve_reasoner_system_prompt.
  • render_robot_context_prompt(capabilities: RobotCapabilities | None, *, base_prompt=DEFAULT_SYSTEM_PROMPT) -> str (L322) — Option B: append a deterministic ## THIS ROBOT body-awareness block (embodiment tags, locomotion + navigate/no-navigate guidance, manipulation/sensing hardware, payload, control modes) to the system prompt. None returns base_prompt unchanged.
  • resolve_reasoner_system_prompt(capabilities: RobotCapabilities | None, *, env=None) -> str (L436) — Compose the reasoner system prompt: base brief (OPENRAL_REASONER_SYSTEM_PROMPT override if non-empty, else DEFAULT_SYSTEM_PROMPT) + the ## THIS ROBOT block. env is injectable for tests. Called by ReasonerNode.on_configure.
  • class ToolUseClient(Protocol) (L483) — Attribute model_id; optional (read via getattr, not a Protocol member) last_prompt_tokens: int | None — prompt tokens of the most recent select_tool call, which ReasonerCore.run_prepared_llm records on the tick span; set by both shipped clients from provider usage (_prompt_tokens sums prompt_tokens / input_tokens + the two Anthropic cache_*_input_tokens fields, so the number is comparable across providers); method select_tool(*, context_text, palette, system_prompt=DEFAULT_SYSTEM_PROMPT) -> ReasonerToolCall; method describe_image(*, image_jpeg: bytes, question: str) -> str. Raises ROSReasonerInvalidPlan on bad discriminator / palette mismatch, ROSPlanningError on transport failure.
  • class OpenAICompatibleToolUseClient__init__(*, model_id, api_key=None, base_url=None, timeout_s=10.0, tool_choice="required", max_tokens=None). tool_choice forces exactly one tool call per tick (the reasoner contract); "auto" is used for endpoints that reject "required" (the HF router), and select_tool then retries once with an explicit nudge if the model replies in prose. max_tokens (env OPENRAL_REASONER_MAX_TOKENS) optionally caps completion tokens; unset = endpoint default (reasoning models reserve their full window, which a metered gateway rejects on a low-balance key — HTTP 402).
  • module constants REASONER_MODEL_ENV / REASONER_ENDPOINT_ENV / REASONER_API_KEY_ENV / REASONER_DIALECT_ENV / REASONER_MAX_TOKENS_ENV / REASONER_TIMEOUT_ENV — ADR-0088 model-first env names.
  • build_tool_use_client_from_env() -> ToolUseClient — Model-first factory. OPENRAL_REASONER_MODEL resolves openral_core.REASONER_MODELS; the registry supplies dialect, served id, endpoint, auth, hosting, tool choice, token cap, and local fit. OPENRAL_REASONER_ENDPOINT overrides location; API_KEY, MAX_TOKENS, and TIMEOUT_S override the remaining runtime values. ENDPOINT accepts a named endpoint from the module-private _ENDPOINT_PRESETS (anthropic / openrouter / gemini / xai / deepseek / huggingface / ollama / vllm) as well as a URL or the managed sentinel; a name carries its own base URL, dialect, auth posture, cold-start timeout (60 s for the self-hosted daemons + the HF router, which materialise a model on the first call) and tool_choice quirk (auto for the HF router, which 400s on required), so DIALECT is needed only for a bare URL — nothing can classify one. An explicit DIALECT still wins, for a preset behind a translating proxy. A raw uncurated model logs reasoner.model.uncurated. The provider-first OPENRAL_REASONER_LLM_* contract was removed in 0.3.0 and is no longer read. No cloud lock-in: the library has no model default. (L635)
  • class AnthropicToolUseClient (L1416) — Anthropic SDK-backed client. __init__(*, model_id, api_key, base_url=None, max_tokens=1024, timeout_s=10.0). base_url carries OPENRAL_REASONER_ENDPOINT; the SDK client/HTTP pool is built once and reused. select_tool marks the static system/tools prefix cacheable. Methods: select_tool, describe_image.
  • _anthropic_response_text(response) -> str — Concatenate all text blocks so thinking-enabled responses do not lose an answer after a leading thinking block.
  • class OpenAICompatibleToolUseClient (L1577) — OpenAI-compatible client with cached SDK/HTTP pool, endpoint/tool-choice/token-cap configuration, and image-description support.
  • _tool_palette_to_anthropic_tools(palette) -> list[dict] — Render the closed palette, including WaitTool; per-skill names use collision-resistant execute_rskill__<slug>_<sha1-8>.
  • _tool_palette_to_openai_tools(palette) -> list[dict] — Convert the same surface to OpenAI function shape without leaking the Anthropic-only input_schema key.
  • _decode_tool_payload(*, tool_name, arguments, palette) -> ReasonerToolCall — Validate provider output against the union + palette; per-skill names resolve through the same hashed mapping used to render them.
  • module constant _PER_SKILL_TOOL_PREFIX: str = "execute_rskill__" — prefix the decoder matches on to identify per-skill tool calls. (L931)
  • module constant _LLM_TOOL_NAME_MAX_LEN: int = 64 — Anthropic + OpenAI tool-name regex limit; long HF Hub ids are sha1-suffix-truncated to fit. (L934)
  • _skill_id_to_tool_name(rskill_id: str) -> str — Map a <owner>/<repo> id into a collision-resistant 64-char-max execute_rskill__<slug>_<sha1-8> name. (L937)
  • _format_skill_tool_description(entry: RSkillToolEntry) -> str — Render the skill's id + description + actions + objects + scenes into the NL string the LLM scores. (L954)
  • _drop_property(schema: dict, name: str) -> dict — Return a copy of a JSON Schema dict with name stripped from both properties and required. Used to drop rskill_id from per-skill ExecuteRskillTool schemas. (L1300)

python/reasoner/src/openral_reasoner/cosmos3.py

NVIDIA Cosmos 3 reasoner backend (OPENRAL_REASONER_MODEL=cosmos3-edge) — the physical-AI-native S2 planner. Plans with the autoregressive reasoner tower of a Cosmos 3 omnimodal world model (the curated 4B on-device Edge tier, OpenMDW-1.1, commercial OK) behind an OpenAI-compatible endpoint, so the typed tool-use contract (CLAUDE.md §3, no free-form JSON) is unchanged. Companion boot helper: tools/cosmos3_reasoner_sidecar.py.

  • module constant COSMOS3_BASE_URL: strhttp://127.0.0.1:8901/v1; the managed local endpoint (dedicated port so the sidecar never collides with a user-run vllm serve on :8000). (L75)
  • module constant DEFAULT_COSMOS3_MODEL: strnvidia/Cosmos3-Edge, the served id of the curated cosmos3-edge registry entry. Other tiers use the explicit uncurated model + endpoint escape hatch until validated. (L81)
  • module constants AUTOSTART_ENV / BOOT_TIMEOUT_ENV / SIDECAR_SCRIPT_ENV / DEFAULT_BOOT_TIMEOUT_S — env knobs: OPENRAL_COSMOS3_AUTOSTART (0 disables the managed spawn), OPENRAL_COSMOS3_BOOT_TIMEOUT_S (first boot provisions a venv + downloads ~8 GB of weights; default 1800 s), OPENRAL_COSMOS3_SIDECAR (boot-helper path override). (L86)
  • find_cosmos3_sidecar_script() -> Path — Locate tools/cosmos3_reasoner_sidecar.py (env override or repo-parent walk); ROSConfigError when the OPENRAL_COSMOS3_SIDECAR override path does not exist (fail-fast with the real cause, not a later "sidecar exited early" misdiagnosis) or when the repo walk finds nothing. (L94)
  • _managed_port(base_url) -> int | None — the explicit port of a loopback base URL, or None (unmanaged): autostart requires an explicit loopback port so the spawned server and the readiness probe agree; a portless loopback URL (reverse proxy on :80) is treated as self-managed. (L132)
  • class Cosmos3ToolUseClient(OpenAICompatibleToolUseClient)__init__(*, model_id=DEFAULT_COSMOS3_MODEL, api_key=None, base_url=COSMOS3_BASE_URL, timeout_s=120.0, max_tokens=None, auto_start=True, boot_timeout_s=1800.0). Wire path is the inherited OpenAI-compatible client; adds managed probe/spawn/readiness/teardown. A boot timeout keeps the still-provisioning child so the next call waits instead of duplicate-spawning. warm() exposes _ensure_server so ReasonerNode.on_configure can start the sidecar during bringup instead of on the first tick — otherwise the boot (a vLLM model load, or a venv provision plus ~9 GB download on a cold host) lands after the whole graph is up and an operator is waiting on a decision, while HAL on_configure / MuJoCo / camera first-frame gating had minutes it could have overlapped. Idempotent: _ensure_server short-circuits on _server_ready and reuses a still-booting child. (L168)

python/reasoner/src/openral_reasoner/palette.py

Closed-set ToolPalette + builder. Three tool variants (reload_gst_pipeline / lifecycle_transition / emit_prompt) are always available; execute_skill is gated by the installed-rSkill registry filtered by RobotCapabilities + license posture. Palette carries per-skill metadata (RSkillToolEntry), not just opaque ids — the LLM gets one tool per skill with description + action verbs + object/scene tags.

  • class RSkillToolEntry(BaseModel) (L204) — Frozen per-skill record surfaced to the LLM as one tool. Fields: rskill_id: str, description: str, actions: tuple[RSkillAction, ...], objects: tuple[str, ...] = (), scenes: tuple[str, ...] = (). Mirrored from the matching RSkillManifest fields at palette-build time.
  • class ContinuousDetectorEntry(BaseModel) (L112) — Frozen coverage record for a mode: continuous detector — surfaced to the LLM as coverage (not a tool) so it can read world state for tracked objects and reserve locate_in_view for the long tail. Fields: rskill_id: str, description: str, objects: tuple[str, ...] = (), scenes: tuple[str, ...] = (), num_labels: int = 0 (compact characterisation, not the full label list).
  • class OnDemandDetectorEntry(BaseModel) — Frozen record for a mode: on_demand open-vocab locator surfaced as a selectable locate_in_view option (a prompt-able read-only tool, never an ExecuteRskill policy). Fields: rskill_id: str, alias: str (short selector the LLM passes as LocateInViewTool.detector), description: str (capability hint).
  • detector_alias(rskill_name) -> str — Short LLM-/operator-facing detector id: strips the OpenRAL/ org + rskill- kind prefixes ("OpenRAL/rskill-omdet_turbo-any-locator-fp16""omdet_turbo-any-locator-fp16"). Single source of truth for the alias the reasoner routes on.
  • detector_service_segment(alias) -> str — ROS-safe service-namespace segment for an alias (hyphens → underscores), so the locate service lives at /openral/perception/<segment>/locate_in_view.
  • locate_in_view_service(detector, *, default="") -> str — Resolves the locate_in_view service for a (possibly empty) selector: empty detectordefault; empty resolved alias → the legacy /openral/perception/locate_in_view (single-detector back-compat); else the namespaced service. Shared by the reasoner dispatch and the deploy launch (which names each locator node's service).
  • class ToolPalette(BaseModel) (L243) — Frozen palette presented to the LLM each tick. Fields: skills: tuple[RSkillToolEntry, ...] = () (primary surface), execute_rskill_ids: frozenset[str] = frozenset() (back-compat — auto-derived from skills via the _derive_execute_rskill_ids model-validator), sensor_ids: frozenset[str] = frozenset(), node_ids: frozenset[str] = frozenset(), continuous_detectors: tuple[ContinuousDetectorEntry, ...] = () (mode: continuous detectors for the active robot; coverage, not tools), spatial_memory_available: bool = False (gates the two read-only recall_object / resolve_place query tools; off unless the reasoner_node has a SpatialMemory backend wired), detector_available: bool = False (gates locate_in_view), on_demand_detectors: tuple[OnDemandDetectorEntry, ...] = () (selectable locator options for locate_in_view), scene_query_available: bool = False (gates query_scene; independent of detector_available), memory_available: bool = False (gates the self-maintained-memory tools memory_write (write) + memory_search (read-only archival recall); off unless the reasoner_node has a MEMORY.md wired via memory_md_path). Cross-validator _check_skills_match_ids rejects callers that pass both skills and execute_rskill_ids with disagreeing ids.
  • build_tool_palette(*, installed_skills, robot_capabilities, sensor_ids=(), node_ids=(), commercial_deployment=False, spatial_memory_available=False, detector_available=False, scene_query_available=False, task_progress_available=False, memory_available=False) -> ToolPalette — A skill is included iff it is not an unresolved scaffold (RSkillManifest.is_scaffold_placeholder — drops rskills/template/, which the rskills/*/rskill.yaml glob otherwise admits as a role: s1 skill a weak LLM can pick and the decode guard can't reject), role=s1, kind≠detector (detector rSkills are perception producers, not ExecuteRskill-dispatchable), capability flags satisfied, embodiment tags intersect, and (when commercial) license allows commercial use. A mode: continuous detector is instead collected into continuous_detectors (coverage for the LLM, never an ExecuteRskill tool); a mode: on_demand detector is collected into on_demand_detectors (selectable locate_in_view options via detector_alias, never an ExecuteRskill tool). Emits RSkillToolEntry records (manifest description/actions/objects/scenes mirrored in) in stable id-sorted order so the LLM tool schema is deterministic. spatial_memory_available forwards the read-only recall_object/resolve_place tools; detector_available forwards the read-only locate_in_view tool; scene_query_available forwards the read-only query_scene tool; memory_available forwards the memory_write + memory_search tools. All are ToolPalette fields gated in tool_use so the LLM only sees a tool when its dispatcher is wired; detector_available and scene_query_available are independent (localization vs scene-state reasoning). The reasoner_node dispatches query_scene via _dispatch_query_scene/openral/perception/query_scene and re-prompts with the answer (frame_id scene_vlm).
  • task_space_disagreement(manifest, description, hal_mode, legacy_ok) -> str | None — Phase 2 (warn-only). Pure (no ROS) shadow gate: builds TaskSpace.from_action_contract(manifest.action_contract, description) and runs task_space_compatible(..., hal_mode), returning a warning string ONLY when the canonical gate disagrees with the caller's legacy_ok verdict, else None (and None for non-actuating skills with no action_contract). Called by the reasoner deploy-palette filter (alongside _action_executable) and tools/rskill_publisher._validate_task_space to surface cross-layer mismatches (slot EE-name / joint-width) without changing the drop/publish decision. Phase 4 makes task_space_compatible authoritative.

python/reasoner/src/openral_reasoner/spatial_query.py

Phase 2 — read-only spatial-memory query bridge: maps a RecallObjectTool / ResolvePlaceTool to a query, runs it against an injected backend, and renders an LLM-readable result for the prompt cascade. Layer-4 module; does not import openral_world_state (backend is duck-typed).

  • class SpatialMemoryQuerier(Protocol) — Read-only query surface (recall_object(query, *, now_ns) -> RecallObjectResult; resolve_place(query, *, from_node_id=None) -> ResolvePlaceResult; to_scene_graph() -> SceneGraph — immutable snapshot for telemetry/dashboard); structurally satisfied by openral_world_state.SpatialMemory.
  • SpatialQueryTool: TypeAliasRecallObjectTool | ResolvePlaceTool (the read-only ReasonerToolCall variants this bridge dispatches).
  • recall_object_tool_to_query(call) -> RecallObjectQuery / resolve_place_tool_to_query(call) -> ResolvePlaceQuery — tool → query mappers.
  • format_recall_object_result(query_text, result, *, blocked_node_ids=frozenset()) -> str / format_resolve_place_result(reference, result) -> str — render results as LLM-readable text (misses reported as text, never a fabricated pose). blocked_node_ids (Phase 4) renders a match whose approach failed grid refinement as "approach BLOCKED on the occupancy grid" instead of a pose.
  • run_spatial_query(call, querier, *, now_ns, from_node_id=None, refine_approach=None) -> str — execute a read-only tool call and render the result; catches ROSObjectNotInMemory → "not in memory" message. refine_approach (Phase 4, ApproachRefiner — duck-typed like the querier so this L4 module never imports L2) is applied to every recall_object match's approach viewpoint before rendering; a None from the refiner marks the match BLOCKED. Thin wrapper over run_spatial_query_detailed returning only .text.
  • class SpatialQueryOutcome(NamedTuple)(text: str, found: bool). found is True when recall_object returned ≥1 match (in memory, even if every approach is grid-BLOCKED) or resolve_place resolved the reference; False on a miss. Drives the reasoner's recall→locate_in_view escalation.
  • run_spatial_query_detailed(call, querier, *, now_ns, from_node_id=None, refine_approach=None) -> SpatialQueryOutcome — same as run_spatial_query but also reports whether the query matched, so the node can escalate a miss to a live perception check without re-parsing the rendered text.
  • ApproachRefiner (TypeAlias = Callable[[ApproachViewpoint, tuple[float, float, float]], ApproachViewpoint | None]) — the occupancy-grid refinement callback contract; the reasoner node wires refine_approach_pose over its latched /map subscription.

python/reasoner/src/openral_reasoner/active_search.py

§3 Phase 4 — bounded active object search over the scene graph (pure-Python, openral_core only).

  • class SearchBudget(BaseModel) — frozen; max_candidates (1–50), max_attempts (1–50). The bound.
  • class SearchCandidate(BaseModel)place_node_id, goal: Pose6D, open_container_id: str | None, reason, rank ∈ [0,1].
  • plan_active_search(graph, *, target_text, budget) -> list[SearchCandidate] — ranked frontier of places to check (occluding containers first, then containers, then places), truncated to budget.max_candidates; [] when nowhere to search (→ human-handoff). Semantic prioritization among candidates is the LLM's (priors).
  • class SearchProgress — attempt counter against a SearchBudget: record_attempt() -> bool (True while budget remains), attempts, exhausted, reset(). The runaway bound.
  • format_search_frontier(candidates, target_text) -> str — LLM-readable frontier text (empty → "hand off to a human").

python/reasoner/src/openral_reasoner/completion.py

§5 — VLM adjudication helpers (pure, no rclpy). Importable standalone (tested without a ROS install). - COMPLETION_QUESTION: str — prompt template for the ambiguous reward band: "Has the robot finished this task: {task!r}? Look at the scene and answer only 'yes' or 'no'." (format with task=active.text). - parse_yes_no(answer: str) -> bool — parse a VLM free-text answer: True iff the text contains an affirmative token ("yes", "complete"/"completed", "done", "success"/"succeeded"/"successful"/"successfully", "finished") without a negation token ("no", "not", "cannot", "isn't", …). Token-based over punctuation-normalised text — substring matching false-completed on "No. It is done." (the "No." never matched) and "The task was abandoned" ("abandoned" contains "done"). Typographic apostrophes (U+2018/U+2019) fold to ASCII first so a typographic "isn't" still negates. Returns False on empty or ambiguous input — never a false positive. - image_msg_to_jpeg(*, data: bytes, height: int, width: int, encoding: str, flip_180: bool = False) -> bytes — convert a raw sensor_msgs/Image payload to JPEG bytes using numpy + PIL (no cv_bridge). Supports "rgb8" and "bgr8". flip_180 rotates the image 180° (both spatial axes) before encoding — the HAL publishes LIBERO/MuJoCo frames bottom-up (the topic is raw; OPENRAL_DASHBOARD_FLIP_180 flips only the dashboard thumbnail), so the VLM completion gate needs the same correction the dashboard/VLA apply. Raises ValueError on unsupported encoding; propagates numpy/PIL errors so the caller can leave the cache unchanged on failure. - is_frame_fresh(*, age_s: float, max_age_s: float) -> bool — whether a cached completion frame is recent enough to adjudicate (§5): True iff max_age_s <= 0 (guard disabled) or age_s <= max_age_s. A stale frame (cached from a prior attempt during a topic stall) would make the VLM judge the wrong scene → reasoner_node._adjudicate_completion treats over-age as "cannot adjudicate" (degrades to the ladder, never a false verdict). - is_reward_wake(*, source: str, severity: int, severity_fail: int) -> bool — classify a FailureTrigger as a reward-watcher wake (§2): True iff source == "critic" and severity >= severity_fail. reasoner_node._on_failure uses it to cancel an in-flight execute_rskill goal (stop the VLA now, verify on the reward signal, not at the deadline_s clock); non-critic sources are ordinary failures. - resolve_band_edges(*, contract_threshold: float | None, contract_floor: float | None, fallback_threshold: float, fallback_floor: float) -> tuple[float, float] — three-tier verdict band edges (§1/§5): the active reward model's (success_threshold, check_floor) when both present, else the system fallback (never mixes sources). reasoner_node._band_edges adapts it over _reward_contract. - resolve_patience_s(*, override: float | None, contract_default: float | None, legacy_deadline_s: float) -> float — patience ceiling for a dispatch (§2/§3) along the authority stack system-fallback < reward-model default < LLM override: LLM patience_s override wins, else the reward model's default_patience_s, else the legacy deadline_s (0.0 → runner resolves its own manifest ceiling). reasoner_node._effective_patience_s adapts it over _reward_contract + the ExecuteRskillTool call.

python/reasoner/src/openral_reasoner/mission.py

§1 — typed sequential task queue for multi-task deploy goals. Reasoner-internal bookkeeping (no rclpy/Pydantic); the node drives transitions, the ContextRenderer renders the ## MISSION ledger. - TaskStatus (TypeAlias = Literal["pending","active","verifying","done","abandoned"]) — subtask lifecycle; done/abandoned terminal (never re-queued). - VerdictAction (TypeAlias = Literal["complete","abandon","retry","vlm_check"]) — the reward-gate decision (§2 / Decision 5): complete (auto-pass, score ≥ threshold), vlm_check (ambiguous band — caller adjudicates via describe_image), abandon (ladder exhausted), retry. - DEFAULT_MAX_ATTEMPTS: int = 3 — default per-task attempt cap before the gate abandons + hands off. - DEFAULT_MAX_SUBDIVIDE_DEPTH: int = 2 — max re-decomposition depth (amendment / #123); a blocked task already at this depth is refused subdivision so the ladder terminates in human-handoff. - DEFAULT_MAX_TASK_LOCATE_ATTEMPTS: int = 3 — default per-task locate_in_view cycle budget (amendment); locate cycles a task may spend without an execute_rskill dispatch before it is abandoned. Distinct from the SearchProgress miss budget (which resets on a locate HIT, so a live locate-loop that keeps hitting never terminates). - evaluate_task_verdict(*, ok: bool, progress_now: float, success_threshold: float, check_floor: float, attempts: int, max_attempts=DEFAULT_MAX_ATTEMPTS, success_now: float | None = None) -> tuple[VerdictAction, str] — three-tier reward-gate (§2 / Decision 5 + amendment). Gates the band on the PROGRESS head, not the success head — robometer's progress (closeness) reaches ~0.80–0.86 on a genuine success and the 0.8/0.5 bars were calibrated against it, while its success (done-confidence) head is compressed (~0.56–0.79 even on a real success) so a 0.8 bar over it is effectively dead. When ok=True: (1) progress_now >= success_threshold"complete" (auto-pass, no VLM); (2) check_floor <= progress_now < success_threshold"vlm_check" (ambiguous — caller calls describe_image, may weigh success_now as corroboration); (3) progress_now < check_floor → attempts ladder ("abandon" if attempts >= max_attempts, else "retry"). ok=False always goes to the ladder (never fabricates success). success_now is an optional secondary corroborating signal surfaced in the verdict text (never overrides the progress band). Consumed by reasoner_node._on_mission_verify_response. - class TaskLocateBudget (dataclass, slots) — per-task locate_in_view cycle budget (amendment). charge(task_id) -> bool (count one locate cycle for the active task — auto-resets when task_id changes — returns True once the count exceeds max_attempts=DEFAULT_MAX_TASK_LOCATE_ATTEMPTS); reset() (on real progress / new goal); restore(task_id, count) (crash-resume from a persisted ladder snapshot); reason(query) -> str (specific abandonment reason for the ledger, e.g. could not confirm 'teapot' in view after 3 locate attempts without a skill dispatch); count + task_id properties. Held by reasoner_node as _task_locate_budget. - class TaskState (dataclass, slots) — one subtask: fields task_id, text, status=pending, attempts=0, last_rskill_id, last_trace_id, last_verdict, depth=0 (depth = re-decomposition level, #123). - class MissionState — ordered queue, ≤1 task active/verifying. from_prompt(text) classmethod (seeds the operator goal as a single active task; the LLM decomposes via decompose_mission); readers tasks, active() -> TaskState | None, is_empty(), is_complete(), has_started() -> bool (#123 — any task terminal or the active task attempted; gates a safe decompose_mission populate-replace), __len__; mutators record_attempt(*, rskill_id, trace_id=None), mark_verifying(), complete_active(verdict) -> TaskState | None, abandon_active(reason) -> TaskState | None, rearm_active() -> TaskState | None (#123 — move the active task verifying → active so the dispatch/subdivide cycle resumes after a subdivision offer), subdivide_active(subtasks, *, max_depth=DEFAULT_MAX_SUBDIVIDE_DEPTH) -> TaskState | None (amendment / #123 — flat-splice the blocked active task in place with finer children t<n>.1, t<n>.2, … at depth+1, activate the first; returns None when no active task, empty subtasks, or depth bound reached → caller falls back to abandon_active); each mutator returns the newly-active task or None when finished; to_summary() -> dict[str, object] (JSON-able {max_attempts, tasks:[{id,text,status,attempts,verdict,rskill_id}]} snapshot stamped on the reasoner.tick span as reasoner.mission_json for the dashboard Mission card; children carry dotted ids t<n>.1 so the card indents them under their parent); to_state_dict() -> dict / from_state_dict(state) -> MissionState classmethod (FULL round-trippable snapshot — every TaskState field incl. statuses/attempts/depth/verdicts — for crash-safe ladder persistence, unlike the lossy to_summary; from_state_dict raises ValueError on malformed input); render() -> str (the ## MISSION ledger: done ✓ / active ▶ / verifying ? / abandoned ✗ + pending count; children indented by depth).

python/reasoner/src/openral_reasoner/node_policy.py

Pure prompt-handling policy for the reasoner node (no rclpy) — the two consequential _on_prompt decisions, single-sourced and unit-testable without a ROS install.

  • module constant CASCADE_PROMPT_SOURCES: frozenset[str] — the six frame_ids of the reasoner's own cascade re-prompts (spatial_memory, detector, scene_vlm, reward_monitor, memory, mission). The node's search-bound/streak reset guard and the mission-rebuild guard both key off this ONE set (the pre-fix reset guard matched only "spatial_memory", so every detector re-prompt reset the locate-miss budget it had just charged — the budget could never exceed 1).
  • should_rebuild_mission(source, metadata_json, mission) -> bool — whether an inbound prompt replaces the mission queue: cascade → never; no mission / empty / finished / not yet started (pre-work resend) → yes; genuinely in progress → only with top-level "new_goal": true metadata (an operator reply to a reasoner question must not silently discard the in-flight queue — it reaches the LLM via the PROMPTS section).

python/reasoner/src/openral_reasoner/persistence.py

Crash-safe ladder persistence (pure, no rclpy): the mission ledger + every replanning-ladder bound snapshotted after each mutation and reloaded at configure, so a reasoner restart RESUMES the ladder instead of resetting every cap mid-mission (CLAUDE.md §1.8).

  • class ReasonerLadderState (Pydantic v2, extra-forbid) — versioned on-disk boundary carrying everything a restarted reasoner needs: mission: MissionState | None, subdivide_offered: set[str], collective_nudges: dict[str, int], locate_task_id: str | None, locate_count: int.
  • save_ladder_state(path, state) -> None — atomic JSON write (tmp + os.replace; a crash mid-write leaves the previous snapshot intact). Versioned (schema_version: "0.1").
  • load_ladder_state(path) -> ReasonerLadderState | NoneNone on absent / corrupt / version-mismatched snapshot (caller starts fresh and logs; resuming from a bad snapshot is worse than not resuming).

python/reasoner/src/openral_reasoner/context.py

ContextRenderer builds the structured text snapshot the LLM consumes per tick (no pixels in v1).

  • module constant DEFAULT_BUFFER_SIZE: int = 8 — Rolling buffer capacity per category. (L57)
  • module constant DEFAULT_PROMPT_PRIORITY: int = 10 — Default operator-prompt priority; matches openral_prompt_router.DEFAULT_SOURCES auto-cascade priority. Human sources stamp 100 onto metadata_json so they drain first.
  • class FailureEventRecord (frozen dataclass, L48) — Failure-buffer entry; fields source, kind, severity, evidence_json, rskill_id, trace_id, stamp_ns.
  • class PerceptionEventRecord (frozen dataclass, L62) — Perception-buffer entry; fields kind, text, metadata_json, stamp_ns.
  • class PromptRecord (frozen dataclass, L72) — Operator-prompt-buffer entry; fields text, metadata_json, stamp_ns, priority=DEFAULT_PROMPT_PRIORITY. The priority field is filled in by append_prompt from metadata_json["priority"] when the record was constructed with the default sentinel.
  • render_robot_self_model(description: RobotDescription) -> str (L318) — Decision 2.1 (EMOS "Robot Resume"): a deterministic static self-model block — name/embodiment, dof, end_effectors, locomotion, payload_kg, capability flags, cameras (with FOV), control_modes — so the LLM can judge reach/view feasibility before dispatch. Set on a renderer via ContextRenderer.set_robot_model; rendered as the ## ROBOT section.
  • render_playbooks_block(entries: list[tuple[str, str]]) -> str — Decision 1 / Phase 3: renders the ## PLAYBOOKS system-prompt block from (name—trigger, PLAYBOOK.md body) entries. reasoner_node._collect_playbooks_block gathers installed, capability-matched kind: playbook rSkills and appends the block to the system prompt at seed time; returns "" (no-op) when none match. Playbooks guide decisions only — every motion still goes through execute_rskill + the safety kernel.
  • class MemoryEntry / class MemoryStore (openral_reasoner.memory) — §3 / Phase 4b: the self-maintained MEMORY.md file model (persistent semantic memory — preferences, lessons, home facts, object-location log, open tasks; complementary to the geometric scene graph). MemoryStore.from_markdown / to_markdown round-trip the human-editable file; to_context_block(cap=None) renders the ## MEMORY section — Phase 5: when cap is set and the store exceeds it, only the top-cap entries by importance then recency (current over stale) render, with a "use memory_search to recall" footer (bounded always-on context); apply(op, section, content, importance, target, now) does an explicit add/update/supersede/delete (Mem0 + Zep supersession — supersede marks the prior stale but keeps it as a search hint) returning any entry to archive; consolidate() -> list[MemoryEntry] (Phase 5) drops exact (section, content) duplicates keeping the highest-ranked (_rank: current > stale, then importance, then recency), returning the removed copies to archive (Mem0 ADD-merge); search(archive, query, section, limit) ranks archived entries (MemGPT recall). Advisory only. reasoner_node._maybe_load_memory loads it from the memory_md_path param into ContextRenderer.set_memory_block at configure (read path) and loads the <MEMORY.md>.archive.jsonl recall log + flips ToolPalette.memory_available so the write/search tools are offered (Phase 4c). The ## MEMORY block is rendered via _render_memory_block under the memory_context_cap param (Phase 5; 0 = off). Writes flow through _dispatch_memory_write (apply → archive the displaced entry → consolidate() paging duplicates to the archive → persist MEMORY.md → re-render → confirm); recall through _dispatch_memory_search (Phase 4c).
  • class ExecutionEventRecord (frozen dataclass) — §2.2 execution-feedback buffer entry; fields rskill_id, outcome ("ok"|"failed"), summary, reflection (§2.3 hint, failures only), stamp_ns.
  • class RewardStateRecord (frozen dataclass) — amendment: latest two-head reward assessment surfaced to the LLM; fields progress (closeness — the gated head, drives persist-vs-replan), success (done-confidence — compressed; secondary), progress_trend, success_trend (per-frame slopes), task, stamp_ns. Rendered as the ## REWARD section via ContextRenderer.set_reward_state / _render_reward; fed by reasoner_node from each query_task_progress / mission-verify response.
  • reflect_on_failure(outcome_state, detail) -> str / reflect_on_retry_cap(tool, cap) -> str / reflect_on_invalid_plan(detail) -> str — §2.3 (Reflexion): deterministic one-line strategy hints (no LLM call) turning a raw failure / exhausted retry ladder / undecodable tool call into a "change approach" cue for the next tick. reflect_on_invalid_plan feeds the decode error back so a weak model that emitted malformed JSON arguments fixes its call instead of re-issuing it.
  • reflect_on_reward_plateau(progress_now) -> str — the reward-plateau hint (policy ran clean but the progress head says NOT done). Distinct from reflect_on_failure (controller fault → shorten/substitute): here the move is to change tactic (different grasp/angle), not subdivide the same action or re-issue the identical instruction.
  • class ContextRenderer (L386) — Stateful renderer. Methods: set_robot_model(robot_model: str | None) (§2.1 — sets/clears the static ## ROBOT self-model; static config, does NOT bump seq), set_memory_block(memory_block: str | None) (§3 — sets/clears the ## MEMORY block from the MEMORY.md store; does NOT bump seq), set_mission(mission: MissionState | None) (§1 — sets/clears the active task queue rendered as ## MISSION before ## WORLD_STATE; a new goal is an event so it DOES bump seq), set_in_view(objects: ObjectsMetadata | None) (sets/clears the latest continuous-detector enumeration rendered as the camera-space in_view[<camera>] line in ## WORLD_STATE; bumps seq only when the rendered enumeration changes — a continuous detector republishing identical frames used to bump unconditionally and permanently defeat the heartbeat-idle gate), set_inflight_skill(rskill_id: str | None, *, stamp_ns=0, state="running") + properties inflight_skill / inflight_state (the execute_rskill goal currently in flight, with its PHASE — "dispatching" from send until accept (cold policy loads take tens of seconds; without the phase the LLM escalated "task is blocked" to the operator mid-load, observed live 2026-07-20) then "running" — rendered as the leading in_flight: line in ## EXECUTION so the LLM never double-dispatches blind; state changes bump seq; also read by ReasonerCore's heartbeat-idle gate so mid-run reward polling stays possible), clear_located() (drops the sticky open-vocab grounding on a new operator goal — stale authority once objects may have moved; bumps seq when non-empty), note_located(objects: ObjectsMetadata | None) (folds open-vocab locate_in_view hits into a sticky located[<camera>] line (keyed by lowercased label, latest-wins, capped at _LOCATED_CAP=12) that survives the continuous detector's per-frame set_in_view clobber, so a goal noun the fixed indoor vocabulary mislabels — e.g. basket/ketchup — stays grounded for decompose/dispatch; bumps seq; None/empty is a no-op), set_reward_state(reward: RewardStateRecord | None) (sets/clears the latest two-head reward assessment rendered as the ## REWARD section; a fresh assessment is an event so it DOES bump seq), advance_mission(*, done: bool, verdict: str) -> TaskState | None (§1 — complete_active/abandon_active the active task + activate the next, bumps seq; returns the new active task or None when the mission is finished; no-op when no mission set), property mission -> MissionState | None (node mutates it in place for non-waking bookkeeping — record_attempt/mark_verifying), append_execution (§2.2 — success+failure outcomes into the ## EXECUTION section, bumps seq so feedback wakes an idle heartbeat), append_failure, clear_failures (drops stale failure/execution context after /openral/estop_cleared so the next prompt is not poisoned by a reset-resolved e-stop), append_perception, append_prompt (priority-ordered insert; buffer-evicts the lowest-priority oldest entry on overflow — every append also bumps the monotonic seq counter), render(*, world_state) -> str, drain_prompts(*, seen: tuple[PromptRecord, ...] | None = None) -> tuple[PromptRecord, ...] (pull-once, priority-desc + arrival-asc order; does NOT bump seq; seen drains only those records by identity — the phased tick (#21) passes its prepare-time snapshot so a prompt that arrived while the LLM call was in flight, and was therefore never rendered, survives for the next tick); properties failures, perception_events, prompts, seq (mutation counter consumed by ReasonerCore to short-circuit a heartbeat tick when no event has arrived since the last successful tick — amendment 2026-05-25 §2). The ## WORLD_STATE block (_render_world_state) renders joint_state / ee_poses / battery / diagnostics and, since #14 (2026-06-12), a scene_objects[<frame>]: label@(x,y,z), … line from WorldState.detected_objects (deduped by label, first-seen pose) — so the LLM sees the lifted object labels (e.g. bread) and can map a goal noun (baguette) onto them with its own semantics rather than only learning a name is "not in memory". _render_in_view adds a camera-space in_view[<camera>]: #<det_id> <label> @px(<cx>,<cy>), … line (sorted by det_id) from the latest ObjectsMetadata set via set_in_view — a depth-free enumeration (pixel centres, explicitly image space, kept distinct from the 3D scene_objects line) that populates even when the lift can't run (RGB-only / no octomap), so the LLM can ground/decompose a collective goal; it renders even before the first WorldState snapshot. _render_in_view also emits a sticky located[<camera>]: <label> @px(<cx>,<cy>), … line from note_located hits (the open-vocab locator's confirmed goal nouns) — distinct from the fixed-vocab in_view line it clobbers — which is what breaks the deploy locate-loop (the continuous indoor detector mislabels basket/ketchup/milk, so without the sticky locate fold the LLM never grounded the goal nouns and looped on recall_object/locate_in_view instead of decomposing/dispatching). _render_reward adds a ## REWARD section (when set_reward_state has been called) carrying BOTH reward heads, distinctly labelled — progress=<v> (closeness, trend …) and success=<v> (done-confidence, trend …) — so the LLM uses progress for persist-vs-replan and success for done-ness; the heads' different meanings are never blurred.
  • _summarise_evidence_json(payload) -> str — Decode the FailureEvidence discriminated union and produce a one-line summary. (L1048)
  • _extract_priority(metadata_json) -> int — Parse a top-level priority field out of a PromptStamped's metadata; returns DEFAULT_PROMPT_PRIORITY on missing / malformed / non-int payload.

python/reasoner/src/openral_reasoner/core.py

ReasonerCore, the transport-agnostic orchestrator. The ROS-side reasoner_node wraps this with rclpy.

  • class ReasonerTickResult (frozen dataclass, L26) — Tick outcome; fields tool_call: ReasonerToolCall | None, error: ROSPlanningError | None, elapsed_s: float, suppressed_reason: str (one of "", "min_interval", "heartbeat_idle", "mission_finished", "retry_cap", "palette_empty"), traceparent: str | None (W3C traceparent captured inside the active reasoner.tick span — None when no real TracerProvider is installed).
  • _call_identity(call) -> str — canonical retry-cap identity of a tool call: the full argument payload minus rationale (a loop rephrasing its rationale between identical retries must not dodge the cap), JSON-serialised with sorted keys.
  • class PreparedTick (dataclass, L82) — In-flight state of a phased tick (#21) between prepare_tick and finish_tick: the LLM inputs (context_text, palette, system_prompt) plus the prepare-time bookkeeping snapshots (started, seq, prompts, the open non-attached OTel span, renderer, force, tier). The seq/prompts snapshots are the mid-flight-event contract: finish_tick marks seen / drains only what the model actually saw. llm_s + prompt_tokens are the ONLY fields the LLM phase writes back (#92): the provider round-trip's own wall-clock and the prompt size that drove it, so a tick that drifts from 6 s to 99 s over a mission can be attributed to the provider or to reasoner-side overhead (elapsed_s - llm_s) instead of neither.
  • class ReasonerCore (L154) — Orchestrator. Methods: tick(*, world_state, renderer, palette, force=False, tier="heartbeat") -> ReasonerTickResult (synchronous composition of the three phases below — the contract for tests and non-ROS embedders); phased API (#21 — the ROS node runs the blocking LLM phase on a worker thread so it cannot starve the rclpy executor): prepare_tick(*, world_state, renderer, palette, force=False, tier="heartbeat") -> PreparedTick | ReasonerTickResult (suppression gates + span open + context render; must run on the owning/executor thread), run_prepared_llm(prep: PreparedTick) -> ReasonerToolCall (the blocking select_tool round-trip; the ONLY phase safe off-thread — touches nothing but the client and the prep snapshots; runs under the tick span via per-thread use_span; times the call into prep.llm_s in a finally — a timed-out call is the one worth timing — and picks up the client's optional last_prompt_tokens into prep.prompt_tokens), finish_tick(prep, *, call=None, error=None) -> ReasonerTickResult (retry-cap ladder + seen-marking up to the prepare-time snapshot + traceparent capture + span close, back on the owning thread; a non-ROSPlanningError error is recorded and re-raised); read-only properties retry_cap, streak_tool. §4 min-interval (100 ms) + the retry cap (default 3) enforced here — the cap is keyed on the full call identity (_call_identity: tool + args minus rationale), so navigate→pick→place (all execute_rskill) never trips it while a verbatim repeat still does. The read-only search tools plus wait (_RETRY_CAP_EXEMPT_TOOLS = recall_object / resolve_place / locate_in_view / wait) are TRANSPARENT to the cap — neither counted nor streak-resetting: the search loops' own budgets (SearchProgress, TaskLocateBudget) bound them and terminate in an explicit human-handoff, which the cap's silent hold must never preempt (caught live by test_active_search_cascade_is_bounded_and_hands_off); wait is byte-identical by construction and instructed during nominal in-flight supervision, so counting it fabricates a retry-cap failure mid-run. After a retry_cap suppression the core arms a pre-call hold (suppressed_reason="retry_cap_hold"): non-forced ticks whose renderer.seq has not moved past the capped seq skip the LLM call entirely (the pre-fix flow paid a full LLM round-trip per capped tick just to discard the result); the node's reflection feedback bumps seq and releases the hold. A terminal mission short-circuits non-forced ticks with suppressed_reason="mission_finished" even when perception keeps changing renderer.seq; a new operator goal rebuilds the mission before its forced tick, and urgent forced failure ticks still bypass the gate. Heartbeat-idle short-circuit (amendment 2026-05-25 §2): when force=False, renderer.seq matches the seq at the last successful tick and no skill is in flight (renderer.inflight_skill is None — a running skill keeps the heartbeat live for mid-run query_task_progress polling), the LLM call is suppressed with suppressed_reason="heartbeat_idle". Palette-empty short-circuit prevents wasted LLM calls when force=False — a force=True tick (event preemption from SEVERITY_FAIL FailureTrigger, SEVERITY_WARN on /openral/failure/safety, or new operator prompt) bypasses the min-interval gate, the mission-finished gate, the heartbeat-idle gate, AND the palette-empty gate so the LLM can pick EmitPromptTool to escalate even on a bare reasoner. The retry-cap gate still applies under force=True. The tier kwarg ("A"/"B"/"C"/"D"/"heartbeat") is recorded verbatim on the span as reasoner.tier for dashboard filtering — observability only; per-tier preemption thresholds live in ReasonerNode._FAILURE_TIER_FOR_SOURCE. Wraps the per-tick work in reasoner_span (openral_observability) so the LLM call lives under a reasoner.tick OTel span with reasoner.{model, tick.idx, tool, rskill_id, suppressed_reason, error_kind, force, tier, llm_s, prompt_tokens} attributes (§6); llm_s/prompt_tokens are stamped before the error and retry-cap branches (those ticks burned the round-trip too) and also ride the reasoner.tick.selected structured log.

python/reasoner/src/openral_reasoner/critic_watchdog.py

Tier-C critic progress-stall / success watchdog — default decision core for the reserved /openral/failure/critic source (observability audit P1 R3 + reward-watcher). Pure logic, import-safe (no rclpy); emits the real openral_core.CriticEvidence. Source-agnostic: any reward model emitting a higher-is-better scalar (Robometer, a future SARM, a success classifier) drives the same watchdog. The critic producer node subscribes to the generic /openral/critic/score topic (openral_msgs/CriticScore), routes samples through CriticWatchdogGroup, and on a non-None return publishes via FailureBusPublisher(node, FailureSource.CRITIC) (kind=KIND_CRITIC, severity=SEVERITY_FAIL); the reasoner_node maps that FAIL event to a forced Tier-C tick. Fires on stall OR success — the reasoner is woken promptly when an attempt is likely done, not only after a subsequent stall.

  • class CriticWatchdog (L100) — Progress-stall / success state machine. __init__(critic_id: str, threshold: float, stall_patience: int, *, min_delta: float = 0.0) (raises ValueError on stall_patience < 1 or min_delta < 0). Methods: observe(score: float) -> CriticEvidence | None — fires one CriticEvidence(critic_id, score, threshold) in two mutually exclusive cases: (a) successscore >= threshold and the success latch is not set (one-shot per streak; latch clears when score next drops below threshold or on reset); (b) stallstall_patience consecutive below-threshold, non-improving observations while the stall latch is not set (latch clears on progress, recovery, or reset). Success takes precedence when both would fire on the same sample; reset() -> None — clears running best, stall counter, stall latch, and success latch (call on reasoner context shift, mirroring ReasonerCore.reset_kind_streak). Read-only properties critic_id, threshold, stall_patience, min_delta.
  • class CriticWatchdogGroup (L272) — Multiplexer keying one CriticWatchdog per critic_id so multiple/future reward models (Robometer + SARM + …) share the /openral/failure/critic source independently. __init__(*, stall_patience: int, min_delta: float = 0.0). Methods: observe(*, critic_id: str, score: float, threshold: float) -> CriticEvidence | None — lazily creates a watchdog per critic_id (binding threshold on first sight, held stable) and delegates; known_critics() -> frozenset[str]; reset(critic_id: str | None = None) -> None — drop one critic's watchdog (rebinds its threshold) or all. Read-only properties stall_patience, min_delta.

python/wam/src/openral_wam/protocol.py

World Action Model Protocol (CLAUDE.md §6.3). This package ships the Protocol/contract surface only (WorldModel, Rollout, NullWorldModel); concrete generative WAM adapters (Cosmos Predict, UnifoLM-WMA-0, IRASim) ship as separate downstream packages in the private OpenRAL Pro monorepo.

  • class WorldModel(Protocol) — Generative simulator used by the planning layer for the three integration patterns (gating / failure anticipation / replanning). Attribute: max_horizon. Method: rollout(world_state, action_chunk, horizon) -> Rollout — predict horizon steps of future state; raises ROSConfigError (horizon exceeds max). (L31)

python/wam/src/openral_wam/rollout.py

Pydantic v2 schema for a WAM's predicted trajectory.

  • class Rollout(BaseModel) — Predicted trajectory from one WorldModel.rollout call. Fields: predicted_states: list[WorldState] (min_length=1), predicted_rewards: list[float] | None, horizon: int (>0), latency_ms: float (≥0.0), confidence: float ∈ [0.0, 1.0]. extra="forbid". (L24)

python/wam/src/openral_wam/null_wam.py

Identity stub satisfying the WorldModel Protocol (for plumbing tests; not a production fallback).

  • class NullWorldModel — Returns horizon copies of the input WorldState, no rewards, 0.0 ms latency, confidence 1.0. Attribute: max_horizon. (L27)
  • __init__(max_horizon=16) -> None — Raises ValueError if max_horizon <= 0. (L54)
  • rollout(world_state, action_chunk, horizon) -> Rollout — Replays the input state. Raises ValueError for horizon ∉ (0, max_horizon]. (L60)

packages/openral_safety/openral_safety/supervisor_node.py

Lifecycle node skeleton; reserves the supervisor node name and topic surface for the future C++ kernel (CLAUDE.md §6.1 Layer 6, §7.7). No enforcement logic.

  • class SafetySupervisorNode(LifecycleNode) — Skeleton lifecycle node. Every transition callback returns SUCCESS. (L638)
  • __init__(node_name="openral_safety_supervisor") -> None — Initialise; logs a "skeleton no-op" line so the supervisor's presence in the graph is visible. (L100)
  • on_configure(state) -> TransitionCallbackReturn.SUCCESS (L150)
  • on_activate(state) -> TransitionCallbackReturn.SUCCESS (L225)
  • on_deactivate(state) -> TransitionCallbackReturn.SUCCESS (L233)
  • on_cleanup(state) -> TransitionCallbackReturn.SUCCESS (L240)
  • on_shutdown(state) -> TransitionCallbackReturn.SUCCESS (L263)
  • main(args=None) -> int — Entry point for ros2 run openral_safety supervisor_node. (L641)

packages/openral_safety/openral_safety/envelope_loader.py

Pydantic → C++ kernel ROS-param bridge.

  • merge_deploy_envelope(robot_env, deploy) -> SafetyEnvelope — Apply explicit DeployScene.safety fields to the robot ceiling with tighten-only validation; omitted fields keep robot manifest values.
  • compute_intersection(robot, skill=None, *, deploy=None) -> EnvelopeIntersection — Robot ceiling ∩ optional deploy/workcell envelope ∩ optional skill envelope; rejects (never clamps) any deploy or skill safety field that loosens the robot ceiling.
  • kernel_params_from_envelope(envelope) -> dict[str, object] — Canonical scalar/AABB envelope → kernel ROS-param dict.
  • collision_params_from_description(robot, *, margin_m=None) -> dict[str, object] — Flatten collision_geometry + allowed_collision_pairs + the kinematic chain (joint origin_xyz/rpy/axis) into the kernel's collision params, topologically ordered. Routes each link's primitive by shape: capsules/spheres → collision_capsule_link + parallel radius/half-length/origin arrays; BoxShapecollision_box_link + collision_box_half_extents + collision_box_origin_xyzrpy. Omits any empty primitive / allowed-pair array (launch_ros rejects empty typed arrays — an all-box robot like so101 has zero capsules). margin_m=None reads robot.safety.self_collision_margin_m (else the explicit arg overrides). {"self_collision_enabled": False} when no geometry. The C++ kernel checks box↔capsule / box↔box (self) and box↔world-capsule / box↔voxel (world) alongside the capsule paths.
  • merge_extra_allowed_pairs(params, pairs) -> dict[str, object] — Additive deploy-scene ACM merge. Resolves link names through collision_link_names, rejects unknown/self pairs, dedupes order-insensitively, and no-ops when self-collision geometry is disabled.
  • ee_link_index_from_collision_params(params) -> int — Phase 3. Pick the predictive-Cartesian EE control link (the kinematically deepest collision link) for the kernel's Jacobian look-ahead; -1 when no collision model (predictive disabled, reactive floor only). Mis-identification is bounded by the reactive check.

packages/openral_safety/openral_safety/mjcf_lowering.py

Offline MJCF → kernel collision-params lowering; imports mujoco lazily.

  • lower_collision_params(model, joint_names, *, margin_m=0.0) -> dict[str, object] — Lower a compiled mujoco.MjModel to the kernel's collision params from the full kinematic tree (fixed mounts + floating base): per-link origins from the body tree, every collidable primitive per body as a capsule (cylinder→capsule, box→bounding-sphere; mesh/plane skipped), dof_index assigned by movable-joint order (the i-th hinge/slide joint → manifest column i, capped at len(joint_names); MJCF joint names are not consulted — they differ from the manifest, e.g. Rotation vs shoulder_pan, so name-matching silently froze every link's FK at rest), ACM = parent↔child + MJCF excludes + a neutral-pose overlap sweep (the MoveIt "disable always-in-collision pairs" rule under the kernel's own capsule approximation).

packages/openral_safety/openral_safety/urdf_lowering.py

Offline URDF(+SRDF) → manifest collision-model lowering tool; lazy-imports yourdfpy / trimesh (the [lowering] group). Populates robot.yaml's collision_geometry + allowed_collision_pairs (the hand-reviewable manifest path), distinct from mjcf_lowering (the runtime MJCF path).

  • parse_srdf_disabled_pairs(srdf_path) -> set[frozenset[str]] — Parse a MoveIt SRDF's <disable_collisions> rows into unordered link pairs (the ACM).
  • fit_capsule_to_vertices(vertices) -> tuple[CapsuleShape, tuple[float×6]] — PCA bounding capsule (segment along +Z) containing every vertex — a conservative over-approximation so the safety check never under-covers; returns the shape + link-frame origin_xyz_rpy (kernel's rpy convention, inverse of mjcf_lowering._rpy_to_mat).
  • lower_link_geometry(urdf_path) -> list[LinkCollisionGeometry] — One conservative capsule/sphere per URDF link with a <collision> (box→8 corners, cylinder→cap rims, sphere→exact SphereShape, mesh→trimesh vertices PCA-fit), vertices first transformed into the link frame by the collision <origin>.
  • acm_for_geometry(urdf_path, geoms, *, srdf_path=None, n_samples=2000, seed=20260610, margin_m=0.0) -> set[frozenset[str]] — The ACM for a specific per-link capsule geometry (the geometry the kernel will actually load). ACM = adjacent ∪ always-colliding(capsule) ∪ [SRDF-disabled if srdf_path else never-colliding(capsule)]. The always-colliding term adds the capsule-junction pairs a mesh-based SRDF omits (e.g. a short link making skip-one neighbours' capsules overlap) — without them the capsule kernel false-E-stops every step. Deterministic under the pinned seed.
  • sample_acm_from_urdf(urdf_path, *, n_samples=2000, seed=20260610, margin_m=0.0) -> set[frozenset[str]] — No-SRDF fallback: lowers the URDF's own collision geometry and runs acm_for_geometry without an SRDF. Verified conservative against URDF-lowered (mesh-bounding) capsules — its disabled set is a subset of the precise-mesh SRDF's, never false-permissive.
  • lower_robot(robot, *, srdf_path=None, acm_only=False, geometry_only=False) -> LoweredCollisionModel — Top-level entry. ACM source precedence: explicit srdf_pathrobot.srdf_path → URDF sampling fallback; ACM scoped to links carrying geometry. Generated geometry is scoped to the manifest's kinematic chain (no orphan URDF links); joint_fk is lowered too (unless acm_only). acm_only/geometry_only restrict output so hand-tuned safety geometry isn't churned. Zero fitted links (unresolvable collision meshes) raises ROSConfigError — never an empty geometry/ACM the kernel would silently not check. Vendored-URDF rd:<module>:<relpath> mesh refs are expanded at load time via the pinned robot_descriptions clone. Raises ValueError if robot.urdf_path is unset/unresolvable (a robot_descriptions:<module> xacro form is accepted).
  • lower_joint_fk(robot, urdf_ref) -> dict[str, tuple[xyz, rpy, axis]] — Per-manifest-joint forward kinematics (origin + axis) read from the URDF, matched to manifest joints by child_link. The kernel needs these to place the link capsules. Unmatched joints (synthetic gripper / base DoF) are omitted.
  • lower_robot_from_mjcf(robot, *, n_samples=2000, seed=20260610, margin_m=0.0, manifest_dir=None) -> LoweredCollisionModel — MJCF backend for robots with no URDF whose collision is meshes (mjcf_lowering's primitive path skips them), e.g. bimanual openarm. Keeps the manifest's hand-authored capsules; lowers joint FK (the MJCF parent→child transform at rest) + the conservative ACM (mujoco-FK sweep). Manifest↔MJCF link-name divergence is reconciled via sim_joint_name. A manifest assets.srdf is unioned into the sweep's disabled set (parse_srdf_disabled_pairs) — the explicit channel for hand-reviewed rest-pose exemptions the always-colliding criterion can't prove (openarm's folded gripper); hand edits to the generated ACM block are unreproducible and forbidden. Lazy-imports mujoco + openral_core.assets.resolve_asset (resolves robot.assets.mjcf, honouring manifest_dir for file: refs). acm_source="mjcf".
  • select_lowering(robot, *, manifest_dir=None) -> LoweringSource — Provenance-correct routing (§5): "srdf" when an SRDF plus a URDF with usable collision geometry are present (mesh-proven ACM), "sampling" when a URDF with usable collision meshes but no SRDF, "mjcf" when no usable URDF geometry but an MJCF exists (an SRDF on such a robot stays on the MJCF path and feeds its exemption union instead) (e.g. openarm, whose vendored URDF's package:// collision meshes don't resolve → 0 geometry). Replaces the naive urdf if assets.urdf else mjcf guess that wrongly sent openarm to the empty URDF path. Raises ROSConfigError when no lowerable asset.
  • lower_robot_auto(robot, *, acm_only=False, geometry_only=False, manifest_dir=None) -> LoweredCollisionModel — Single dispatch over select_loweringlower_robot (srdf/sampling) or lower_robot_from_mjcf (mjcf). The one entry the CLI (openral collision lower|check) and the byte-identical regression both call, so routing can never diverge between what's committed and what's verified.
  • LoweringSourceLiteral["srdf", "sampling", "mjcf"]; the source select_lowering resolves to (matches LoweredCollisionModel.acm_source).
  • class LoweredCollisionModel — Frozen dataclass result: collision_geometry, allowed_collision_pairs (sorted tuples), acm_source ("srdf"|"sampling"|"mjcf"), srdf_path, joint_fk (per-joint FK for onboarding).

packages/openral_safety/openral_safety/cumotion_config.py

Derive a cuRobo (cuMotion) robot-config from the same lowered collision geometry the safety kernel checks, so plan-time and kernel-time collision stay consistent. Pure module; reuses urdf_lowering._capsule_segment_radius.

  • class CuMotionSphere — Frozen dataclass: center (link-frame (x, y, z)), radius — one cuRobo collision sphere.
  • capsule_to_spheres(p0, p1, radius, *, count) -> list[CuMotionSphere] — Sample count spheres evenly along the segment p0p1 (endpoints inclusive for count >= 2; midpoint for count == 1).
  • spheres_for_capsule(shape) -> int — Sphere count to tile a lowered capsule with centres ≤ one radius apart (ceil(L/r)+1); 1 for a sphere / zero-length capsule.
  • link_collision_spheres(geom, *, count=None) -> list[CuMotionSphere] — Lower one LinkCollisionGeometry to cuRobo spheres in its link frame (reuses the kernel's capsule→segment math).
  • actuated_joint_names(robot) -> list[str] — Single-DOF movable joint names (revolute/prismatic/continuous), in manifest order — the cuRobo cspace.joint_names.
  • render_cumotion_config(robot, model) -> str — Render a cuRobo robot_cfg YAML fragment (base_link, collision_spheres, self_collision_ignore from the ACM, cspace.joint_names) with a generated-provenance header. retract_config / accel-jerk limits are planner tuning, left for Phase 3.

packages/openral_reasoner_ros/openral_reasoner_ros/reasoner_node.py

reasoner_node lifecycle wrapper. Thin rclpy shell around openral_reasoner.ReasonerCore.

  • module constants _FAILURE_SOURCES, _PERCEPTION_KINDS — closed sets from §3 (hal/sensor/rskill/safety/wam/critic (the rskill suffix replaced skill on 2026-05-25 — amendment §5) and motion/objects/ocr/scene_change). (L282)
  • module constants _KIND_TIMEOUT, _KIND_CONTROLLER, _SEVERITY_WARN, _SEVERITY_FAIL — IDL-mirror constants for openral_msgs/FailureTrigger. Kept inline rather than importing the openral_observability.failure_bus helper so the reasoner emits a FailureTrigger without dragging the rate-limiter into the dispatch path (the reasoner publishes O(1) events per skill goal, not a stream). (L309)
  • module constants _EXECUTE_SKILL_SERVER_PROBE_S, _LIFECYCLE_SERVER_PROBE_S — 100 ms wait_for_server / wait_for_service probes so an absent F1 server / lifecycle peer can't block the executor thread. (L326)
  • module constant _FAILURE_TIER_FOR_SOURCE: dict[str, str] — the 2026-05-25 amendment trigger taxonomy. Greppable map of each /openral/failure/<source> to its tier: safety → "A", hal/sensor/rskill/wam → "B", critic → "C". Used by _on_failure to stamp reasoner.tier on the OTel span — observability only; the per-source preemption threshold (SEVERITY_WARN for safety, SEVERITY_FAIL for everything else) is decided inline in the same callback.
  • (the former module-local _SIM_EXECUTABLE_CONTROL_MODES frozenset was removed 2026-06-04; the hal_mode == "sim" gate now imports the canonical openral_core.SIM_EXECUTABLE_CONTROL_MODES, trimmed to the six packer-implemented modes — see the Layer-0 core entry. Amendment 2026-06-04.)
  • def _required_control_modes(manifest: RSkillManifest) -> set[ControlMode] (L428) — Pure helper for the deploy-path palette gate. Reads action_contract by specificity: None → set() (no action constraint); representation set → control_modes_for_representation(...); slots set → each non-None slot's control_mode; bare dim (legacy) → {JOINT_POSITION}.
  • def _action_executable(manifest: RSkillManifest, description: RobotDescription, hal_mode: str) -> bool (L459) — Pure helper. True when every _required_control_modes(manifest) is in the executable set: openral_core.SIM_EXECUTABLE_CONTROL_MODES for hal_mode == "sim", else description.capabilities.supported_control_modes (coerced to ControlMode both sides so an enum-member or raw-"joint_position"-string deserialisation compares equal). Empty required set → True.
  • def _resets_search_episode(call) -> bool (L504) — §3 pure helper for the find→re-prompt cascade bound. True when dispatching call ends the active-search episode (so _dispatch resets _spatial_search + _locate_escalated); False for the three search actions RecallObjectTool / ResolvePlaceTool / LocateInViewTool. Guards the regression where a directly-emitted locate_in_view reset its own bounding budget — a recall→locate→recall loop against an undetectable object zeroed the counter every cycle and never handed off (observed live as 127 consecutive locate attempts on libero_object). Paired with _on_locate_in_view_response, which now records one budget attempt on found=False (and hands off when exhausted), resetting the streak on a hit.
  • class ReasonerNode(LifecycleNode) (L598) — Lifecycle node. __init__(*, node_name="openral_reasoner", tick_hz=0.2, client=None, palette=None, robot_capabilities=None, commercial_deployment=False, spatial_memory=None). Phase 2b: spatial_memory is an optional read-only SpatialMemoryQuerier backend (a SpatialMemory); when supplied the palette's spatial_memory_available is set (the recall_object / resolve_place tools are offered) and the rebuild path threads it through. Deployment wiring: the spatial_memory_path ROS parameter (default "") loads a persisted scene graph as that backend at on_configure when no backend was injected (see _maybe_load_spatial_memory); the spatial_memory_ingest ROS parameter (default false) auto-creates an empty backend and folds each WorldState.detected_objects snapshot into it on tick (live dynamic memory from the producer). Decision 3b — the deploy memory bundle: sim_e2e.launch.py forwards memory_md_path (loads MEMORY.md + enables the memory tools) and brings up a standalone nav2_map_server from a saved map.yaml when its map_path arg is set and SLAM is off (latches /map, which the reasoner consumes into its _occupancy_grid via occupancy_map_topic); with SLAM on, map_path is ignored (SLAM owns /map). The hal_mode ROS parameter (default "sim") selects the action-mode palette gate (_action_executable) the skill-registry refresh applies. tick_hz is the heartbeat rate (default 0.2 Hz = one tick every 5 s; was 5.0 pre-2026-05-25 amendment — the reasoner is now event-driven with a slow heartbeat). The two refresh-kwargs (added in the F4 contract-closure follow-up) drive the /openral/skill_registry_changed refresh path: without robot_capabilities the callback logs a warning and leaves the palette alone (an empty-capabilities refresh would risk dispatching incompatible skills).
  • _submit_client_warmup(client) -> None — At on_configure, kicks a managed LLM sidecar's boot onto _llm_pool instead of leaving it to the first tick. No-op for clients without a warm() (every cloud provider). Off the executor thread so on_configure returns promptly and the lifecycle transition is not held open by a model load. Non-fatal: the lazy path in select_tool still owns whether the server is usable and reports the real error where an operator can act on it; a failure here only logs at warning, which keeps a silent sidecar failure visible during bringup rather than only at the first tick.
  • on_configure — Build ToolUseClient from env if not injected, attach subscribers to /openral/world_state_slow + 6 failure topics + 4 perception topics + /openral/prompt + /openral/skill_registry_changed, create the /openral/prompt publisher + /openral/failure/rskill publisher + /openral/execute_rskill action client. Reads the vram_lifecycle_peers ROS parameter (default []) into _vram_lifecycle_peers — GPU peers auto-deactivated before each execute_rskill and reactivated after (the deploy launch sets it to openral_ros_image_detector when --enable-object-detector). Also loads the full reward manifest (_reward_manifest, from reward_manifest_path) and the GPU total (gpu_total_vram_gb param, else a one-shot nvidia-smi probe → _gpu_total_vram_gb) for the pre-dispatch VLA+reward pair fit check.
  • on_activate — Arm the periodic tick timer at tick_hz.
  • on_deactivate — Cancel the tick timer (subscriptions remain attached).
  • on_cleanup — Tear down pending skill-goal deadline timers, destroy the action client and every cached per-topic emit_prompt publisher, drop cached lifecycle/service clients, and clear the in-flight goal / cancel-reason / tick-trampoline state.
  • _on_failure(source, msg) — Append a FailureEventRecord to the renderer; preempt the next tick per the 2026-05-25 amendment trigger taxonomy — Tier A (source == "safety") preempts on severity ≥ SEVERITY_WARN, Tier B/C (hal, sensor, rskill, wam, critic) preempts on severity ≥ SEVERITY_FAIL. Reward-cancel (§2): when is_reward_wake(...) is true (a critic FAIL) and an execute_rskill goal is in flight (_active_rskill_goal), _cancel_inflight_rskill_for_reward() requests goal_handle.cancel_goal_async() and latches _rskill_cancel_reason="reward" instead of ticking — the reward signal stops the VLA now (not at the deadline_s clock), and the canceled result re-enters _maybe_verify_active_mission_task. With no goal in flight the wake falls through to the ordinary Tier-C preempt.
  • _on_tick(*, force=False, tier="heartbeat") — Single-flight trampoline over _start_tick: a tick requested from inside an in-flight tick's own dispatch (several verify/decompose handlers force one synchronously) — or, since #21, while the LLM round-trip is on the worker — is coalesced (force wins) and replayed after the in-flight pass finishes — flat stack, one LLM call at a time, bounded by _MAX_TICK_REPLAYS=4 via _release_tick_and_maybe_replay (the counter accumulates across chained replays and resets on a quiet finish). Async LLM phase (#21): _start_tick runs ReasonerCore.prepare_tick on the executor, hands run_prepared_llm to the single-worker _llm_pool (ThreadPoolExecutor(max_workers=1) — one outstanding tick LLM call; describe_image runs on its own single-worker _vlm_pool so a Tier-A tick is never queued behind an in-flight adjudication), and returns; the worker marshals _finish_llm_tick back onto the executor via _post_to_executor (inbox deque + rclpy guard condition _inbox_guard, drained by _drain_executor_inbox), which runs ReasonerCore.finish_tick, routes the ReasonerToolCall via _dispatch(call, traceparent=result.traceparent), and releases the single-flight window. While the call is in flight the executor stays free — goal results, deadline/patience timers, and Tier-A safety preemptions run instead of queueing behind the LLM (live bug: a 10 s patience timer fired at 76.6 s); a round-trip that lands after deactivate/cleanup is dropped by the _llm_generation check (span closed, nothing dispatches). Suppressed ticks finish synchronously and log at DEBUG (min_interval, heartbeat_idle, retry_cap_hold) or WARN (retry_cap, once per streak) per their operational signal-to-noise. The tier arg is passed through from the preempting callback (A from _on_failure(source="safety"), B/C from other failure sources, D from _on_prompt) and lands on the reasoner.tick OTel span as reasoner.tier. Acceptance-pinned by tests/integration/test_reasoner_async_llm.py (goal result < 1 s mid-call, deadline timer within 1 s of its deadline mid-call, Tier-A ingest mid-call + forced tick right after, dispatch/abort soak).
  • Mission lifecycle. _on_prompt seeds a single-task MissionState from the operator goal (MissionState.from_prompt, one task; the LLM decomposes via decompose_mission) via ContextRenderer.set_mission, gated by the pure node_policy.should_rebuild_mission (cascade re-prompts never rebuild; an in-progress mission is only replaced with explicit "new_goal": true metadata so an operator reply can't clobber the queue; a pre-work resend still replaces) — a rebuild also clears the sticky located grounding and persists the ladder snapshot. The search-bound + retry-cap streak resets likewise apply only when the source is absent from node_policy.CASCADE_PROMPT_SOURCES (the pre-fix guard excluded only "spatial_memory", so every detector/reward/mission re-prompt reset the very budget its dispatch had just charged). _dispatch_execute_rskill records an attempt against the active task and stores the accepted goal handle in _active_rskill_goal (§2) so a reward wake can cancel it; the goal's deadline_s slot carries the resolved patience ceiling (_effective_patience_sresolve_patience_s: LLM patience_s override > reward model's default_patience_s > legacy deadline_s), the runner's backstop, not the usual stop. On skill return — success (status 4), abort (status 6), or a reasoner-driven cancel (status 5 with _rskill_cancel_reason ∈ {"reward", "patience"}, which skips the controller-failure path as an intentional end-of-attempt; only an operator/e-stop cancel is not an attempt)_maybe_verify_active_mission_task auto-issues a windowed query_task_progress (when task_progress_available; never auto-completes without it) — the verify requests the active RewardContract's full frame_window_s (else _MISSION_VERIFY_WINDOW_S=40.0) so robometer scores the whole attempt start→now, not an 8 s trailing tail that missed the completion arc — and _on_mission_verify_response (stale-verdict guard compares BOTH task_id and text — duplicate task texts are common after a decompose) applies evaluate_task_verdict (three-tier: auto-pass / vlm_check / ladder). The band gates on the response's PROGRESS head (resp.progress_now), not the compressed success head; resp.success_now is threaded as a secondary corroborating signal and BOTH heads are pushed to the ## REWARD context via set_reward_state. Band edges from _band_edgesresolve_band_edges: the active RewardContract loaded from the reward_manifest_path param, else module-level _DEFAULT_SUCCESS_THRESHOLD=0.8 / _DEFAULT_CHECK_FLOOR=0.5; vlm_check runs _adjudicate_completion on the LLM worker via _adjudicate_completion_async (#21 — the describe_image round-trip shares the LLM timeout budget and used to starve the executor from inside the verify done-callback; the continuation _on_vlm_completion_verdict re-applies the stale-verdict guard, then hands off to the shared _apply_mission_verdict tail) — VLM yes_complete_active_and_advance, no/None → re-runs evaluate_task_verdict(ok=False, …) to drop into the attempts ladder (abandon once attempts >= max_attempts, else retry) so an ambiguous-band reward the VLM can't confirm is still bounded and never retries forever) → advance_mission(done=…) (complete/abandon) or retry, forcing a Tier-C tick. When advancement activates a new task it calls ReasonerCore.reset_kind_streak() (same as a new operator prompt) so the next task is not suppressed by retry_cap for re-using the tool kind the finished task ended on. _emit_mission_complete emits an honest operator-facing summary when the queue is finished.
  • VLM completion adjudication (§5). completion_camera_topic ROS param (default "/openral/cameras/top/image", empty = disabled): on on_configure subscribes sensor_msgs/Image (BEST_EFFORT, VOLATILE, depth=1) and caches each frame as JPEG bytes via _on_completion_camera into _latest_completion_frame. _adjudicate_completion(task_text) -> bool | None asks _tool_use_client.describe_image(image_jpeg=…, question=COMPLETION_QUESTION.format(task=…)) and returns True (complete), False (not complete), or None (no frame / no client / provider error — degrades to ladder, §6). _complete_active_and_advance(active, verdict, *, traceparent) — DRY helper shared by the native "complete" branch and the VLM-confirmed "vlm_check" branch: calls advance_mission(done=True), resets the per-kind streak when a next task is activated, emits the mission-complete summary when the queue drains, and forces a Tier-C tick. parse_yes_no(answer) -> bool (module-level, openral_reasoner.completion) — parses the VLM free-text answer: True iff the lowercased text contains an affirmative token (incl. inflections — see the module entry above) without a negation token; False on any ambiguity or empty string (never a false positive).
  • Task subdivision on replan (amendment / #123). _should_offer_subdivision(active, offered, max_depth) -> bool (module helper) decides whether a just-abandoned task gets one decomposition offer before the abandon/handoff ladder runs — bounded by _subdivide_offered (a set[str] of task ids, one offer per task; cleared when a new operator goal rebuilds the mission) and the DEFAULT_MAX_SUBDIVIDE_DEPTH depth cap, so a task that declines to decompose still terminates in human-handoff. When it returns true, _on_mission_verify_response re-arms the active task (MissionState.rearm_active) and _emit_subdivision_invite(task, verdict, *, traceparent) self-prompts (frame_id mission, a cascade source the reasoner consumes without rebuilding the queue) inviting decompose_mission(target_task_id=…). _dispatch_decompose_mission(call: DecomposeMissionTool) applies the LLM's typed decomposition: with a target_task_id it flat-splices that active task via subdivide_active(call.rendered_subtasks()) (refused at the depth bound → falls through to handoff); with an empty id it replaces the whole queue via MissionState(call.rendered_subtasks()) only when not mission.has_started() (never discards in-flight progress). call.subtasks are GroundedSubtask (one object each), rendered to task-text strings here. Edits the S2 ledger only — no actuation; forces a Tier-C tick.
  • Per-task locate budget (amendment). _charge_task_locate_budget(call, *, traceparent) -> bool (called at the top of _dispatch_locate_in_view) charges one _task_locate_budget.charge(active.task_id) cycle against the active mission task; once exhausted (DEFAULT_MAX_TASK_LOCATE_ATTEMPTS=3 without an execute_rskill dispatch) it abandons the active subtask — appends the specific reason (could not confirm '<obj>' in view after 3 locate attempts…) to ## EXECUTION, advance_mission(done=False, verdict=reason) so the reason becomes the task's ledger verdict, resets the budget + kind-streak, and forces a Tier-C tick — without dispatching the locate, then returns True. Fixes the live locate-loop the SearchProgress miss budget cannot bound (that resets on a HIT, so a repeatedly-HITTING locate_in_view never terminates). _reset_task_locate_budget clears it on a new operator goal (_on_prompt) and on a real execute_rskill dispatch (so locate cycles only count while the task has produced no skill dispatch).
  • _on_skill_registry_changed(msg) — §4 palette refresh. Walks rSkill.list_installed(), loads each entry's manifest_path into a real RSkillManifest, runs build_tool_palette(...) against the active robot_capabilities + commercial_deployment flag (every availability flag preserved — incl. memory_available=self._memory_store is not None, which the pre-fix rebuild omitted, silently dropping memory_write/memory_search on every refresh), installs the result via set_palette. openral_rskill is lazy-imported to keep the node cheap to import.
  • _dispatch(call, *, traceparent=None) — Routing-only switch over the ReasonerToolCall variants; delegates to _dispatch_emit_prompt / _dispatch_execute_skill / _dispatch_lifecycle_transition / _dispatch_spatial_query / _dispatch_memory_write / _dispatch_memory_search. WaitTool is a deliberate no-op (debug-log the rationale, return — no ROS traffic). ReloadGstPipelineTool is the sole log-and-acknowledge stub (F6 sensor-package service IDL not yet on disk — GH-126).
  • _dispatch_emit_prompt(call, *, traceparent) — Publish a PromptStamped on call.target_topic (per-topic publisher cache _emit_prompt_pubs via _emit_prompt_publisher; /openral/prompt reuses the standing cascade publisher — the pre-fix dispatcher published every call on /openral/prompt while logging the requested target, silently dropping cross-topic cascades); stamps the threaded-through traceparent into metadata_json per §6.
  • _dispatch_spatial_query(call, *, traceparent) — Phase 2b/§3. Read-only: runs a RecallObjectTool / ResolvePlaceTool against the injected SpatialMemory via run_spatial_query_detailed and republishes the rendered result as a PromptStamped with frame_id "spatial_memory" (so _on_prompt consumes it, not filtered as a self-emit) — the prompt cascade feeds the answer into the next tick. Bounded by a SearchProgress/SearchBudget: consecutive queries are counted, and once max_attempts is hit the result is published with the reasoner's own frame_id and _finish_active_search_handoff abandons the active mission task, so later heartbeats cannot restart the terminal search (the pre-fix path filtered only the immediate cascade and then logged “handing off” on every heartbeat forever). Reset on any non-query dispatch and on a non-cascade operator prompt. No actuation, no FailureTrigger. Warns + no-ops if no backend is wired. A recall_object miss (SpatialQueryOutcome.found == False, #10) escalates to a live locate_in_view for the same query term — policy-driven (not LLM-chosen) — when detector_available and the term hasn't already been escalated this search streak (tracked in _locate_escalated, reset with the search bound); a detector miss clears that term so a transient startup miss (service active before its first camera frame) can retry, while the shared search budget still bounds the total attempts. The open-vocab detector grounds objects the map never ingested / labelled differently before the budget reaches human-handoff. Phase 4: when a latched /map has been received (params occupancy_map_topic default /map — empty disables; approach_inflation_m default 0.25), every recall_object approach viewpoint is refined through refine_approach_pose before rendering, so the LLM only sees grid-valid approach poses (BLOCKED note when none exists; grid absent → geometric pass-through).
  • _maybe_load_spatial_memory() — Deployment wiring. On on_configure, when no backend was injected and spatial_memory_path is set, lazy-imports openral_world_state.SpatialMemory, SpatialMemory.load(path), sets it as the query backend, and flips spatial_memory_available. Load failure (OSError/ValueError) degrades to WARNING + no backend (tools simply not offered) — never a fabricated map. Wired in sim_e2e.launch.py via the spatial_memory_path:=<path> launch arg. Emits the loaded map once via _emit_scene_objects_span.
  • _maybe_load_memory() — §3 deployment wiring. On on_configure, when memory_md_path is set: parses the (possibly absent) MEMORY.md into a MemoryStore, renders the ## MEMORY context block, loads the <MEMORY.md>.archive.jsonl recall log (_load_memory_archive), and flips ToolPalette.memory_available so memory_write / memory_search are offered. Read failure degrades to WARNING + empty store — never a fabricated memory.
  • _dispatch_memory_write(call, *, traceparent) — §3 / Phase 4c. The reasoner's first write-capable dispatch: applies the MemoryWriteTool op (add/update/supersede/delete) to the live MemoryStore, appends any displaced entry to the archival JSONL (_archive_memory_entry), persists MEMORY.md (_persist_memory), re-renders the ## MEMORY block, and re-prompts a short confirmation (frame_id "memory") so the next tick reads the update. Advisory — a persist failure logs, never raises; warns + no-ops if no backend is wired. Timestamps come from the ROS clock (_memory_now, sim-time-aware).
  • _dispatch_memory_search(call, *, traceparent) — §3 / Phase 4c. Read-only: MemoryStore.search over the archive (superseded/deleted entries that left the live file — current memory is already in the ## MEMORY block), ranked by importance then recency (MemGPT recall), re-prompted with the hits (frame_id "memory"). No actuation, no file write.
  • _emit_scene_objects_span() — Dashboard telemetry. When a spatial-memory backend is wired, calls openral_world_state.emit_scene_objects_span(self._spatial_memory.to_scene_graph(), source_node=…) to publish the world.scene_objects span (scene-objects card + SLAM-map overlay). Called once on load and on every heartbeat _on_tick (above the _core is None guard, so a preloaded map shows even before the tool-use client builds). Advisory only; all failures swallowed at DEBUG so telemetry never disturbs the tick.
  • is_collective_target(text) -> bool — imported from openral_core (the single source of truth, shared with the GroundedSubtask schema validator); true when a task text targets a set (quantifier or bare generic plural). Drives the execute grounding gate. (Was a local _is_collective_target helper; promoted to openral_core so the schema and the runtime gate cannot drift.)
  • _emit_enumeration_invite(task, *, traceparent) — Grounding gate's self-prompt (mirrors _emit_subdivision_invite): publishes a PromptStamped (frame_id mission, a cascade source consumed next tick without rebuilding the queue) telling the LLM the active task targets a collective set, to read the live scene_objects perception line, and to decompose_mission(target_task_id=…) into one concrete subtask per specific object before any actuation.
  • _dispatch_execute_rskill(call, *, traceparent) — Probe the /openral/execute_rskill action server (100 ms wait_for_server); on absence emit a KIND_CONTROLLER FailureTrigger and bail. Grounding gate (first): when the active MissionState task is a collective target (is_collective_target), refuse to actuate — log, _emit_enumeration_invite (no inline forced tick: the invite's own arrival forces one with the invite actually in context; the pre-fix inline tick raced DDS delivery and ran the LLM against the stale context), and return without recording an attempt (a refused actuation is not a try at the task). Busy gate (second): while _rskill_inflight (latched from send to terminal result — the accepted-handle alone leaves the send→accept gap open) a second execute_rskill is refused with ## EXECUTION feedback ("wait or poll query_task_progress") — the runner serves one goal at a time and a forced tick mid-execution used to double-dispatch blind. The latch is bounded by the dispatch-phase watchdog (dispatch_watchdog_s ROS param, default 30 s, ≤0 disables): rclpy futures never time out on their own, so a runner/VRAM-peer that dies AFTER the readiness probe would leave the latch set forever; _on_dispatch_watchdog releases it, reactivates the peers, invalidates that monotonic dispatch generation, and emits a KIND_CONTROLLER FailureTrigger (state="dispatch_timeout"). A completed-yet-starved goal-response future (.done(), issue #21) is not a wedge. Any peer/goal/result/deadline callback carrying an invalidated generation is ignored; a late accepted goal is canceled, so it cannot cancel a newer watchdog, overwrite a newer goal, or send after peer-eviction timeout (live coverage in test_reasoner_dispatch_robustness.py). On accept, ContextRenderer.set_inflight_skill surfaces the running goal to the LLM (and keeps the heartbeat live for mid-run polling); cleared on the terminal result. A skill acts on exactly ONE specific object, so the LLM must enumerate + decompose first; once the active task names a single object the gate passes. Amendment 2026-06-12: when vram_lifecycle_peers is non-empty it routes through _free_vram_peers_then_send (deactivate the GPU peers first, then send); otherwise calls _send_execute_rskill_goal directly. Gate (before recording the attempt): _refuse_unfittable_vla is two-tier, both refusing with a KIND_CONTROLLER / vram_insufficient FailureTrigger and skipping the dispatch (no attempt recorded). Tier 1 — live free-VRAM probe (_detect_gpu_free_vram_gb): refuse when the VLA's declared active_min_vram_gb() exceeds the VRAM free right now — the static budgets are blind to other processes (observed live 2026-07-20: an external vLLM server held 4.7 GB of an 8 GB card, molmoact2 (4.0 GB declared) passed every static gate and burned ~30 s in a CUDA OOM abort); skipped when the dispatched skill is already resident in the runner (_resident_vla_id, set on goal ACCEPT — the warm policy's own residency is why free is low; re-dispatch needs ~0 new VRAM, so probing falsely refused every large-VLA dispatch after the first), when vram_lifecycle_peers are configured (their eviction frees VRAM the pre-eviction probe can't see), the manifest declares no size, or free VRAM is unreadable. Tier 2 — assert_vla_reward_fits(vla_manifest, _reward_manifest, _gpu_total_vram_gb) (a VLA must run with its reward model resident; warns when the VLA's reward_rskill_name ≠ the loaded reward model; skipped when no reward model is active or the GPU total is unreadable). Helpers: _manifest_for_rskill (lazy, cached VLA-manifest lookup by id) and module-level _query_gpu_gb(field) / _detect_gpu_total_vram_gb / _detect_gpu_free_vram_gb (torch-free nvidia-smi MiB→GiB probes, 0.0 on any failure).
  • _send_execute_rskill_goal(call, generation, traceparent) — Build ExecuteRskill.Goal, send asynchronously with feedback_callback=_on_execute_rskill_feedback, attach the generation-bound _on_execute_rskill_goal_response to the send future. (Extracted from _dispatch_execute_rskill for the VRAM-eviction sequencing.)
  • _free_vram_peers_then_send(call, peers, generation, traceparent) — Deactivate each GPU lifecycle peer via _change_state_async, and send the goal only once all in-flight change_state responses return — so the peer's VRAM (e.g. the ~1.3 GB object detector) is released before the runner loads the policy on an 8 GB card. Peers whose service is absent are skipped (dispatch still proceeds); the deactivated subset is recorded in _deactivated_vram_peers for reactivation. A late successful deactivation from an invalidated generation is immediately reactivated and never sends the stale goal.
  • _reactivate_vram_peers() — Reactivate the peers in _deactivated_vram_peers (clears the set first → idempotent). Called from _on_execute_rskill_result (terminal) and the goal-reject/error branches of _on_execute_rskill_goal_response; not on deadline (the policy may still be resident).
  • _on_reactivate_result(peer, future) — Best-effort log of a reactivation change_state outcome.
  • _change_state_async(node, transition) -> future | None — Shared helper: lazily create + cache a lifecycle_msgs/srv/ChangeState client per peer node, map "configure"/"activate"/"deactivate"/"cleanup" to Transition.TRANSITION_*, and call asynchronously. Returns None if the service isn't on the graph. Used by both _dispatch_lifecycle_transition and the VRAM-eviction path.
  • _dispatch_lifecycle_transition(call) — Drive <call.node>/change_state via _change_state_async; on success attach _on_lifecycle_response, on an absent service log + skip.
  • _on_execute_skill_feedback(rskill_id, feedback_msg) — Forward action feedback to the operator log, throttled inline to one WARNING per _FEEDBACK_LOG_PERIOD_S (=1 s) — a VLA goal streams one feedback per action chunk (600+ observed live per goal), and per-chunk WARNINGs drowned the operator log; suppressed lines go to DEBUG.
  • _on_execute_skill_goal_response(call, generation, sent_at, future, traceparent) — Ignore/cancel stale generations; on current rejection emit a KIND_CONTROLLER FailureTrigger; on acceptance arm a one-shot deadline timer (_on_execute_skill_deadline, only when call.deadline_s > 0) and attach _on_execute_skill_result to get_result_async().
  • _on_execute_skill_result(call, generation, goal_id, future, traceparent) — Ignore stale generations, cancel the deadline timer; on STATUS_SUCCEEDED + result.success log success; on abort/cancel/non-success emit a KIND_CONTROLLER FailureTrigger with a ControllerEvidence payload (state ∈ {aborted, canceled, failed}, detail=result.failure_reason). A typed ROSConfigError or ROSCapabilityMismatch result also removes that rSkill from the live palette until the palette is rebuilt, so a policy that cannot start in this session is not selected repeatedly; controller/runtime failures remain retryable.
  • _on_execute_skill_deadline(*, call, generation, sent_at, goal_handle, traceparent) — Ignore stale generations; latch _rskill_cancel_reason="patience" (a patience-expired attempt is a REAL attempt — the canceled result runs the reward verify gate like a reward-watcher stop), cancel the goal via cancel_goal_async(), and emit a KIND_TIMEOUT FailureTrigger with TimeoutEvidence(operation="skill.<rskill_id>", deadline_s, elapsed_s).
  • Ladder persistence. _persist_ladder_state() (after every ledger mutation: mission seed/advance/subdivide, attempt recorded, nudge charged, locate-budget abandon) snapshots ReasonerLadderState via save_ladder_state to the ladder_state_path ROS param (empty = disabled); _maybe_restore_ladder_state() reloads it at on_configure — mission restored onto the renderer, _subdivide_offered / _collective_decompose_nudges / TaskLocateBudget.restore rebound — so a reasoner restart RESUMES the ladder instead of resetting every cap mid-mission.
  • _on_lifecycle_response(call, future) — Log the ChangeState result; lifecycle failures are operator-driven and surface in the target node's own logs (no FailureTrigger re-emission).
  • _publish_skill_failure(*, kind, rskill_id, evidence, traceparent, trace_id=None) — Build + publish a FailureTrigger on /openral/failure/rskill with severity=SEVERITY_FAIL; trace_id (when propagated by the action result) takes precedence over the reasoner's active traceparent. Then mirrors the failure onto the OTLP span path via _emit_skill_failure_event so the live dashboard can tally it (the ROS failure bus is invisible to the OTLP-only dashboard).
  • _emit_skill_failure_event(*, kind, rskill_id, evidence) — Stamp an openral.event.skill_failure span event (semconv.EVENT_SKILL_FAILURE) carrying the failure state (semconv.SKILL_FAILURE_STATE: evidence.state when present — e.g. vram_insufficient / unavailable — else a kind-derived name from _SKILL_FAILURE_KIND_NAMES: timeout / controller). Adds the event to the active reasoner.tick span when one is recording (synchronous dispatch-gate paths) or opens a transient reasoner.skill_failure span (async action-callback paths) so every failure is counted. Drives the dashboard "skill failures" counter.
  • Properties renderer, dispatched_calls; method set_palette(palette) (imperative seam called from the /openral/skill_registry_changed refresh callback).
  • _QOS_REGISTRY_CHANGED — RELIABLE + TRANSIENT_LOCAL + KEEP_LAST=1 so a late-subscribing reasoner sees the most recent invalidation.
  • main(args=None) -> int — Entry point for ros2 run openral_reasoner_ros reasoner_node.

packages/openral_prompt_router/openral_prompt_router/prompt_router_node.py

Single lifecycle node that fans in operator prompts from any external source into /openral/prompt. CLI is the only v1 adapter; WebSocket / voice / Slack out-of-scope per the design's "out-of-scope" section.

  • module constant DEFAULT_SOURCES: dict[str, int] = {"cli": 100, "dashboard": 100, "auto": 10} — Default source → priority registry; human sources get 100, machine cascades get 10. (L76)
  • class PromptRouterNode(LifecycleNode) (L83) — Lifecycle node.
  • __init__(*, node_name="openral_prompt_router", sources=None) — Initialise with a source → priority registry. Defaults to DEFAULT_SOURCES.
  • on_configure — Build the /openral/prompt fan-out publisher and one /openral/prompt_in/<source> subscriber per allowed source.
  • _on_inbound(source, priority, msg) — Forward the inbound PromptStamped onto /openral/prompt after merging {"source": ..., "priority": ...} into metadata_json (preserving any per-source fields).
  • Property forwarded_count — Number of prompts forwarded since on_configure (for tests).
  • main(args=None) -> int — Entry point for ros2 run openral_prompt_router prompt_router_node.

python/cli/src/openral_cli/prompt.py

openral prompt "do X" CLI adapter. Publishes a one-shot PromptStamped onto /openral/prompt_in/cli for the prompt-router to fan out. rclpy lazy-imported so openral --help stays sub-second.

  • prompt_command(text, topic="/openral/prompt_in/cli", wait_s=1.0, discovery_wait_s=5.0, new_goal=False) — Initialise rclpy, publish one PromptStamped with metadata_json={"source_cli": true} plus "new_goal": true under --new-goal, wait briefly for the subscriber to be discovered, then shut down. Exits 2 if rclpy / openral_msgs are not importable (with a hint at just ros2-build). The prompt-router preserves the mission-replacement flag when stamping source/priority.

Observability (Layer 8 — fully shipped)

python/observability/src/openral_observability/_sdk.py

Idempotent OTel SDK setup + flush helper.

  • configure_observability(*, service_name="openral", endpoint=None, sample_ratio=None) -> bool — Install OTLP/gRPC tracer + meter + logger providers; reads OTEL_EXPORTER_OTLP_ENDPOINT when endpoint is None; returns True if exporters were installed, False for the no-op path. On a successful install also kicks off start_system_metrics_collector so the dashboard's System health card receives CPU / RAM / GPU gauges. Registers shutdown_observability via atexit on first install. Metric reader interval is configurable via OPENRAL_OTEL_METRIC_INTERVAL_MS (default 5 s); the BatchSpanProcessor flush interval via OPENRAL_OTEL_SPAN_SCHEDULE_DELAY_MS (default 30 ms ≈ 33 Hz — set ~1.3× the 25 Hz thumbnail rate so the dashboard captures every frame without flush-aliasing; raise it for coarser production batching). sample_ratio selects the trace sampler — None / 1.0ALWAYS_ON, values in (0, 1)ParentBased(TraceIdRatioBased(ratio)); honors OPENRAL_OTEL_SAMPLE_RATIO env var when arg is None. (L115)
  • configure_worker_observability(service_name, *, endpoint=None, sample_ratio=None) -> bool — Cross-process bootstrap for a spawned worker (dispatcher, future fleet supervisor): calls configure_observability (OTLP pipeline + structlog bridge) then attach_traceparent_from_env so the worker's root context is the parent trace; returns whatever configure_observability returned. Parent must spawn the child with env={**os.environ, **traceparent_env()} (R2 multiprocess log/trace correlation). (L231)
  • _resolve_sampler(sample_ratio) -> Sampler — Resolve the trace sampler from arg + env, defaulting to ALWAYS_ON. Garbage env values fall back to always-on so a typo never drops every span. (L344)
  • shutdown_observability() -> None — Flush + shut down all three providers; idempotent and safe to call when no exporter was installed. Stops the system-metrics collector before draining the meter so the final sample lands in the export batch. (L401)

python/observability/src/openral_observability/tracing.py

Span-context-manager helpers; safe to call before configure_observability.

  • rskill_span(name, *, rskill_id=None, role=None, **attrs) — Span for a Skill lifecycle phase; emits rskill.id / rskill.role from semconv. (L64)
  • inference_span(name="skill.chunk_inference", *, chunk_index=None, kind: InferenceKind="foreground", **attrs) — Span for one VLA inference and the openral.inference.duration histogram (emitted from the helper so span and metric cannot diverge); emits inference.kind / inference.chunk_index. InferenceKind = Literal["foreground", "prefetch", "single"] is the closed label set (design §9) — a timing axis, deliberately without "chunk" (shape rides inference.chunk_size). (L94)
  • safety_span(name="safety.check", *, check_name=None, severity="info", **attrs) — Span for a safety check; the C++ kernel parents its own safety.check to the Python tick via the propagator. (L154)
  • reasoner_span(name="reasoner.tick", *, tick_idx=None, model=None, force=None, **attrs) — Span for one ReasonerCore.tick. Sets reasoner.{tick.idx, model, force} and accepts any extra reasoner.* attribute via **attrs. (L188)
  • start_reasoner_span(name="reasoner.tick", *, tick_idx=None, model=None, force=None, **attrs) -> Span — Non-attaching variant for the phased tick (#21): returns a started span without attaching it to the calling thread's context, so prepare_tick → off-thread run_prepared_llmfinish_tick can carry it between phases (each re-attaching via opentelemetry.trace.use_span) and intermediate executor callbacks never see it as current. Caller owns span.end(). Used by openral_reasoner.core to record reasoner.{tool, rskill_id, suppressed_reason, error_kind} over the LLM call. (L259)
  • traced(name=None) — Decorator that wraps a sync function in a span named after it. (L287)

python/observability/src/openral_observability/cli.py

Root-span helper for the openral CLI.

  • cli_command_span(subcommand, *, mode=None, run_id=None, **attrs) — Open the cli.command root span for one CLI invocation; records cli.subcommand, openral.run.id, optional openral.run.mode / openral.run.git_sha. (L52)

python/observability/src/openral_observability/diagnostics.py

diagnostic_msgs/DiagnosticArray heartbeat helper, shared by every OpenRAL lifecycle node.

  • Level — Mirror of diagnostic_msgs/DiagnosticStatus level constants (OK=0, WARN=1, ERROR=2, STALE=3); re-exported so status_fn callbacks can avoid importing diagnostic_msgs on pure-Python hosts. (L32)
  • DiagnosticsHeartbeat(node, *, hardware_id, component_name, status_fn, rate_hz=1.0) — 1 Hz /diagnostics publisher attached to a rclpy.lifecycle.LifecycleNode. Drives the standard create_publisher (in on_configure) / start (in on_activate) / stop (in on_deactivate) / destroy (in on_cleanup) sequence; publish_once() exposes a deterministic publication for tests; an exception inside status_fn is converted to a synthetic ERROR-level diagnostic so the timer never crashes the node. (L49)

python/observability/src/openral_observability/lifecycle.py

Make LifecycleNode transition-callback failures observable — rclpy's __execute_callback swallows callback exceptions into TransitionCallbackReturn.ERROR without logging (literal # TODO(ivanpauno): log sth here), so a composing host reports only exit code 4.

  • log_lifecycle_errors(callback) -> callback — Decorator for on_configure / on_activate / … transition callbacks. Transparent on success; on an uncaught exception it logs the callback name + full traceback via node.get_logger().error(...) (→ /rosout → launch console) and returns TransitionCallbackReturn.FAILURE instead of letting the exception escape into rclpy's silent ERROR conversion. Applied to the on_configure/on_activate of RskillRunnerNode, _WorldStateLifecycleNode, HALLifecycleNodeBase (covers every per-robot HAL), and ReasonerNode. Imports rclpy lazily so the module stays import-safe on pure-Python hosts. (L43)

python/observability/src/openral_observability/semconv.py

Single source of truth for OpenRAL OTel attribute / span / metric names.

Final[str] constants for: the legacy rskill.* / skill.* / inference.* / safety.* attribute prefixes (shipped today, including INFERENCE_DURATION_MS = "inference.duration_ms" for VLA select_action elapsed time); the greenfield openral.run.* / openral.tick.* / openral.skill.* / openral.hal.* / openral.sensors.* / openral.world_state.* / openral.dataset.* namespaces; the reasoner.* attrs (incl. REASONER_LLM_S / REASONER_PROMPT_TOKENS — the provider-time vs reasoner-overhead split of a tick's elapsed_s, #92); span names (SPAN_*, incl. SPAN_WORLD_SCENE_OBJECTS = "world.scene_objects" and SPAN_REWARD_SCORE = "reward.score" with its reward.* attrs — REWARD_PROGRESS / REWARD_SUCCESS / REWARD_STALLED / REWARD_SUCCEEDED / REWARD_FRAMES / REWARD_TASK — added to drive the dashboard rSkill card's reward bar, plus REWARD_CAMERA — the camera the reward monitor attends to, from reward_monitor_node._camera_label, rendered on the same card); the openral.world_state.scene_objects.* dashboard attrs (WORLD_SCENE_OBJECTS_LIST / _COUNT / _FRAME / _SOURCE_NODE); span-event names (EVENT_*, incl. EVENT_EPISODE_CLOSED and EVENT_SKILL_FAILURE + its SKILL_FAILURE_STATE attr, added to drive the dashboard "skill failures" counter); metric instrument names (METRIC_*); closed-set metric label keys (LABEL_*); and enum values for openral.run.mode / openral.safety.kernel. Also adds DATASET_EPISODE_SUCCESS to the openral.dataset.* namespace; the placeholder DATASET_REPO_ID / DATASET_EPISODE_IDX / DATASET_FRAME_IDX constants (L143–145) are now written by openral_dataset.RolloutRecorder.

python/observability/src/openral_observability/metrics.py

Cached OTel meter instruments — safe to call before configure_observability.

  • get_meter() -> Meter — Resolve the OpenRAL meter against the current MeterProvider. (L64)
  • get_tick_duration() -> Histogramopenral.tick.duration, unit ms. (L99)
  • get_inference_duration() -> Histogramopenral.inference.duration, unit ms. (L114)
  • get_hal_read_state_duration() -> Histogramopenral.hal.read_state.duration, unit ms. (L126)
  • get_hal_send_action_duration() -> Histogramopenral.hal.send_action.duration, unit ms. (L138)
  • get_sensors_age_ms() -> Histogramopenral.sensors.age_ms, unit ms. (L150)
  • get_world_state_staleness_ms() -> Histogramopenral.world_state.staleness_ms, unit ms. (L162)
  • get_tick_budget_violations() -> Counteropenral.tick.budget_violations. (L177)
  • get_tick_deadline_misses() -> Counteropenral.tick.deadline_misses. (L188)
  • get_safety_violations() -> Counteropenral.safety.violations, labels check_name / severity. (L199)
  • get_hal_estop_count() -> Counteropenral.hal.estop.count. (L213)
  • get_sensors_stale_reads() -> Counteropenral.sensors.stale_reads. (L234)
  • get_observability_export_failures() -> Counteropenral.observability.export_failures, label signal_kind. (L267)
  • get_world_state_components_stale() -> UpDownCounteropenral.world_state.components_stale. (L292)
  • record_histogram_ms(instrument, value_ms, attributes=None) -> None — Record a millisecond value, skipping negatives and NaN. (L381)

python/observability/src/openral_observability/producer.py

Producer-side helpers for recording rich span attributes on OpenRAL hot-path spans. Safe to call on no-op spans; lists are truncated to _MAX_JOINTS / _MAX_EE_FRAMES and floats rounded to 3 decimals.

  • record_joint_state(span, *, names, positions, velocities=None, efforts=None, position_limits=None, velocity_limits=None, effort_limits=None, stamp_ns=None) -> None — Attach per-joint attributes to a hal.read_state span. (L103)
  • record_action(span, *, next_row, dim=None, horizon=None, applied=None, gripper_position=None, gripper_force_n=None) -> None — Attach commanded-action attributes to a hal.send_action span. (L151)
  • record_ee_poses(span, ee_poses) -> None — Flatten a name → Pose6D mapping onto a world_state.snapshot span. (L182)
  • record_sensor_frame_attrs(span, *, modality=None, encoding=None, width=None, height=None, channels=None, age_ms=None, thumbnail_bytes=None, thumbnail_already_encoded_b64=False) -> None — Attach sensor-frame attributes to a sensors.read_latest span. (L208)
  • emit_sensor_frame_span(frame, *, sensor_name, age_ms, flip_180=False, tracer_name=…) -> None — THE shared producer of a dashboard sensors.read_latest span for one camera frame: optional OPENRAL_DASHBOARD_FLIP_180 rotation of a display copy (3-channel frames with matching buffer length only; the caller's frame — the policy input — is never mutated), then span + record_sensor_frame_attrs + encode_frame_thumbnail. Both emitters route through it (openral_rskill_ros.sensor_leg._emit_frame_observability for pump-fed cameras, WorldState _on_image for tee-fed), replacing two hand-mirrored copies that had already drifted. tracer_name keeps the span attributed to the emitting subsystem. (L252)
  • encode_rgb_thumbnail(rgb) -> bytes | None — Encode an HWC uint8 RGB ndarray to a small JPEG for OTLP; returns None if Pillow is unavailable. (L315)
  • encode_frame_thumbnail(frame) -> bytes | None — Encode an openral_core.SensorFrame (RGB8/BGR8/MONO8/JPEG/PNG) as a small JPEG thumbnail; returns None for non-renderable encodings. (L340)
  • modality_for_encoding(encoding) -> str — Map a FrameEncoding (or its string value) to the dashboard's modality label (rgb / mono / depth / raw / unknown). Reused by DeployRunner._tick_impl and world_state_ros/lifecycle_node._on_image so both surfaces produce identical modality labels for the same encoding. (L57)
  • _MODALITY_BY_ENCODING: dict[str, str] (L45) — Canonical encoding → modality lookup table.

python/observability/src/openral_observability/system_metrics.py

Background sampler for the openral.system.* gauges; feeds the dashboard's System Health card via psutil (CPU + RAM) and optional pynvml (GPU memory + util).

  • start_system_metrics_collector(*, interval_s=1.0) -> bool — Start a daemon thread that samples host metrics every interval_s seconds. Returns False and a quiet no-op when neither psutil nor pynvml is importable. Idempotent; re-starts retune the interval. (L52)
  • stop_system_metrics_collector(*, timeout_s=2.0) -> None — Signal the collector thread to stop and join. Safe to call when not running. (L82)
  • _nvml_query(read, what, gpu_index) -> Any | None — Run one NVML read, returning None when the device does not implement it. Each GPU metric is queried independently so an unsupported call cannot cost the whole tick: on a unified-memory SoC (GB10 / DGX Spark, Thor) nvmlDeviceGetMemoryInfo raises NVMLError_NotSupported — no discrete VRAM pool — while nvmlDeviceGetUtilizationRates works, and previously the former propagated out of _sample_once and dropped utilisation too, logging a traceback per interval. Degradation is logged once per (device, query) via _UNSUPPORTED_LOGGED, since it is a permanent hardware property. Metrics-path counterpart to the unified-memory handling in openral_detect.probes.gpu. (L147)

python/observability/src/openral_observability/propagation.py

W3C TraceContext inject / extract for cross-process trace correlation.

  • current_traceparent() -> str | None — W3C traceparent value for the active span, or None outside a span. (L53)
  • inject_traceparent(carrier=None) -> dict[str, str] — Write the active span's traceparent (and optional tracestate) into a carrier dict; used by producers of ActionChunk.msg / ExecuteRskill.action / FailureTrigger.msg. (L68)
  • extract_traceparent(traceparent, tracestate=None) -> Context — Parse a wire-side traceparent into an OTel Context for context.attach / trace.use_span; consumed by the C++ safety kernel and any Python ROS consumer. (L98)
  • traceparent_env(carrier=None) -> dict[str, str] — Env-var carrier (OTEL_TRACEPARENT + optional OTEL_TRACESTATE) for the active span, built from inject_traceparent; pass as env= to subprocess / multiprocessing so a worker joins the parent trace. {} when no valid span is in scope. (L134)
  • attach_traceparent_from_env(env=None) -> object | None — Worker-side counterpart: read OTEL_TRACEPARENT / OTEL_TRACESTATE from env (default os.environ) and context.attach the parent context; returns the detach token, or None when absent/empty. (L180)
  • remote_parent_from_env(env=None) [@contextmanager] — Scope attach_traceparent_from_env for a worker main(): attaches on enter, detaches on exit; yields the detach token (or None when no carrier present). (L227)

python/observability/src/openral_observability/failure_bus.py

Publisher helper + IDL-mirror constants for the namespaced /openral/failure/{...} bus.

  • class FailureSource(str, Enum) (L118) — HAL | SENSOR | SKILL | SAFETY | WAM | CRITIC; the string value is the topic suffix.
  • topic_for(source: FailureSource) -> str (L133) — Pure helper: FailureSource → /openral/failure/<suffix>.
  • KIND_* / SEVERITY_* int module constants (L94–L109) — Mirror openral_msgs/msg/FailureTrigger; bump both when the IDL changes.
  • DEFAULT_RATE_LIMIT_HZ: dict[int, float | None] (L150) — Per-severity defaults (INFO/WARN → 10/s, FAIL/ABORT → unlimited). DEFAULT_SUMMARY_PERIOD_S = 1.0 (L154).
  • class _TokenBucket (L164) — Private, lock-protected. __init__(rate_hz, *, capacity=1.0, clock=time.monotonic); try_consume() -> bool.
  • class FailureBusPublisher (L213) — __init__(node, source, *, rate_limit_hz=None, summary_period_s=1.0, clock=None). Methods: create_publisher() (opens RELIABLE+VOLATILE+KL=50 publisher on topic_for(source)), start() (boots 1 Hz suppressed-summary timer), stop(), destroy(), publish(*, kind, severity, evidence, rskill_id='', trace_id=None) -> bool (False when rate-limited). Properties: topic, source.

python/observability/src/openral_observability/logging.py

  • trace_context_processor(_logger, _method_name, event_dict) — structlog processor that stamps trace_id / span_id on every log event. (L63)
  • resolve_log_level() -> int — Resolve the OpenRAL log floor from OPENRAL_LOG_LEVEL (level name, case-insensitive and whitespace-tolerant, or an integer). Defaults to INFO, not DEBUG. Unparseable values fall back to the default rather than raising — a typo in an env var must not take down a deploy. Every record clearing this floor is JSON-rendered and shipped as an OTLP log record; below it the stdlib level check short-circuits before either happens, which matters because the deploy graph has ~73 DEBUG call sites and several fire per control tick (world_state.*.updated ×7 in the aggregator, skill.step, safety.null_check) on the same GIL the camera readers and the VLA weight load contend for. Governs log records only — dashboard span rows are banded separately by dashboard.store._is_headline_span, so the Event Log's DEBUG chip still shows the per-tick span stream at the default floor.
  • install_structlog_bridge(logger_provider) — Wire the structlog processor chain to forward records to the OTel LoggerProvider, with both the bridge logger and the openral root logger set to resolve_log_level(). (L107)

python/observability/src/openral_observability/dashboard/store.py

In-memory aggregator for openral dashboard — feeds the SSE stream and the /api/state JSON endpoint. Thread-safe, bounded (200 events, 600 metric samples per series) (issue #44). Span families registered in _HEADLINE_FAMILIES (L772+): rskill.execute, rskill.tick, rskill.activate, rskill.configure, skill.chunk_inference, safety.check, hal.send_action, hal.read_state, sensors.read_latest, world_state.snapshot, slam.occupancy_grid (SLAM map card), reasoner.tick (last LLM tool decision, rendered in the Reasoner card added alongside the navigate-look-pick demo), reward.score (latest reward-monitor assessment → reward_score card, rendered as the rSkill card's colour-banded progress/success bar), sim.run, sim.step, cli.command. Each populates one slot in self._topics: dict[str, dict[str, Any]] (L266+).

Event-log severity band (distinct from _HEADLINE_FAMILIES, which routes spans to cards). _is_headline_span(name) decides the band of a span's Event Log row: ERROR status → error; else name in _HEADLINE_SPANS or name.startswith(_HEADLINE_SPAN_PREFIXES)info; else → debug. _HEADLINE_SPANS is cli.command, rskill.execute, rskill.configure, rskill.activate, reasoner.tick, world.scene_objects, sim.run, plus the detect.probe. prefix. This is an allow-list on purpose: it replaced a deny-list (_PER_TICK_SPANS) that named only hal.read_state / hal.send_action / sensors.read_latest and missed four more families ticking at the same 30 Hz rate — world_state.snapshot, rskill.tick, safety.check (one per candidate chunk, from three emitters including the C++ kernel) and rskill.chunk_inference. Measured on one second of a real 30 Hz two-camera deploy: 121 info rows/s, cycling the 200-slot ring every 1.65 s; inverted, routine traffic contributes 0. A deny-list makes "noisy" the thing you must remember to declare, and that memory failed four times — so a new span is now quiet until deliberately promoted here. Unaffected: ERROR spans still escalate, and every span is still indexed in full for openral replay. This changes the event-log band only.

  • class TelemetryEvent — Frozen dataclass holding one event log row (ts_unix, kind, title, attrs, severity). .to_json() returns a plain dict. (L240)
  • class TelemetryStore — Read-side aggregator over OTLP signals. (L392)
  • ingest_spans(payload: list[ResourceSpans]) -> int — Decode + record spans; populates headline cards, increments span-event counters, publishes a delta to every subscriber queue. Returns the number of spans recorded. Routes by span name into per-topic buckets, incl. world.scene_objectstopics["scene_objects"] (durable spatial-memory objects for the scene-objects card + SLAM-map overlay; the world_state.scene_objects.list JSON attr is decoded via _parse_object_list). (L474)
  • ingest_metrics(payload: list[ResourceMetrics]) -> int — Decode + record metric data points; appends per-series samples and tracks cumulative sums. (L508)
  • ingest_logs(payload: list[ResourceLogs]) -> int — Decode + record OTLP ResourceLogs (the structlog→OTel bridge) as event-log rows (issue #318): body → title, instrumentation scope (logger) name → kind, severity_numberdebug/info/warn/error/fatal via _log_level. Records share the bounded event ring with spans/span-events; the UI defaults the Debug chip off so high-rate DEBUG stays opt-in. Returns the number of log records recorded.
  • snapshot() -> dict[str, Any] — One-shot view: service identity, headline cards, event ring, counters, metric series with p50/p95. (L568)
  • subscribe() -> asyncio.Queue — Register an SSE subscriber. The queue is bounded; on overflow the oldest payload is dropped so the producer never blocks. (L591)
  • unsubscribe(queue) -> None — Drop a subscriber's queue. (L607)

python/observability/src/openral_observability/dashboard/discovery.py

mDNS advertise + browse for the live dashboard (issue #75b). Optional — requires the mdns extra (zeroconf>=0.131, LGPL-2.1, TSC-approved 2026-06-21). When zeroconf is not importable, Discovery stays disabled and the dashboard runs exactly as before.

  • module constant SERVICE_TYPE: str = "_openral-otlp._tcp.local." — mDNS service type for all OpenRAL dashboard OTLP receivers. Single source of truth for the advertiser and browser. (L30)
  • class DiscoveredRobot(BaseModel) — One mDNS-discovered OpenRAL service; the /api/robots wire shape used by external operator tooling. Fields: name: str, addresses: list[str], port: int, properties: dict[str, str] = {}, last_seen: float. (L37)
  • class RobotRegistry — Thread-safe map of discovered robots (zeroconf callbacks run off-thread). Methods: upsert(robot: DiscoveredRobot) -> None, remove(name: str) -> None, list_robots() -> list[DiscoveredRobot] (sorted by name, snapshot). (L47)
  • class Discovery — Owns the Zeroconf instance, advertiser, and browser for the dashboard. Attribute enabled: bool; method robots() -> list[DiscoveredRobot] (delegates to registry); start(*, host: str, port: int) -> None (browse always; advertise only on a non-loopback, non-wildcard bind — loopback/wildcard binds are browse-only); stop() -> None (unregister, cancel browser, close Zeroconf). Wired into run_dashboard in server.py; app.state.discovery holds the instance (or None when the mdns extra is absent or zeroconf failed). (L83)

python/observability/src/openral_observability/dashboard/app.py

  • create_app(store: TelemetryStore | None = None) -> FastAPI — Build the dashboard ASGI app. Routes: /, /static/*, /healthz, /api/state, /api/stream (SSE), the OTLP/HTTP receivers POST /v1/traces, POST /v1/metrics, POST /v1/logs (logs now feed the event log via TelemetryStore.ingest_logs — issue #318), and the operator write endpoints POST /api/prompt (shells out to openral prompt --topic /openral/prompt_in/dashboard), POST /api/estop_reset. Also: GET /api/camera/{source}/stream (MJPEG multipart live camera video re-serving OTLP thumbnails — issue #75a); GET /api/robots (mDNS-discovered OpenRAL services for external discovery tooling — issue #75b, read-only, returns {"enabled": false, "robots": []} when discovery is absent); POST /api/skill/execute and POST /api/param/set (guarded write-controls, default OFF — issue #75c; return 403 unless OPENRAL_DASHBOARD_WRITE_CONTROLS=1; skill/execute returns 202 on action-server acceptance with async background result logging; param/set also refuses safety-relevant param names via _SAFETY_PARAM_DENYLIST). GET /api/config now returns {"jaeger_ui_url": "...", "write_controls_enabled": bool, "voice_prompt_enabled": bool} — the last flag is vad_assets.vad_assets_available() (below), read by dashboard.js to grey out the mic button instead of failing on click. Honours gzip-encoded request bodies. (L780)

python/observability/src/openral_observability/dashboard/vad_assets.py

  • class PinnedAsset(NamedTuple)url: str, sha256: str, size: int for one pinned binary asset.
  • PINNED_VAD_ASSETS: dict[str, PinnedAsset] — The three voice-prompt binaries no longer committed to git (ort-wasm-simd-threaded.wasm, silero_vad_v5.onnx, silero_vad_legacy.onnx), pinned to the exact onnxruntime-web 1.22.0 / @ricky0123/vad-web 0.0.29 jsDelivr URLs + sha256 recorded in static/vendor/vad/NOTICE.md.
  • ensure_vad_assets() -> bool — Best-effort: for each pinned asset, reuse a sha256-verified hit in $OPENRAL_CACHE_DIR/dashboard_assets/vad/ (default ~/.cache/openral/…) or download + verify one, then hard-link/copy it into the served static/vendor/vad/ dir. Never raises; a failed asset is a structlog warning (dashboard.vad_asset_unavailable), never a silent skip, and does not stop the others. Returns True iff every asset ended up served. Called best-effort from run_dashboard on every dashboard start (never gates startup).
  • vad_assets_available() -> bool — Cheap presence-only check (no re-hash) of whether every pinned asset is currently served; backs /api/config's voice_prompt_enabled.

python/observability/src/openral_observability/dashboard/server.py

  • run_dashboard(*, host="127.0.0.1", port=4318, inprocess_cmd=None, store=None, log_level="warning") -> None — Start uvicorn on host:port and block until SIGINT/SIGTERM. Calls vad_assets.ensure_vad_assets() best-effort before binding (never gates startup — see above). Prints a single OpenRAL dashboard: http://host:port/ banner to stderr before binding (issue #132) so the user always sees the URL. When inprocess_cmd is set, spawns the argv as a child process with OTEL_EXPORTER_OTLP_ENDPOINT + OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf pointed at the dashboard. Default port is 4318 (OTLP/HTTP standard) instead of the historic 8000 to avoid clashing with mkdocs serve / python -m http.server. (L56)
  • spawn_dashboard(*, host="127.0.0.1", port=4318, ready_timeout_s=10.0) -> Iterator[str | None] [@contextmanager] — Inverse of --inprocess: spawn openral dashboard as a child of the current process, poll /healthz until ready, set OTEL_EXPORTER_OTLP_{ENDPOINT,PROTOCOL}, yield the URL, and SIGINT the child on exit. Yields None (workload continues unattached) if openral is not on PATH, the child died early, or /healthz never came back within the timeout. (L36, in openral_observability/dashboard/attach.py)
  • attached_dashboard(*, enabled, port=4318) -> Iterator[bool] [@contextmanager] — High-level wrapper used by openral sim run --dashboard, openral deploy run --dashboard, and openral benchmark run --dashboard. When enabled=False, yields False immediately (true no-op, no FastAPI/uvicorn imports). When enabled=True, delegates to spawn_dashboard, re-runs configure_observability on the new endpoint, and drains via shutdown_observability in finally so the last batch lands before the child is SIGINT'd. Yields True iff the child reported healthy.

python/observability/src/openral_observability/dashboard/store.py — F7 trace index additions

Bounded per-trace_id span index for query-time bag↔OTel join.

  • class _IndexedSpan (L278) — Frozen-ish record retained by trace_id: name, trace_id, span_id, parent_span_id, start_ns, end_ns, attrs, status_code, status_message, events. .to_json() returns a plain dict carrying duration_ms.
  • TelemetryStore.list_traces() -> list[dict] — One row per indexed trace_id (trace_id, span_count, last_seen_unix), most-recent first. Backs GET /api/traces.
  • TelemetryStore.lookup_trace(trace_id: str) -> list[dict] | None — Every indexed span for trace_id, sorted ascending by start_unix_ns. None when the trace is not (or no longer) in the bounded index. Backs GET /api/spans/{trace_id}.
  • _TRACE_INDEX_MAX_TRACES = 64 / _TRACE_INDEX_MAX_SPANS = 2048 — Memory caps. Older trace_ids evict FIFO on insertion.

python/observability/src/openral_observability/dashboard/app.py — F7 routes

  • GET /api/traces — JSON {"traces": [...]} from TelemetryStore.list_traces.
  • GET /api/spans/{trace_id} — JSON {"trace_id", "spans": [...]} from TelemetryStore.lookup_trace; 404 when the trace is not indexed.
  • GET /api/config — JSON {"jaeger_ui_url": "..."} sourced from the OPENRAL_JAEGER_UI_URL env (trailing slash stripped, default ""). The dashboard UI fetches this on load to decide whether to enable the footer "open in jaeger" link — leaving the env unset keeps the link disabled with a helpful tooltip instead of producing a broken-link click against a guessed localhost:16686.

python/observability/src/openral_observability/tracing_lttng.py

Opt-in LTTng tracepoints around the realtime hot path. No-op when OPENRAL_ROS2_TRACING is unset; falls back to JSONL when lttngust is missing.

  • ENV_TRACING_GATE = "OPENRAL_ROS2_TRACING" — Truthy values (1/true/yes/on) enable the backend; anything else leaves every tracepoint a no-op.
  • ENV_TRACING_FALLBACK_DIR = "OPENRAL_ROS2_TRACING_FALLBACK_DIR" — Override for the JSONL fallback directory (default /tmp/openral-lttng-fallback).
  • TP_RUNNER_TICK, TP_HAL_READ_STATE, TP_HAL_SEND_ACTION, TP_SENSORS_READ_LATEST, TP_WORLD_STATE_SNAPSHOT, TP_SKILL_STEP, TP_ACTION_PUBLISH, TP_SAFETY_VALIDATE — Tracepoint base names; lttng_tracepoint appends _begin / _end suffixes.
  • is_enabled() -> bool (L112) — Single source of truth for the gate.
  • lttng_tracepoint(name, **attrs) -> Iterator[None] (L175) — Context manager that fires <name>_begin / <name>_end around the block. Attaches the active OTel trace_id as otel_trace_id so CTF traces can join back to OTel.
  • class LttngSession(name, output_dir) (L93) — Identity of an active session.
  • class LttngSessionError(RuntimeError) (L88) — Raised by the subprocess wrappers.
  • start_session(*, name, output_dir) -> LttngSession (L321) — lttng create / enable-event openral:* / add-context / start.
  • stop_session(*, name) -> None (L346) — lttng stop + destroy (flush + teardown).
  • view_session(*, output_dir) -> None (L358) — babeltrace2 OUTPUT_DIR; falls back to listing files when babeltrace2 is absent.

python/dataset/src/openral_dataset/recorder.py

In-memory per-rollout accumulator with multi-sink fan-out.

  • @dataclass class EpisodeHeader(episode_idx, task_string, fps, robot_name, stamp_ns) — Per-episode metadata pushed to sinks at episode_start. (L58)
  • @dataclass class DatasetFrame(episode_idx, frame_idx, observation_state, images, action, reward, terminated, truncated, stamp_ns, trace_id="", span_id="") — Per-tick frame pushed to sinks at record_frame. trace_id (32 hex) / span_id (16 hex) carry the producing rskill.tick span's ids (ISSUE-109 forward link); "" when no valid span was in scope. (L81)
  • @dataclass class EpisodeSummary(episode_idx, success, n_frames, stamp_ns) — Per-episode close-out pushed to sinks at episode_end. (L122)
  • class DatasetSink(Protocol) — Fan-out target with open_episode / write_frame / close_episode / finalize. (L140)
  • class RolloutRecorder(*, robot, task_string, fps, sinks, repo_id=None) — In-memory accumulator that fans every step out to one or more DatasetSink implementations and writes the OTel openral.dataset.repo_id / episode_idx / frame_idx attributes on the active rskill.tick span. (L167)
  • episode_start(*, task_string=None) -> int — Open a new episode; returns its idx. (L296)
  • record_frame(*, observation_state, images, action, reward, terminated, truncated, stamp_ns, trace_id=None, span_id=None) -> int — Append one frame. Captures the active rskill.tick span's (trace_id, span_id) onto the frame (ISSUE-109); explicit trace_id/span_id override the live capture (the offline converter replays the bag's original ids). (L341)
  • episode_end(*, success: bool) -> EpisodeSummary — Close the current episode. (L454)
  • finalize() -> None — Flush all sinks idempotently. (L487)
  • prop fps, robot_name, repo_id, n_sinks, expected_state_shape — Read-only views consumed by callers building the per-frame payload. (L222)
  • expected_image_keys() -> tuple[str, ...] — Camera keys (without observation.images. prefix) the sinks expect; derived from RobotDescription.sensors[*].vla_feature_key. (L256)

python/dataset/src/openral_dataset/schema_map.py

Pure RobotDescription → LeRobot v3 features dict mapping; no I/O, no lerobot import.

  • @dataclass class FeatureSpec(key, dtype, shape) — Decoupled feature descriptor; sinks translate to lerobot's {'dtype', 'shape', 'names'} format. (L45)
  • features_from_robot(robot: RobotDescription, *, fps: float) -> dict[str, FeatureSpec] — Build the LeRobot v3 features dict for the recorder. Reads ObservationSpec.state_shape, ActionSpec.dim, and SensorSpec.vla_feature_key (image modalities only) from the robot manifest. (L62)

python/dataset/src/openral_dataset/bag.py

Mcap-backed :class:DatasetSink for online hardware recording.

  • Rosbag2Sink(*, bag_path, compression="zstd") — Writes every RolloutRecorder event into an mcap file readable by ros2 bag info / Foxglove / mcap-cli. Daemon writer thread + bounded queue.Queuewrite_frame enqueues only; hot path never blocks on disk I/O. JSON-schema encoding (interoperable with ROS 2's ros2msg encoding for the same topics). Topics: /openral/tick (per-tick metadata plus inline observation_state + action arrays), /openral/episode (PHASE_START / PHASE_END markers), /openral/dataset/image (one base64 raw-u8 frame per camera per tick). The inline arrays + image frames make the bag self-sufficient for conversion — no separate /joint_states / camera-topic join needed. (L189)
  • open_episode(header) -> None — Open the bag on first call; emit Episode(PHASE_START). (L290)
  • write_frame(frame) -> None — Enqueue a Tick message (incl. inline observation_state/action + the frame's trace_id/span_id, ISSUE-109) and one DatasetImage message per camera; never blocks. The off-thread mcap write reads the ids off the frame because the OTel context is gone by then. (L304)
  • close_episode(summary) -> None — Emit Episode(PHASE_END) with success flag. (L347)
  • finalize() -> None — Drain queue, stop writer thread, close mcap. Idempotent. (L357)
  • prop bag_path, n_ticks_written, n_episode_markers_written, n_images_written, n_dropped — Diagnostics. (L264)
  • TOPIC_TICK, TOPIC_EPISODE, TOPIC_IMAGE, PHASE_START, PHASE_END — Module-private constants the converter imports by symbol. (L66, L67, L73, L117, L118)

python/dataset/src/openral_dataset/converter.py

Offline mcap rosbag2 → LeRobotDataset v3 converter.

  • @dataclass class DatasetSummary(output_root, n_episodes, n_frames, n_success, repo_id) — Returned by from_bag describing what landed on disk. (L69)
  • Rosbag2ToLeRobotConverter.from_bag(*, bag_path, robot, output_root, repo_id=None, license="CC-BY-4.0", fps=None) -> DatasetSummary — Walk a Rosbag2Sink-produced mcap, group Ticks under PHASE_START / PHASE_END markers, join each tick's inline observation_state/action arrays + the per-(episode_idx, step_idx) camera frames from /openral/dataset/image, and replay each episode through a real LeRobotDatasetSink → produce a reloadable v3 dataset with real proprio/action/video (legacy metadata-only bags fall back to zero vectors of the declared shape). Each replayed tick re-injects the bag's original (trace_id, span_id) so the on-disk frame points at the source rollout, not the convert run (ISSUE-109). Raises ROSConfigError on missing bag / missing episode markers / mismatched robot. (L118)

python/dataset/src/openral_dataset/frame_trace.py

ISSUE-109 — pivot a written LeRobotDataset frame back to its OTel ids.

  • read_frame_trace(*, root, episode_idx, frame_idx) -> tuple[str, str] — Return the (trace_id, span_id) stamped on a v3 frame. Reads the root/data/**/*.parquet correlation columns directly via pyarrow (no video decode), so it works without a torchcodec/ffmpeg backend. Raises ROSConfigError when the root has no parquet, the dataset predates the columns, or no (episode_idx, frame_idx) row matches. Backs openral replay --frame. (L28)

python/dataset/src/openral_dataset/sinks.py

LeRobotDataset v3.0 (codebase_version="3.0") writer; deferred LeRobotDataset.create so per-camera shapes come from the first frame.

  • class LeRobotDatasetSink(DatasetSink) — Implementation of DatasetSink writing LeRobot v3 datasets via real lerobot.datasets.LeRobotDataset.create / add_frame / save_episode / finalize. Lazy-imports lerobot at construction. (L93)
  • __init__(*, root, robot, fps, repo_id=None, license="CC-BY-4.0", vcodec="libsvtav1") — Raises ROSConfigError if lerobot ≥ 0.5.1 is not importable. (L129)
  • open_episode(header) -> None — Stash the task string for per-frame tagging. (L257)
  • write_frame(frame) -> None — Validates per-frame shapes against the declared features, then forwards to LeRobotDataset.add_frame. Adds the frame's trace_id/span_id as string parquet columns (ISSUE-109). (L276)
  • close_episode(summary) -> None — Calls LeRobotDataset.save_episode(parallel_encoding=True) and accumulates the per-dataset success counter. (L353)
  • finalize() -> None — Calls LeRobotDataset.finalize() then appends dataset_success_rate / license / repo_id and the dataset-level trace_ids / n_traces (distinct OTel traces, ISSUE-109) to meta/info.json["metadata"], and writes the per-episode episode_index → trace_id map to the meta/openral_traces.json sidecar. (L396)