robonix_pilot/
soma_context.rs1use 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
161fn 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}