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