Layer 5–8 — Reasoning, WAM, Safety, Observability
Part of the OpenRAL public-symbol inventory. Hand-curated;
(LNN)markers are refreshed bytools/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 thewaitno-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 toopenral_core.schemas(single source shared withopenral doctor); re-exported here unchanged because this module was their public home. - module constant
_ENDPOINT_PRESETS: dict[str, ReasonerEndpointPreset]— back-compat alias ofopenral_core.REASONER_ENDPOINT_PRESETS(with_EndpointPresetaliasingopenral_core.ReasonerEndpointPreset): named values accepted byOPENRAL_REASONER_ENDPOINT(anthropic/openrouter/gemini/xai/deepseek/huggingface/ollama/vllm). Each preset carriesurl,dialect,auth_required,timeout_sandtool_choice— the five properties the retiredPROVIDERenum knew per vendor. Both builder paths consult it: the uncurated path takes all five (soDIALECTis 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 byresolve_reasoner_system_prompt. render_robot_context_prompt(capabilities: RobotCapabilities | None, *, base_prompt=DEFAULT_SYSTEM_PROMPT) -> str(L322) — Option B: append a deterministic## THIS ROBOTbody-awareness block (embodiment tags, locomotion + navigate/no-navigate guidance, manipulation/sensing hardware, payload, control modes) to the system prompt.Nonereturnsbase_promptunchanged.resolve_reasoner_system_prompt(capabilities: RobotCapabilities | None, *, env=None) -> str(L436) — Compose the reasoner system prompt: base brief (OPENRAL_REASONER_SYSTEM_PROMPToverride if non-empty, elseDEFAULT_SYSTEM_PROMPT) + the## THIS ROBOTblock.envis injectable for tests. Called byReasonerNode.on_configure.class ToolUseClient(Protocol)(L483) — Attributemodel_id; optional (read viagetattr, not a Protocol member)last_prompt_tokens: int | None— prompt tokens of the most recentselect_toolcall, whichReasonerCore.run_prepared_llmrecords on the tick span; set by both shipped clients from provider usage (_prompt_tokenssumsprompt_tokens/input_tokens+ the two Anthropiccache_*_input_tokensfields, so the number is comparable across providers); methodselect_tool(*, context_text, palette, system_prompt=DEFAULT_SYSTEM_PROMPT) -> ReasonerToolCall; methoddescribe_image(*, image_jpeg: bytes, question: str) -> str. RaisesROSReasonerInvalidPlanon bad discriminator / palette mismatch,ROSPlanningErroron transport failure.class OpenAICompatibleToolUseClient—__init__(*, model_id, api_key=None, base_url=None, timeout_s=10.0, tool_choice="required", max_tokens=None).tool_choiceforces exactly one tool call per tick (the reasoner contract);"auto"is used for endpoints that reject"required"(the HF router), andselect_toolthen retries once with an explicit nudge if the model replies in prose.max_tokens(envOPENRAL_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_MODELresolvesopenral_core.REASONER_MODELS; the registry supplies dialect, served id, endpoint, auth, hosting, tool choice, token cap, and local fit.OPENRAL_REASONER_ENDPOINToverrides location;API_KEY,MAX_TOKENS, andTIMEOUT_Soverride the remaining runtime values.ENDPOINTaccepts a named endpoint from the module-private_ENDPOINT_PRESETS(anthropic/openrouter/gemini/xai/deepseek/huggingface/ollama/vllm) as well as a URL or themanagedsentinel; 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) andtool_choicequirk (autofor the HF router, which 400s onrequired), soDIALECTis needed only for a bare URL — nothing can classify one. An explicitDIALECTstill wins, for a preset behind a translating proxy. A raw uncurated model logsreasoner.model.uncurated. The provider-firstOPENRAL_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_urlcarriesOPENRAL_REASONER_ENDPOINT; the SDK client/HTTP pool is built once and reused.select_toolmarks 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, includingWaitTool; per-skill names use collision-resistantexecute_rskill__<slug>_<sha1-8>._tool_palette_to_openai_tools(palette) -> list[dict]— Convert the same surface to OpenAI function shape without leaking the Anthropic-onlyinput_schemakey._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-maxexecute_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 withnamestripped from bothpropertiesandrequired. Used to droprskill_idfrom per-skillExecuteRskillToolschemas. (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: str—http://127.0.0.1:8901/v1; the managed local endpoint (dedicated port so the sidecar never collides with a user-runvllm serveon :8000). (L75) - module constant
DEFAULT_COSMOS3_MODEL: str—nvidia/Cosmos3-Edge, the served id of the curatedcosmos3-edgeregistry 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(0disables 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— Locatetools/cosmos3_reasoner_sidecar.py(env override or repo-parent walk);ROSConfigErrorwhen theOPENRAL_COSMOS3_SIDECARoverride 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, orNone(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_serversoReasonerNode.on_configurecan 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 HALon_configure/ MuJoCo / camera first-frame gating had minutes it could have overlapped. Idempotent:_ensure_servershort-circuits on_server_readyand 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 matchingRSkillManifestfields at palette-build time.class ContinuousDetectorEntry(BaseModel)(L112) — Frozen coverage record for amode: continuousdetector — surfaced to the LLM as coverage (not a tool) so it can read world state for tracked objects and reservelocate_in_viewfor 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 amode: on_demandopen-vocab locator surfaced as a selectablelocate_in_viewoption (a prompt-able read-only tool, never an ExecuteRskill policy). Fields:rskill_id: str,alias: str(short selector the LLM passes asLocateInViewTool.detector),description: str(capability hint).detector_alias(rskill_name) -> str— Short LLM-/operator-facing detector id: strips theOpenRAL/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 thelocate_in_viewservice for a (possibly empty) selector: emptydetector→default; 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 fromskillsvia the_derive_execute_rskill_idsmodel-validator),sensor_ids: frozenset[str] = frozenset(),node_ids: frozenset[str] = frozenset(),continuous_detectors: tuple[ContinuousDetectorEntry, ...] = ()(mode: continuousdetectors for the active robot; coverage, not tools),spatial_memory_available: bool = False(gates the two read-onlyrecall_object/resolve_placequery tools; off unless the reasoner_node has a SpatialMemory backend wired),detector_available: bool = False(gateslocate_in_view),on_demand_detectors: tuple[OnDemandDetectorEntry, ...] = ()(selectable locator options forlocate_in_view),scene_query_available: bool = False(gatesquery_scene; independent ofdetector_available),memory_available: bool = False(gates the self-maintained-memory toolsmemory_write(write) +memory_search(read-only archival recall); off unless the reasoner_node has aMEMORY.mdwired viamemory_md_path). Cross-validator_check_skills_match_idsrejects callers that pass bothskillsandexecute_rskill_idswith 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— dropsrskills/template/, which therskills/*/rskill.yamlglob otherwise admits as arole: s1skill 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. Amode: continuousdetector is instead collected intocontinuous_detectors(coverage for the LLM, never an ExecuteRskill tool); amode: on_demanddetector is collected intoon_demand_detectors(selectablelocate_in_viewoptions viadetector_alias, never an ExecuteRskill tool). EmitsRSkillToolEntryrecords (manifestdescription/actions/objects/scenesmirrored in) in stable id-sorted order so the LLM tool schema is deterministic.spatial_memory_availableforwards the read-onlyrecall_object/resolve_placetools;detector_availableforwards the read-onlylocate_in_viewtool;scene_query_availableforwards the read-onlyquery_scenetool;memory_availableforwards thememory_write+memory_searchtools. All areToolPalettefields gated intool_useso the LLM only sees a tool when its dispatcher is wired;detector_availableandscene_query_availableare independent (localization vs scene-state reasoning). The reasoner_node dispatchesquery_scenevia_dispatch_query_scene→/openral/perception/query_sceneand re-prompts with the answer (frame_idscene_vlm).task_space_disagreement(manifest, description, hal_mode, legacy_ok) -> str | None— Phase 2 (warn-only). Pure (no ROS) shadow gate: buildsTaskSpace.from_action_contract(manifest.action_contract, description)and runstask_space_compatible(..., hal_mode), returning a warning string ONLY when the canonical gate disagrees with the caller'slegacy_okverdict, elseNone(andNonefor non-actuating skills with noaction_contract). Called by the reasoner deploy-palette filter (alongside_action_executable) andtools/rskill_publisher._validate_task_spaceto surface cross-layer mismatches (slot EE-name / joint-width) without changing the drop/publish decision. Phase 4 makestask_space_compatibleauthoritative.
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 byopenral_world_state.SpatialMemory.SpatialQueryTool: TypeAlias—RecallObjectTool | 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; catchesROSObjectNotInMemory→ "not in memory" message.refine_approach(Phase 4,ApproachRefiner— duck-typed like the querier so this L4 module never imports L2) is applied to everyrecall_objectmatch's approach viewpoint before rendering; aNonefrom the refiner marks the match BLOCKED. Thin wrapper overrun_spatial_query_detailedreturning only.text.class SpatialQueryOutcome(NamedTuple)—(text: str, found: bool).foundisTruewhenrecall_objectreturned ≥1 match (in memory, even if every approach is grid-BLOCKED) orresolve_placeresolved the reference;Falseon a miss. Drives the reasoner's recall→locate_in_viewescalation.run_spatial_query_detailed(call, querier, *, now_ns, from_node_id=None, refine_approach=None) -> SpatialQueryOutcome— same asrun_spatial_querybut 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 wiresrefine_approach_poseover its latched/mapsubscription.
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 tobudget.max_candidates;[]when nowhere to search (→ human-handoff). Semantic prioritization among candidates is the LLM's (priors).class SearchProgress— attempt counter against aSearchBudget: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 everydetectorre-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": truemetadata (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 | None—Noneon 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; matchesopenral_prompt_router.DEFAULT_SOURCESauto-cascade priority. Human sources stamp 100 ontometadata_jsonso they drain first. class FailureEventRecord(frozen dataclass, L48) — Failure-buffer entry; fieldssource, kind, severity, evidence_json, rskill_id, trace_id, stamp_ns.class PerceptionEventRecord(frozen dataclass, L62) — Perception-buffer entry; fieldskind, text, metadata_json, stamp_ns.class PromptRecord(frozen dataclass, L72) — Operator-prompt-buffer entry; fieldstext, metadata_json, stamp_ns, priority=DEFAULT_PROMPT_PRIORITY. Thepriorityfield is filled in byappend_promptfrommetadata_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 viaContextRenderer.set_robot_model; rendered as the## ROBOTsection.render_playbooks_block(entries: list[tuple[str, str]]) -> str— Decision 1 / Phase 3: renders the## PLAYBOOKSsystem-prompt block from(name—trigger, PLAYBOOK.md body)entries.reasoner_node._collect_playbooks_blockgathers installed, capability-matchedkind: playbookrSkills 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 throughexecute_rskill+ the safety kernel.class MemoryEntry/class MemoryStore(openral_reasoner.memory) — §3 / Phase 4b: the self-maintainedMEMORY.mdfile model (persistent semantic memory — preferences, lessons, home facts, object-location log, open tasks; complementary to the geometric scene graph).MemoryStore.from_markdown/to_markdownround-trip the human-editable file;to_context_block(cap=None)renders the## MEMORYsection — Phase 5: whencapis set and the store exceeds it, only the top-capentries by importance then recency (current overstale) render, with a "use memory_search to recall" footer (bounded always-on context);apply(op, section, content, importance, target, now)does an explicitadd/update/supersede/delete(Mem0 + Zep supersession —supersedemarks the priorstalebut 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_memoryloads it from thememory_md_pathparam intoContextRenderer.set_memory_blockat configure (read path) and loads the<MEMORY.md>.archive.jsonlrecall log + flipsToolPalette.memory_availableso the write/search tools are offered (Phase 4c). The## MEMORYblock is rendered via_render_memory_blockunder thememory_context_capparam (Phase 5; 0 = off). Writes flow through_dispatch_memory_write(apply → archive the displaced entry →consolidate()paging duplicates to the archive → persistMEMORY.md→ re-render → confirm); recall through_dispatch_memory_search(Phase 4c).class ExecutionEventRecord(frozen dataclass) — §2.2 execution-feedback buffer entry; fieldsrskill_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; fieldsprogress(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## REWARDsection viaContextRenderer.set_reward_state/_render_reward; fed byreasoner_nodefrom eachquery_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_planfeeds 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 fromreflect_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## ROBOTself-model; static config, does NOT bumpseq),set_memory_block(memory_block: str | None)(§3 — sets/clears the## MEMORYblock from the MEMORY.md store; does NOT bumpseq),set_mission(mission: MissionState | None)(§1 — sets/clears the active task queue rendered as## MISSIONbefore## WORLD_STATE; a new goal is an event so it DOES bumpseq),set_in_view(objects: ObjectsMetadata | None)(sets/clears the latest continuous-detector enumeration rendered as the camera-spacein_view[<camera>]line in## WORLD_STATE; bumpsseqonly 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")+ propertiesinflight_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 leadingin_flight:line in## EXECUTIONso the LLM never double-dispatches blind; state changes bumpseq; also read byReasonerCore'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; bumpsseqwhen non-empty),note_located(objects: ObjectsMetadata | None)(folds open-vocablocate_in_viewhits into a stickylocated[<camera>]line (keyed by lowercased label, latest-wins, capped at_LOCATED_CAP=12) that survives the continuous detector's per-frameset_in_viewclobber, so a goal noun the fixed indoor vocabulary mislabels — e.g.basket/ketchup— stays grounded for decompose/dispatch; bumpsseq;None/empty is a no-op),set_reward_state(reward: RewardStateRecord | None)(sets/clears the latest two-head reward assessment rendered as the## REWARDsection; a fresh assessment is an event so it DOES bumpseq),advance_mission(*, done: bool, verdict: str) -> TaskState | None(§1 —complete_active/abandon_activethe active task + activate the next, bumpsseq; returns the new active task orNonewhen the mission is finished; no-op when no mission set), propertymission -> MissionState | None(node mutates it in place for non-waking bookkeeping —record_attempt/mark_verifying),append_execution(§2.2 — success+failure outcomes into the## EXECUTIONsection, bumpsseqso feedback wakes an idle heartbeat),append_failure,clear_failures(drops stale failure/execution context after/openral/estop_clearedso 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 monotonicseqcounter),render(*, world_state) -> str,drain_prompts(*, seen: tuple[PromptRecord, ...] | None = None) -> tuple[PromptRecord, ...](pull-once, priority-desc + arrival-asc order; does NOT bumpseq;seendrains 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); propertiesfailures,perception_events,prompts,seq(mutation counter consumed byReasonerCoreto short-circuit a heartbeat tick when no event has arrived since the last successful tick — amendment 2026-05-25 §2). The## WORLD_STATEblock (_render_world_state) renders joint_state / ee_poses / battery / diagnostics and, since #14 (2026-06-12), ascene_objects[<frame>]: label@(x,y,z), …line fromWorldState.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_viewadds a camera-spacein_view[<camera>]: #<det_id> <label> @px(<cx>,<cy>), …line (sorted bydet_id) from the latestObjectsMetadataset viaset_in_view— a depth-free enumeration (pixel centres, explicitly image space, kept distinct from the 3Dscene_objectsline) 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 firstWorldStatesnapshot._render_in_viewalso emits a stickylocated[<camera>]: <label> @px(<cx>,<cy>), …line fromnote_locatedhits (the open-vocab locator's confirmed goal nouns) — distinct from the fixed-vocabin_viewline it clobbers — which is what breaks the deploy locate-loop (the continuous indoor detector mislabelsbasket/ketchup/milk, so without the sticky locate fold the LLM never grounded the goal nouns and looped onrecall_object/locate_in_viewinstead of decomposing/dispatching)._render_rewardadds a## REWARDsection (whenset_reward_statehas been called) carrying BOTH reward heads, distinctly labelled —progress=<v> (closeness, trend …)andsuccess=<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-levelpriorityfield out of a PromptStamped's metadata; returnsDEFAULT_PROMPT_PRIORITYon 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; fieldstool_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 activereasoner.tickspan —Nonewhen no realTracerProvideris installed)._call_identity(call) -> str— canonical retry-cap identity of a tool call: the full argument payload minusrationale(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) betweenprepare_tickandfinish_tick: the LLM inputs (context_text,palette,system_prompt) plus the prepare-time bookkeeping snapshots (started,seq,prompts, the open non-attached OTelspan,renderer,force,tier). Theseq/promptssnapshots are the mid-flight-event contract:finish_tickmarks seen / drains only what the model actually saw.llm_s+prompt_tokensare 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 blockingselect_toolround-trip; the ONLY phase safe off-thread — touches nothing but the client and theprepsnapshots; runs under the tick span via per-threaduse_span; times the call intoprep.llm_sin afinally— a timed-out call is the one worth timing — and picks up the client's optionallast_prompt_tokensintoprep.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-ROSPlanningErrorerroris recorded and re-raised); read-only propertiesretry_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 (allexecute_rskill) never trips it while a verbatim repeat still does. The read-only search tools pluswait(_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 bytest_active_search_cascade_is_bounded_and_hands_off);waitis byte-identical by construction and instructed during nominal in-flight supervision, so counting it fabricates a retry-cap failure mid-run. After aretry_capsuppression the core arms a pre-call hold (suppressed_reason="retry_cap_hold"): non-forced ticks whoserenderer.seqhas 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 bumpsseqand releases the hold. A terminal mission short-circuits non-forced ticks withsuppressed_reason="mission_finished"even when perception keeps changingrenderer.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): whenforce=False,renderer.seqmatches 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-runquery_task_progresspolling), the LLM call is suppressed withsuppressed_reason="heartbeat_idle". Palette-empty short-circuit prevents wasted LLM calls whenforce=False— aforce=Truetick (event preemption fromSEVERITY_FAILFailureTrigger,SEVERITY_WARNon/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 pickEmitPromptToolto escalate even on a bare reasoner. The retry-cap gate still applies underforce=True. Thetierkwarg ("A"/"B"/"C"/"D"/"heartbeat") is recorded verbatim on the span asreasoner.tierfor dashboard filtering — observability only; per-tier preemption thresholds live inReasonerNode._FAILURE_TIER_FOR_SOURCE. Wraps the per-tick work inreasoner_span(openral_observability) so the LLM call lives under areasoner.tickOTel span withreasoner.{model, tick.idx, tool, rskill_id, suppressed_reason, error_kind, force, tier, llm_s, prompt_tokens}attributes (§6);llm_s/prompt_tokensare stamped before the error and retry-cap branches (those ticks burned the round-trip too) and also ride thereasoner.tick.selectedstructured 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)(raisesValueErroronstall_patience < 1ormin_delta < 0). Methods:observe(score: float) -> CriticEvidence | None— fires oneCriticEvidence(critic_id, score, threshold)in two mutually exclusive cases: (a) success —score >= thresholdand the success latch is not set (one-shot per streak; latch clears when score next drops below threshold or onreset); (b) stall —stall_patienceconsecutive below-threshold, non-improving observations while the stall latch is not set (latch clears on progress, recovery, orreset). 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, mirroringReasonerCore.reset_kind_streak). Read-only propertiescritic_id,threshold,stall_patience,min_delta.class CriticWatchdogGroup(L272) — Multiplexer keying oneCriticWatchdogpercritic_idso multiple/future reward models (Robometer + SARM + …) share the/openral/failure/criticsource 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 percritic_id(bindingthresholdon 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 propertiesstall_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— predicthorizonsteps of future state; raisesROSConfigError(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 oneWorldModel.rolloutcall. 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— Returnshorizoncopies of the inputWorldState, no rewards, 0.0 ms latency, confidence 1.0. Attribute:max_horizon. (L27)__init__(max_horizon=16) -> None— RaisesValueErrorifmax_horizon <= 0. (L54)rollout(world_state, action_chunk, horizon) -> Rollout— Replays the input state. RaisesValueErrorforhorizon ∉ (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 returnsSUCCESS. (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 forros2 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 explicitDeployScene.safetyfields 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]— Flattencollision_geometry+allowed_collision_pairs+ the kinematic chain (jointorigin_xyz/rpy/axis) into the kernel's collision params, topologically ordered. Routes each link's primitive byshape: capsules/spheres →collision_capsule_link+ parallel radius/half-length/origin arrays;BoxShape→collision_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=Nonereadsrobot.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 throughcollision_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;-1when 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 compiledmujoco.MjModelto 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_indexassigned by movable-joint order (the i-th hinge/slide joint → manifest column i, capped atlen(joint_names); MJCF joint names are not consulted — they differ from the manifest, e.g.Rotationvsshoulder_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-frameorigin_xyz_rpy(kernel's rpy convention, inverse ofmjcf_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→exactSphereShape, mesh→trimeshvertices 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 runsacm_for_geometrywithout 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: explicitsrdf_path→robot.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_fkis lowered too (unlessacm_only).acm_only/geometry_onlyrestrict output so hand-tuned safety geometry isn't churned. Zero fitted links (unresolvable collision meshes) raisesROSConfigError— never an empty geometry/ACM the kernel would silently not check. Vendored-URDFrd:<module>:<relpath>mesh refs are expanded at load time via the pinnedrobot_descriptionsclone. RaisesValueErrorifrobot.urdf_pathis unset/unresolvable (arobot_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 bychild_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. bimanualopenarm. 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 viasim_joint_name. A manifestassets.srdfis 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-importsmujoco+openral_core.assets.resolve_asset(resolvesrobot.assets.mjcf, honouringmanifest_dirforfile: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'spackage://collision meshes don't resolve → 0 geometry). Replaces the naiveurdf if assets.urdf else mjcfguess that wrongly sentopenarmto the empty URDF path. RaisesROSConfigErrorwhen no lowerable asset.lower_robot_auto(robot, *, acm_only=False, geometry_only=False, manifest_dir=None) -> LoweredCollisionModel— Single dispatch overselect_lowering→lower_robot(srdf/sampling) orlower_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.LoweringSource—Literal["srdf", "sampling", "mjcf"]; the sourceselect_loweringresolves to (matchesLoweredCollisionModel.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]— Samplecountspheres evenly along the segmentp0→p1(endpoints inclusive forcount >= 2; midpoint forcount == 1).spheres_for_capsule(shape) -> int— Sphere count to tile a lowered capsule with centres ≤ one radius apart (ceil(L/r)+1);1for a sphere / zero-length capsule.link_collision_spheres(geom, *, count=None) -> list[CuMotionSphere]— Lower oneLinkCollisionGeometryto 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 cuRobocspace.joint_names.render_cumotion_config(robot, model) -> str— Render a cuRoborobot_cfgYAML 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(therskillsuffix replacedskillon 2026-05-25 — amendment §5) andmotion/objects/ocr/scene_change). (L282) - module constants
_KIND_TIMEOUT,_KIND_CONTROLLER,_SEVERITY_WARN,_SEVERITY_FAIL— IDL-mirror constants foropenral_msgs/FailureTrigger. Kept inline rather than importing theopenral_observability.failure_bushelper so the reasoner emits aFailureTriggerwithout 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 mswait_for_server/wait_for_serviceprobes 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_failureto stampreasoner.tieron the OTel span — observability only; the per-source preemption threshold (SEVERITY_WARNfor safety,SEVERITY_FAILfor everything else) is decided inline in the same callback. - (the former module-local
_SIM_EXECUTABLE_CONTROL_MODESfrozenset was removed 2026-06-04; thehal_mode == "sim"gate now imports the canonicalopenral_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. Readsaction_contractby specificity:None → set()(no action constraint);representationset →control_modes_for_representation(...);slotsset → each non-Noneslot'scontrol_mode; baredim(legacy) →{JOINT_POSITION}.def _action_executable(manifest: RSkillManifest, description: RobotDescription, hal_mode: str) -> bool(L459) — Pure helper.Truewhen every_required_control_modes(manifest)is in the executable set:openral_core.SIM_EXECUTABLE_CONTROL_MODESforhal_mode == "sim", elsedescription.capabilities.supported_control_modes(coerced toControlModeboth 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.Truewhen dispatchingcallends the active-search episode (so_dispatchresets_spatial_search+_locate_escalated);Falsefor the three search actionsRecallObjectTool/ResolvePlaceTool/LocateInViewTool. Guards the regression where a directly-emittedlocate_in_viewreset its own bounding budget — arecall→locate→recallloop against an undetectable object zeroed the counter every cycle and never handed off (observed live as 127 consecutive locate attempts onlibero_object). Paired with_on_locate_in_view_response, which now records one budget attempt onfound=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_memoryis an optional read-onlySpatialMemoryQuerierbackend (aSpatialMemory); when supplied the palette'sspatial_memory_availableis set (therecall_object/resolve_placetools are offered) and the rebuild path threads it through. Deployment wiring: thespatial_memory_pathROS parameter (default"") loads a persisted scene graph as that backend aton_configurewhen no backend was injected (see_maybe_load_spatial_memory); thespatial_memory_ingestROS parameter (defaultfalse) auto-creates an empty backend and folds eachWorldState.detected_objectssnapshot into it on tick (live dynamic memory from the producer). Decision 3b — the deploy memory bundle:sim_e2e.launch.pyforwardsmemory_md_path(loadsMEMORY.md+ enables the memory tools) and brings up a standalonenav2_map_serverfrom a savedmap.yamlwhen itsmap_patharg is set and SLAM is off (latches/map, which the reasoner consumes into its_occupancy_gridviaoccupancy_map_topic); with SLAM on,map_pathis ignored (SLAM owns/map). Thehal_modeROS parameter (default"sim") selects the action-mode palette gate (_action_executable) the skill-registry refresh applies.tick_hzis 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_changedrefresh path: withoutrobot_capabilitiesthe callback logs a warning and leaves the palette alone (an empty-capabilities refresh would risk dispatching incompatible skills)._submit_client_warmup(client) -> None— Aton_configure, kicks a managed LLM sidecar's boot onto_llm_poolinstead of leaving it to the first tick. No-op for clients without awarm()(every cloud provider). Off the executor thread soon_configurereturns promptly and the lifecycle transition is not held open by a model load. Non-fatal: the lazy path inselect_toolstill 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— BuildToolUseClientfrom 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/promptpublisher +/openral/failure/rskillpublisher +/openral/execute_rskillaction client. Reads thevram_lifecycle_peersROS parameter (default[]) into_vram_lifecycle_peers— GPU peers auto-deactivated before eachexecute_rskilland reactivated after (the deploy launch sets it toopenral_ros_image_detectorwhen--enable-object-detector). Also loads the full reward manifest (_reward_manifest, fromreward_manifest_path) and the GPU total (gpu_total_vram_gbparam, else a one-shotnvidia-smiprobe →_gpu_total_vram_gb) for the pre-dispatch VLA+reward pair fit check.on_activate— Arm the periodic tick timer attick_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 aFailureEventRecordto the renderer; preempt the next tick per the 2026-05-25 amendment trigger taxonomy — Tier A (source == "safety") preempts onseverity ≥ SEVERITY_WARN, Tier B/C (hal,sensor,rskill,wam,critic) preempts onseverity ≥ SEVERITY_FAIL. Reward-cancel (§2): whenis_reward_wake(...)is true (acriticFAIL) and anexecute_rskillgoal is in flight (_active_rskill_goal),_cancel_inflight_rskill_for_reward()requestsgoal_handle.cancel_goal_async()and latches_rskill_cancel_reason="reward"instead of ticking — the reward signal stops the VLA now (not at thedeadline_sclock), 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 (forcewins) and replayed after the in-flight pass finishes — flat stack, one LLM call at a time, bounded by_MAX_TICK_REPLAYS=4via_release_tick_and_maybe_replay(the counter accumulates across chained replays and resets on a quiet finish). Async LLM phase (#21):_start_tickrunsReasonerCore.prepare_tickon the executor, handsrun_prepared_llmto the single-worker_llm_pool(ThreadPoolExecutor(max_workers=1)— one outstanding tick LLM call;describe_imageruns on its own single-worker_vlm_poolso a Tier-A tick is never queued behind an in-flight adjudication), and returns; the worker marshals_finish_llm_tickback onto the executor via_post_to_executor(inboxdeque+ rclpy guard condition_inbox_guard, drained by_drain_executor_inbox), which runsReasonerCore.finish_tick, routes theReasonerToolCallvia_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_generationcheck (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. Thetierarg is passed through from the preempting callback (Afrom_on_failure(source="safety"),B/Cfrom other failure sources,Dfrom_on_prompt) and lands on thereasoner.tickOTel span asreasoner.tier. Acceptance-pinned bytests/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_promptseeds a single-taskMissionStatefrom the operator goal (MissionState.from_prompt, one task; the LLM decomposes viadecompose_mission) viaContextRenderer.set_mission, gated by the purenode_policy.should_rebuild_mission(cascade re-prompts never rebuild; an in-progress mission is only replaced with explicit"new_goal": truemetadata so an operator reply can't clobber the queue; a pre-work resend still replaces) — a rebuild also clears the stickylocatedgrounding and persists the ladder snapshot. The search-bound + retry-cap streak resets likewise apply only when the source is absent fromnode_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_rskillrecords 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'sdeadline_sslot carries the resolved patience ceiling (_effective_patience_s→resolve_patience_s: LLMpatience_soverride > reward model'sdefault_patience_s> legacydeadline_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_taskauto-issues a windowedquery_task_progress(whentask_progress_available; never auto-completes without it) — the verify requests the activeRewardContract's fullframe_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 BOTHtask_idand text — duplicate task texts are common after a decompose) appliesevaluate_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_nowis threaded as a secondary corroborating signal and BOTH heads are pushed to the## REWARDcontext viaset_reward_state. Band edges from_band_edges→resolve_band_edges: the activeRewardContractloaded from thereward_manifest_pathparam, else module-level_DEFAULT_SUCCESS_THRESHOLD=0.8/_DEFAULT_CHECK_FLOOR=0.5;vlm_checkruns_adjudicate_completionon the LLM worker via_adjudicate_completion_async(#21 — thedescribe_imageround-trip shares the LLM timeout budget and used to starve the executor from inside the verify done-callback; the continuation_on_vlm_completion_verdictre-applies the stale-verdict guard, then hands off to the shared_apply_mission_verdicttail) — VLMyes→_complete_active_and_advance,no/None→ re-runsevaluate_task_verdict(ok=False, …)to drop into the attempts ladder (abandon onceattempts >= 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 callsReasonerCore.reset_kind_streak()(same as a new operator prompt) so the next task is not suppressed byretry_capfor re-using the tool kind the finished task ended on._emit_mission_completeemits an honest operator-facing summary when the queue is finished. - VLM completion adjudication (§5).
completion_camera_topicROS param (default"/openral/cameras/top/image", empty = disabled): onon_configuresubscribessensor_msgs/Image(BEST_EFFORT, VOLATILE, depth=1) and caches each frame as JPEG bytes via_on_completion_camerainto_latest_completion_frame._adjudicate_completion(task_text) -> bool | Noneasks_tool_use_client.describe_image(image_jpeg=…, question=COMPLETION_QUESTION.format(task=…))and returnsTrue(complete),False(not complete), orNone(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: callsadvance_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:Trueiff the lowercased text contains an affirmative token (incl. inflections — see the module entry above) without a negation token;Falseon 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(aset[str]of task ids, one offer per task; cleared when a new operator goal rebuilds the mission) and theDEFAULT_MAX_SUBDIVIDE_DEPTHdepth cap, so a task that declines to decompose still terminates in human-handoff. When it returns true,_on_mission_verify_responsere-arms the active task (MissionState.rearm_active) and_emit_subdivision_invite(task, verdict, *, traceparent)self-prompts (frame_idmission, a cascade source the reasoner consumes without rebuilding the queue) invitingdecompose_mission(target_task_id=…)._dispatch_decompose_mission(call: DecomposeMissionTool)applies the LLM's typed decomposition: with atarget_task_idit flat-splices that active task viasubdivide_active(call.rendered_subtasks())(refused at the depth bound → falls through to handoff); with an empty id it replaces the whole queue viaMissionState(call.rendered_subtasks())only whennot mission.has_started()(never discards in-flight progress).call.subtasksareGroundedSubtask(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=3without anexecute_rskilldispatch) 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 returnsTrue. Fixes the live locate-loop theSearchProgressmiss budget cannot bound (that resets on a HIT, so a repeatedly-HITTINGlocate_in_viewnever terminates)._reset_task_locate_budgetclears it on a new operator goal (_on_prompt) and on a realexecute_rskilldispatch (so locate cycles only count while the task has produced no skill dispatch). _on_skill_registry_changed(msg)— §4 palette refresh. WalksrSkill.list_installed(), loads each entry'smanifest_pathinto a realRSkillManifest, runsbuild_tool_palette(...)against the activerobot_capabilities+commercial_deploymentflag (every availability flag preserved — incl.memory_available=self._memory_store is not None, which the pre-fix rebuild omitted, silently droppingmemory_write/memory_searchon every refresh), installs the result viaset_palette.openral_rskillis lazy-imported to keep the node cheap to import._dispatch(call, *, traceparent=None)— Routing-only switch over theReasonerToolCallvariants; delegates to_dispatch_emit_prompt/_dispatch_execute_skill/_dispatch_lifecycle_transition/_dispatch_spatial_query/_dispatch_memory_write/_dispatch_memory_search.WaitToolis a deliberate no-op (debug-log the rationale, return — no ROS traffic).ReloadGstPipelineToolis the sole log-and-acknowledge stub (F6 sensor-package service IDL not yet on disk — GH-126)._dispatch_emit_prompt(call, *, traceparent)— Publish aPromptStampedoncall.target_topic(per-topic publisher cache_emit_prompt_pubsvia_emit_prompt_publisher;/openral/promptreuses the standing cascade publisher — the pre-fix dispatcher published every call on/openral/promptwhile logging the requested target, silently dropping cross-topic cascades); stamps the threaded-throughtraceparentintometadata_jsonper §6._dispatch_spatial_query(call, *, traceparent)— Phase 2b/§3. Read-only: runs aRecallObjectTool/ResolvePlaceToolagainst the injectedSpatialMemoryviarun_spatial_query_detailedand republishes the rendered result as aPromptStampedwith frame_id"spatial_memory"(so_on_promptconsumes it, not filtered as a self-emit) — the prompt cascade feeds the answer into the next tick. Bounded by aSearchProgress/SearchBudget: consecutive queries are counted, and oncemax_attemptsis hit the result is published with the reasoner's own frame_id and_finish_active_search_handoffabandons 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, noFailureTrigger. Warns + no-ops if no backend is wired. Arecall_objectmiss (SpatialQueryOutcome.found == False, #10) escalates to a livelocate_in_viewfor the same query term — policy-driven (not LLM-chosen) — whendetector_availableand 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/maphas been received (paramsoccupancy_map_topicdefault/map— empty disables;approach_inflation_mdefault 0.25), everyrecall_objectapproach viewpoint is refined throughrefine_approach_posebefore 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. Onon_configure, when no backend was injected andspatial_memory_pathis set, lazy-importsopenral_world_state.SpatialMemory,SpatialMemory.load(path), sets it as the query backend, and flipsspatial_memory_available. Load failure (OSError/ValueError) degrades to WARNING + no backend (tools simply not offered) — never a fabricated map. Wired insim_e2e.launch.pyvia thespatial_memory_path:=<path>launch arg. Emits the loaded map once via_emit_scene_objects_span._maybe_load_memory()— §3 deployment wiring. Onon_configure, whenmemory_md_pathis set: parses the (possibly absent)MEMORY.mdinto aMemoryStore, renders the## MEMORYcontext block, loads the<MEMORY.md>.archive.jsonlrecall log (_load_memory_archive), and flipsToolPalette.memory_availablesomemory_write/memory_searchare 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 theMemoryWriteToolop (add/update/supersede/delete) to the liveMemoryStore, appends any displaced entry to the archival JSONL (_archive_memory_entry), persistsMEMORY.md(_persist_memory), re-renders the## MEMORYblock, 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.searchover the archive (superseded/deleted entries that left the live file — current memory is already in the## MEMORYblock), 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, callsopenral_world_state.emit_scene_objects_span(self._spatial_memory.to_scene_graph(), source_node=…)to publish theworld.scene_objectsspan (scene-objects card + SLAM-map overlay). Called once on load and on every heartbeat_on_tick(above the_core is Noneguard, 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 fromopenral_core(the single source of truth, shared with theGroundedSubtaskschema validator); true when a task text targets a set (quantifier or bare generic plural). Drives the execute grounding gate. (Was a local_is_collective_targethelper; promoted toopenral_coreso the schema and the runtime gate cannot drift.)_emit_enumeration_invite(task, *, traceparent)— Grounding gate's self-prompt (mirrors_emit_subdivision_invite): publishes aPromptStamped(frame_idmission, a cascade source consumed next tick without rebuilding the queue) telling the LLM the active task targets a collective set, to read the livescene_objectsperception line, and todecompose_mission(target_task_id=…)into one concrete subtask per specific object before any actuation._dispatch_execute_rskill(call, *, traceparent)— Probe the/openral/execute_rskillaction server (100 mswait_for_server); on absence emit aKIND_CONTROLLERFailureTriggerand bail. Grounding gate (first): when the activeMissionStatetask 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 secondexecute_rskillis refused with## EXECUTIONfeedback ("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_sROS 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_watchdogreleases it, reactivates the peers, invalidates that monotonic dispatch generation, and emits aKIND_CONTROLLERFailureTrigger (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 intest_reasoner_dispatch_robustness.py). On accept,ContextRenderer.set_inflight_skillsurfaces 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: whenvram_lifecycle_peersis non-empty it routes through_free_vram_peers_then_send(deactivate the GPU peers first, then send); otherwise calls_send_execute_rskill_goaldirectly. Gate (before recording the attempt):_refuse_unfittable_vlais two-tier, both refusing with aKIND_CONTROLLER/vram_insufficientFailureTriggerand skipping the dispatch (no attempt recorded). Tier 1 — live free-VRAM probe (_detect_gpu_free_vram_gb): refuse when the VLA's declaredactive_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), whenvram_lifecycle_peersare 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'sreward_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-freenvidia-smiMiB→GiB probes,0.0on any failure)._send_execute_rskill_goal(call, generation, traceparent)— BuildExecuteRskill.Goal, send asynchronously withfeedback_callback=_on_execute_rskill_feedback, attach the generation-bound_on_execute_rskill_goal_responseto the send future. (Extracted from_dispatch_execute_rskillfor 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-flightchange_stateresponses 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_peersfor 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 ondeadline(the policy may still be resident)._on_reactivate_result(peer, future)— Best-effort log of a reactivationchange_stateoutcome._change_state_async(node, transition) -> future | None— Shared helper: lazily create + cache alifecycle_msgs/srv/ChangeStateclient per peer node, map"configure"/"activate"/"deactivate"/"cleanup"toTransition.TRANSITION_*, and call asynchronously. ReturnsNoneif the service isn't on the graph. Used by both_dispatch_lifecycle_transitionand the VRAM-eviction path._dispatch_lifecycle_transition(call)— Drive<call.node>/change_statevia_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 aKIND_CONTROLLERFailureTrigger; on acceptance arm a one-shot deadline timer (_on_execute_skill_deadline, only whencall.deadline_s > 0) and attach_on_execute_skill_resulttoget_result_async()._on_execute_skill_result(call, generation, goal_id, future, traceparent)— Ignore stale generations, cancel the deadline timer; onSTATUS_SUCCEEDED + result.successlog success; on abort/cancel/non-success emit aKIND_CONTROLLERFailureTriggerwith aControllerEvidencepayload (state ∈ {aborted,canceled,failed},detail=result.failure_reason). A typedROSConfigErrororROSCapabilityMismatchresult 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 viacancel_goal_async(), and emit aKIND_TIMEOUTFailureTriggerwithTimeoutEvidence(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) snapshotsReasonerLadderStateviasave_ladder_stateto theladder_state_pathROS param (empty = disabled);_maybe_restore_ladder_state()reloads it aton_configure— mission restored onto the renderer,_subdivide_offered/_collective_decompose_nudges/TaskLocateBudget.restorerebound — so a reasoner restart RESUMES the ladder instead of resetting every cap mid-mission. _on_lifecycle_response(call, future)— Log theChangeStateresult; lifecycle failures are operator-driven and surface in the target node's own logs (noFailureTriggerre-emission)._publish_skill_failure(*, kind, rskill_id, evidence, traceparent, trace_id=None)— Build + publish aFailureTriggeron/openral/failure/rskillwithseverity=SEVERITY_FAIL;trace_id(when propagated by the action result) takes precedence over the reasoner's activetraceparent. Then mirrors the failure onto the OTLP span path via_emit_skill_failure_eventso 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 anopenral.event.skill_failurespan event (semconv.EVENT_SKILL_FAILURE) carrying the failure state (semconv.SKILL_FAILURE_STATE:evidence.statewhen present — e.g.vram_insufficient/unavailable— else a kind-derived name from_SKILL_FAILURE_KIND_NAMES:timeout/controller). Adds the event to the activereasoner.tickspan when one is recording (synchronous dispatch-gate paths) or opens a transientreasoner.skill_failurespan (async action-callback paths) so every failure is counted. Drives the dashboard "skill failures" counter.- Properties
renderer,dispatched_calls; methodset_palette(palette)(imperative seam called from the/openral/skill_registry_changedrefresh 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 forros2 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 toDEFAULT_SOURCES.on_configure— Build the/openral/promptfan-out publisher and one/openral/prompt_in/<source>subscriber per allowed source._on_inbound(source, priority, msg)— Forward the inbound PromptStamped onto/openral/promptafter merging{"source": ..., "priority": ...}intometadata_json(preserving any per-source fields).- Property
forwarded_count— Number of prompts forwarded sinceon_configure(for tests). main(args=None) -> int— Entry point forros2 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 withmetadata_json={"source_cli": true}plus"new_goal": trueunder--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 atjust ros2-build). The prompt-router preserves the mission-replacement flag when stampingsource/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; readsOTEL_EXPORTER_OTLP_ENDPOINTwhenendpointis None; returnsTrueif exporters were installed,Falsefor the no-op path. On a successful install also kicks offstart_system_metrics_collectorso the dashboard's System health card receives CPU / RAM / GPU gauges. Registersshutdown_observabilityviaatexiton first install. Metric reader interval is configurable viaOPENRAL_OTEL_METRIC_INTERVAL_MS(default 5 s); theBatchSpanProcessorflush interval viaOPENRAL_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_ratioselects the trace sampler —None/1.0→ALWAYS_ON, values in(0, 1)→ParentBased(TraceIdRatioBased(ratio)); honorsOPENRAL_OTEL_SAMPLE_RATIOenv 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): callsconfigure_observability(OTLP pipeline + structlog bridge) thenattach_traceparent_from_envso the worker's root context is the parent trace; returns whateverconfigure_observabilityreturned. Parent must spawn the child withenv={**os.environ, **traceparent_env()}(R2 multiprocess log/trace correlation). (L231)_resolve_sampler(sample_ratio) -> Sampler— Resolve the trace sampler from arg + env, defaulting toALWAYS_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; emitsrskill.id/rskill.rolefromsemconv. (L64)inference_span(name="skill.chunk_inference", *, chunk_index=None, kind: InferenceKind="foreground", **attrs)— Span for one VLA inference and theopenral.inference.durationhistogram (emitted from the helper so span and metric cannot diverge); emitsinference.kind/inference.chunk_index.InferenceKind = Literal["foreground", "prefetch", "single"]is the closed label set (design §9) — a timing axis, deliberately without"chunk"(shape ridesinference.chunk_size). (L94)safety_span(name="safety.check", *, check_name=None, severity="info", **attrs)— Span for a safety check; the C++ kernel parents its ownsafety.checkto the Python tick via the propagator. (L154)reasoner_span(name="reasoner.tick", *, tick_idx=None, model=None, force=None, **attrs)— Span for oneReasonerCore.tick. Setsreasoner.{tick.idx, model, force}and accepts any extrareasoner.*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, soprepare_tick→ off-threadrun_prepared_llm→finish_tickcan carry it between phases (each re-attaching viaopentelemetry.trace.use_span) and intermediate executor callbacks never see it as current. Caller ownsspan.end(). Used byopenral_reasoner.coreto recordreasoner.{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 thecli.commandroot span for one CLI invocation; recordscli.subcommand,openral.run.id, optionalopenral.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 ofdiagnostic_msgs/DiagnosticStatuslevel constants (OK=0,WARN=1,ERROR=2,STALE=3); re-exported sostatus_fncallbacks can avoid importingdiagnostic_msgson pure-Python hosts. (L32)DiagnosticsHeartbeat(node, *, hardware_id, component_name, status_fn, rate_hz=1.0)— 1 Hz/diagnosticspublisher attached to arclpy.lifecycle.LifecycleNode. Drives the standardcreate_publisher(inon_configure) /start(inon_activate) /stop(inon_deactivate) /destroy(inon_cleanup) sequence;publish_once()exposes a deterministic publication for tests; an exception insidestatus_fnis 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 foron_configure/on_activate/ … transition callbacks. Transparent on success; on an uncaught exception it logs the callback name + full traceback vianode.get_logger().error(...)(→/rosout→ launch console) and returnsTransitionCallbackReturn.FAILUREinstead of letting the exception escape into rclpy's silentERRORconversion. Applied to theon_configure/on_activateofRskillRunnerNode,_WorldStateLifecycleNode,HALLifecycleNodeBase(covers every per-robot HAL), andReasonerNode. Importsrclpylazily 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 currentMeterProvider. (L64)get_tick_duration() -> Histogram—openral.tick.duration, unitms. (L99)get_inference_duration() -> Histogram—openral.inference.duration, unitms. (L114)get_hal_read_state_duration() -> Histogram—openral.hal.read_state.duration, unitms. (L126)get_hal_send_action_duration() -> Histogram—openral.hal.send_action.duration, unitms. (L138)get_sensors_age_ms() -> Histogram—openral.sensors.age_ms, unitms. (L150)get_world_state_staleness_ms() -> Histogram—openral.world_state.staleness_ms, unitms. (L162)get_tick_budget_violations() -> Counter—openral.tick.budget_violations. (L177)get_tick_deadline_misses() -> Counter—openral.tick.deadline_misses. (L188)get_safety_violations() -> Counter—openral.safety.violations, labelscheck_name/severity. (L199)get_hal_estop_count() -> Counter—openral.hal.estop.count. (L213)get_sensors_stale_reads() -> Counter—openral.sensors.stale_reads. (L234)get_observability_export_failures() -> Counter—openral.observability.export_failures, labelsignal_kind. (L267)get_world_state_components_stale() -> UpDownCounter—openral.world_state.components_stale. (L292)record_histogram_ms(instrument, value_ms, attributes=None) -> None— Record a millisecond value, skipping negatives andNaN. (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 ahal.read_statespan. (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 ahal.send_actionspan. (L151)record_ee_poses(span, ee_poses) -> None— Flatten aname → Pose6Dmapping onto aworld_state.snapshotspan. (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 asensors.read_latestspan. (L208)emit_sensor_frame_span(frame, *, sensor_name, age_ms, flip_180=False, tracer_name=…) -> None— THE shared producer of a dashboardsensors.read_latestspan for one camera frame: optionalOPENRAL_DASHBOARD_FLIP_180rotation 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_observabilityfor pump-fed cameras, WorldState_on_imagefor tee-fed), replacing two hand-mirrored copies that had already drifted.tracer_namekeeps 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; returnsNoneif Pillow is unavailable. (L315)encode_frame_thumbnail(frame) -> bytes | None— Encode anopenral_core.SensorFrame(RGB8/BGR8/MONO8/JPEG/PNG) as a small JPEG thumbnail; returnsNonefor non-renderable encodings. (L340)modality_for_encoding(encoding) -> str— Map aFrameEncoding(or its string value) to the dashboard's modality label (rgb/mono/depth/raw/unknown). Reused byDeployRunner._tick_implandworld_state_ros/lifecycle_node._on_imageso 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 everyinterval_sseconds. ReturnsFalseand a quiet no-op when neitherpsutilnorpynvmlis 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, returningNonewhen 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)nvmlDeviceGetMemoryInforaisesNVMLError_NotSupported— no discrete VRAM pool — whilenvmlDeviceGetUtilizationRatesworks, and previously the former propagated out of_sample_onceand 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 inopenral_detect.probes.gpu. (L147)
python/observability/src/openral_observability/propagation.py
W3C TraceContext inject / extract for cross-process trace correlation.
current_traceparent() -> str | None— W3Ctraceparentvalue for the active span, orNoneoutside a span. (L53)inject_traceparent(carrier=None) -> dict[str, str]— Write the active span'straceparent(and optionaltracestate) into a carrier dict; used by producers ofActionChunk.msg/ExecuteRskill.action/FailureTrigger.msg. (L68)extract_traceparent(traceparent, tracestate=None) -> Context— Parse a wire-sidetraceparentinto an OTelContextforcontext.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+ optionalOTEL_TRACESTATE) for the active span, built frominject_traceparent; pass asenv=tosubprocess/multiprocessingso 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: readOTEL_TRACEPARENT/OTEL_TRACESTATEfromenv(defaultos.environ) andcontext.attachthe parent context; returns the detach token, orNonewhen absent/empty. (L180)remote_parent_from_env(env=None)[@contextmanager] — Scopeattach_traceparent_from_envfor a workermain(): attaches on enter, detaches on exit; yields the detach token (orNonewhen 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_*intmodule constants (L94–L109) — Mirroropenral_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 ontopic_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 stampstrace_id/span_idon every log event. (L63)resolve_log_level() -> int— Resolve the OpenRAL log floor fromOPENRAL_LOG_LEVEL(level name, case-insensitive and whitespace-tolerant, or an integer). Defaults toINFO, notDEBUG. 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 bydashboard.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 OTelLoggerProvider, with both the bridge logger and theopenralroot logger set toresolve_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_objects→topics["scene_objects"](durable spatial-memory objects for the scene-objects card + SLAM-map overlay; theworld_state.scene_objects.listJSON 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 OTLPResourceLogs(the structlog→OTel bridge) as event-log rows (issue #318): body → title, instrumentation scope (logger) name → kind,severity_number→debug/info/warn/error/fatalvia_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/robotswire 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 theZeroconfinstance, advertiser, and browser for the dashboard. Attributeenabled: bool; methodrobots() -> 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 intorun_dashboardinserver.py;app.state.discoveryholds the instance (orNonewhen themdnsextra 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 receiversPOST /v1/traces,POST /v1/metrics,POST /v1/logs(logs now feed the event log viaTelemetryStore.ingest_logs— issue #318), and the operator write endpointsPOST /api/prompt(shells out toopenral 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/executeandPOST /api/param/set(guarded write-controls, default OFF — issue #75c; return 403 unlessOPENRAL_DASHBOARD_WRITE_CONTROLS=1;skill/executereturns 202 on action-server acceptance with async background result logging;param/setalso refuses safety-relevant param names via_SAFETY_PARAM_DENYLIST).GET /api/confignow returns{"jaeger_ui_url": "...", "write_controls_enabled": bool, "voice_prompt_enabled": bool}— the last flag isvad_assets.vad_assets_available()(below), read bydashboard.jsto 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: intfor 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-web0.0.29 jsDelivr URLs + sha256 recorded instatic/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 servedstatic/vendor/vad/dir. Never raises; a failed asset is astructlogwarning (dashboard.vad_asset_unavailable), never a silent skip, and does not stop the others. ReturnsTrueiff every asset ended up served. Called best-effort fromrun_dashboardon 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'svoice_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 onhost:portand block until SIGINT/SIGTERM. Callsvad_assets.ensure_vad_assets()best-effort before binding (never gates startup — see above). Prints a singleOpenRAL dashboard: http://host:port/banner to stderr before binding (issue #132) so the user always sees the URL. Wheninprocess_cmdis set, spawns the argv as a child process withOTEL_EXPORTER_OTLP_ENDPOINT+OTEL_EXPORTER_OTLP_PROTOCOL=http/protobufpointed at the dashboard. Default port is4318(OTLP/HTTP standard) instead of the historic8000to avoid clashing withmkdocs 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: spawnopenral dashboardas a child of the current process, poll/healthzuntil ready, setOTEL_EXPORTER_OTLP_{ENDPOINT,PROTOCOL}, yield the URL, and SIGINT the child on exit. YieldsNone(workload continues unattached) ifopenralis not on PATH, the child died early, or/healthznever came back within the timeout. (L36, inopenral_observability/dashboard/attach.py)attached_dashboard(*, enabled, port=4318) -> Iterator[bool][@contextmanager] — High-level wrapper used byopenral sim run --dashboard,openral deploy run --dashboard, andopenral benchmark run --dashboard. Whenenabled=False, yieldsFalseimmediately (true no-op, no FastAPI/uvicorn imports). Whenenabled=True, delegates tospawn_dashboard, re-runsconfigure_observabilityon the new endpoint, and drains viashutdown_observabilityinfinallyso the last batch lands before the child is SIGINT'd. YieldsTrueiff 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 bytrace_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 carryingduration_ms.TelemetryStore.list_traces() -> list[dict]— One row per indexed trace_id (trace_id,span_count,last_seen_unix), most-recent first. BacksGET /api/traces.TelemetryStore.lookup_trace(trace_id: str) -> list[dict] | None— Every indexed span fortrace_id, sorted ascending bystart_unix_ns.Nonewhen the trace is not (or no longer) in the bounded index. BacksGET /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": [...]}fromTelemetryStore.list_traces.GET /api/spans/{trace_id}— JSON{"trace_id", "spans": [...]}fromTelemetryStore.lookup_trace; 404 when the trace is not indexed.GET /api/config— JSON{"jaeger_ui_url": "..."}sourced from theOPENRAL_JAEGER_UI_URLenv (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 guessedlocalhost: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_tracepointappends_begin/_endsuffixes.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>_endaround the block. Attaches the active OTeltrace_idasotel_trace_idso 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 whenbabeltrace2is 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 atepisode_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 atrecord_frame.trace_id(32 hex) /span_id(16 hex) carry the producingrskill.tickspan'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 atepisode_end. (L122)class DatasetSink(Protocol)— Fan-out target withopen_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 moreDatasetSinkimplementations and writes the OTelopenral.dataset.repo_id/episode_idx/frame_idxattributes on the activerskill.tickspan. (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 activerskill.tickspan's(trace_id, span_id)onto the frame (ISSUE-109); explicittrace_id/span_idoverride 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 (withoutobservation.images.prefix) the sinks expect; derived fromRobotDescription.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. ReadsObservationSpec.state_shape,ActionSpec.dim, andSensorSpec.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 everyRolloutRecorderevent into an mcap file readable byros2 bag info/ Foxglove / mcap-cli. Daemon writer thread + boundedqueue.Queue→write_frameenqueues only; hot path never blocks on disk I/O. JSON-schema encoding (interoperable with ROS 2'sros2msgencoding for the same topics). Topics:/openral/tick(per-tick metadata plus inlineobservation_state+actionarrays),/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. inlineobservation_state/action+ the frame'strace_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 byfrom_bagdescribing 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 aRosbag2Sink-produced mcap, group Ticks under PHASE_START / PHASE_END markers, join each tick's inlineobservation_state/actionarrays + the per-(episode_idx, step_idx)camera frames from/openral/dataset/image, and replay each episode through a realLeRobotDatasetSink→ 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). RaisesROSConfigErroron 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 theroot/data/**/*.parquetcorrelation columns directly viapyarrow(no video decode), so it works without a torchcodec/ffmpeg backend. RaisesROSConfigErrorwhen the root has no parquet, the dataset predates the columns, or no(episode_idx, frame_idx)row matches. Backsopenral 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 ofDatasetSinkwriting LeRobot v3 datasets via reallerobot.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")— RaisesROSConfigErrorif 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 toLeRobotDataset.add_frame. Adds the frame'strace_id/span_idasstringparquet columns (ISSUE-109). (L276)close_episode(summary) -> None— CallsLeRobotDataset.save_episode(parallel_encoding=True)and accumulates the per-dataset success counter. (L353)finalize() -> None— CallsLeRobotDataset.finalize()then appendsdataset_success_rate/license/repo_idand the dataset-leveltrace_ids/n_traces(distinct OTel traces, ISSUE-109) tometa/info.json["metadata"], and writes the per-episodeepisode_index → trace_idmap to themeta/openral_traces.jsonsidecar. (L396)