Skip to main content

robonix_pilot/
soma_context.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2//
3// Pilot-side native Soma awareness.
4//
5// Soma exposes robot body data as gRPC contracts. Pilot fetches the
6// default robot's Soma YAML and URDF once at startup, then injects the
7// result into every turn's system prompt so the model knows its body
8// without the user first calling a bridge tool.
9
10use crate::pb::contracts::{
11    robonix_system_soma_get_health_client::RobonixSystemSomaGetHealthClient,
12    robonix_system_soma_get_urdf_client::RobonixSystemSomaGetUrdfClient,
13    robonix_system_soma_get_yaml_client::RobonixSystemSomaGetYamlClient,
14};
15use crate::pb::soma::{GetHealthRequest, GetUrdfRequest, GetYamlRequest};
16use anyhow::{Context, Result};
17use robonix_atlas::client::{self as atlas_client, AtlasClient};
18use robonix_scribe::warn;
19
20const GET_YAML_CONTRACT: &str = "robonix/system/soma/get_yaml";
21const GET_URDF_CONTRACT: &str = "robonix/system/soma/get_urdf";
22const GET_HEALTH_CONTRACT: &str = "robonix/system/soma/get_health";
23
24pub async fn fetch_runtime_prompt_block(atlas: &mut AtlasClient, consumer_id: &str) -> String {
25    match fetch_health(atlas, consumer_id).await {
26        Ok(value) => format!(
27            "\n\n## Current embodiment state (from Soma)\n\
28             This snapshot was refreshed immediately before planning. Fresh fields are \
29             authoritative. Stale or missing fields mean unknown; never reconstruct them \
30             from conversation history. `likely_holding` means the calibrated gripper is \
31             not fully open; it does not identify the object.\n\n{}\n",
32            serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".into())
33        ),
34        Err(error) => format!(
35            "\n\n## Current embodiment state (from Soma)\n\
36             {{\"available\":false,\"error\":{}}}\n",
37            serde_json::to_string(&error.to_string()).unwrap_or_else(|_| "\"unknown\"".into())
38        ),
39    }
40}
41
42async fn fetch_health(atlas: &mut AtlasClient, consumer_id: &str) -> Result<serde_json::Value> {
43    let (channel_id, _provider_id, channel) =
44        atlas_client::connect_to_capability(atlas, consumer_id, GET_HEALTH_CONTRACT)
45            .await
46            .context("connect to Soma get_health")?;
47    let result = async {
48        let response = RobonixSystemSomaGetHealthClient::new(channel)
49            .get_health(GetHealthRequest {})
50            .await
51            .context("call Soma get_health")?
52            .into_inner();
53        let snapshot = response
54            .snapshot
55            .context("Soma has not published a health snapshot yet")?;
56        let components: Vec<_> = snapshot
57            .components
58            .into_iter()
59            .map(|component| {
60                serde_json::json!({
61                    "id": component.id,
62                    "parent_id": component.parent_id,
63                    "kind": component.kind,
64                    "health": component.health,
65                    "operational_state": component.operational_state,
66                    "online": component.online,
67                    "detail": component.detail,
68                })
69            })
70            .collect();
71        let actuators: Vec<_> = snapshot
72            .actuators
73            .into_iter()
74            .map(|actuator| {
75                serde_json::json!({
76                    "component_id": actuator.component_id,
77                    "joint_name": actuator.joint_name,
78                    "position": actuator.position.map(|value| serde_json::json!({
79                        "value": value.value, "unit": value.unit, "quality": value.quality,
80                    })),
81                    "communication_ok": actuator.communication_ok,
82                })
83            })
84            .collect();
85        let metrics: Vec<_> = snapshot
86            .metrics
87            .into_iter()
88            .map(|metric| {
89                serde_json::json!({
90                    "component_id": metric.component_id,
91                    "name": metric.name,
92                    "value": metric.value.map(|value| serde_json::json!({
93                        "value": value.value, "unit": value.unit, "quality": value.quality,
94                    })),
95                })
96            })
97            .collect();
98        Ok::<_, anyhow::Error>(serde_json::json!({
99            "available": true,
100            "body_id": snapshot.body_id,
101            "seq": snapshot.seq,
102            "source_ts_ns": snapshot.source_ts_ns,
103            "ttl_ms": snapshot.ttl_ms,
104            "components": components,
105            "actuators": actuators,
106            "metrics": metrics,
107            "safety": snapshot.safety.map(|safety| serde_json::json!({
108                "motion_allowed": safety.motion_allowed,
109                "motor_power_allowed": safety.motor_power_allowed,
110                "aggregate_state": safety.aggregate_state,
111                "detail": safety.detail,
112            })),
113        }))
114    }
115    .await;
116    let _ = atlas.disconnect_capability(&channel_id).await;
117    result
118}
119
120pub async fn fetch_system_prompt_block(
121    atlas: &mut AtlasClient,
122    consumer_id: &str,
123) -> Result<Option<String>> {
124    let yaml = match fetch_yaml(atlas, consumer_id).await {
125        Ok(text) => text,
126        Err(e) => {
127            warn!("[pilot/soma] get_yaml unavailable; continuing without Soma context: {e:#}");
128            return Ok(None);
129        }
130    };
131    let urdf = match fetch_urdf(atlas, consumer_id).await {
132        Ok(text) => text,
133        Err(e) => {
134            warn!("[pilot/soma] get_urdf unavailable; continuing with YAML only: {e:#}");
135            String::new()
136        }
137    };
138
139    let mut block = String::from(
140        "\n\n## Robot Body Context (from Soma)\n\n\
141         This is the robot's self-description, loaded automatically at Pilot startup. \
142         Treat it as authoritative HARD CONSTRAINTS for the robot's body, sensors, \
143         frames, limits, and deployment-specific notes. Do not ask the user to call \
144         Soma manually unless this context is absent or stale.\n\n\
145         ### Hard planning rules from Soma\n\n\
146         - Sensor placement and modality in `soma.yaml` are binding. Do not invent \
147         sensors, viewpoints, arms, grippers, or degrees of freedom that are not listed.\n\
148         - Before planning an observation, match the user's requested viewpoint \
149         (front / rear / left / right / top, etc.) against the listed sensors' \
150         `placement`, `human_label`, and `cannot_do` notes.\n\
151         - If the requested viewpoint is not directly available from the sensors \
152         listed in Soma, say so explicitly. Do NOT call a camera with one placement \
153         and describe its image as if it came from a different placement.\n\
154         - If a viewpoint can be achieved only by moving the base (for example, \
155         rotate 180 degrees, then use the front camera), state that plan clearly \
156         and use motion + observation capabilities rather than pretending a missing \
157         sensor exists.\n\n\
158         ### soma.yaml\n\n```yaml\n",
159    );
160    block.push_str(yaml.trim());
161    block.push_str("\n```\n");
162    if !urdf.trim().is_empty() {
163        block.push_str("\n### URDF\n\n```xml\n");
164        block.push_str(urdf.trim());
165        block.push_str("\n```\n");
166    }
167    Ok(Some(block))
168}
169
170async fn fetch_yaml(atlas: &mut AtlasClient, consumer_id: &str) -> Result<String> {
171    let (channel_id, _provider_id, channel) =
172        atlas_client::connect_to_capability(atlas, consumer_id, GET_YAML_CONTRACT)
173            .await
174            .context("connect to Soma get_yaml")?;
175    let result = async {
176        let mut client = RobonixSystemSomaGetYamlClient::new(channel);
177        let response = client
178            .get_yaml(GetYamlRequest {
179                robot_id: String::new(),
180            })
181            .await
182            .context("call Soma get_yaml")?
183            .into_inner();
184        Ok::<_, anyhow::Error>(response.yaml_text)
185    }
186    .await;
187    let _ = atlas.disconnect_capability(&channel_id).await;
188    result
189}
190
191async fn fetch_urdf(atlas: &mut AtlasClient, consumer_id: &str) -> Result<String> {
192    let (channel_id, _provider_id, channel) =
193        atlas_client::connect_to_capability(atlas, consumer_id, GET_URDF_CONTRACT)
194            .await
195            .context("connect to Soma get_urdf")?;
196    let result = async {
197        let mut client = RobonixSystemSomaGetUrdfClient::new(channel);
198        let response = client
199            .get_urdf(GetUrdfRequest {
200                robot_id: String::new(),
201            })
202            .await
203            .context("call Soma get_urdf")?
204            .into_inner();
205        Ok::<_, anyhow::Error>(response.urdf_xml)
206    }
207    .await;
208    let _ = atlas.disconnect_capability(&channel_id).await;
209    result
210}