1use crate::discovery::{self, llm_name};
5use crate::history;
6use crate::memory;
7use crate::pb::contracts::robonix_system_executor_control_plan_client::RobonixSystemExecutorControlPlanClient;
8use crate::pb::contracts::robonix_system_executor_execute_client::RobonixSystemExecutorExecuteClient;
9use crate::pb::contracts::robonix_system_executor_list_active_plans_client::RobonixSystemExecutorListActivePlansClient;
10use crate::pb::executor::rtdl_event::RtdlEventEnum;
11use crate::pb::executor::{ControlPlanRequest, ListActivePlansRequest};
12use crate::pb::pilot::rtdl_node_state::RtdlNodeStateEnum;
13use crate::pb::pilot::{
14 BatchResult, CapabilityCall, CapabilityCallResult, PilotEvent, Plan, RtdlNode, RtdlNodeState,
15 SessionStatusEvent, Task, TaskStateEvent,
16};
17use crate::service::{self, PilotStreamBody, SessionState};
18use crate::state_context;
19use crate::vlm::{Message, VlmClient, VlmStreamItem};
20use anyhow::{Context, Result};
21use futures_util::StreamExt;
22use robonix_atlas::client::AtlasClient;
23use robonix_atlas::pb as atlas_pb;
24use robonix_scribe::{debug, info, warn};
25use std::collections::hash_map::DefaultHasher;
26use std::collections::{HashMap, HashSet};
27use std::hash::{Hash, Hasher};
28use std::path::PathBuf;
29use std::sync::Arc;
30use std::sync::atomic::{AtomicU64, Ordering};
31use std::time::Duration;
32use tokio::sync::{mpsc, watch};
33use tonic::Request;
34use tonic::transport::Channel;
35use uuid::Uuid;
36
37pub struct ExecutorConn {
40 pub graph: RobonixSystemExecutorExecuteClient<Channel>,
41 pub control: RobonixSystemExecutorControlPlanClient<Channel>,
42 pub active: RobonixSystemExecutorListActivePlansClient<Channel>,
43}
44
45type CapabilityTarget = (String, String);
46type CapabilityTargetMap = HashMap<String, CapabilityTarget>;
47
48const RTDL_SEQUENCE: u32 = 0;
49const RTDL_PARALLEL: u32 = 1;
50const RTDL_DO: u32 = 2;
51
52struct DisplayCapability<'a> {
53 display_name: String,
54 provider_id: &'a str,
55 cap: &'a atlas_pb::Capability,
56}
57
58#[derive(Default)]
59struct CapabilityPromptCache {
60 fingerprint: u64,
61 catalog: String,
62 initialized: bool,
63}
64
65struct PromptSection<'a> {
66 name: &'static str,
67 content: &'a str,
68}
69
70impl CapabilityPromptCache {
71 fn render<'a>(&'a mut self, caps: &[DisplayCapability<'_>]) -> (&'a str, bool) {
75 let fingerprint = capability_prompt_fingerprint(caps);
76 let hit = self.initialized && self.fingerprint == fingerprint;
77 if !hit {
78 self.catalog = render_capability_prompt(caps);
79 self.fingerprint = fingerprint;
80 self.initialized = true;
81 }
82 (&self.catalog, hit)
83 }
84}
85
86fn estimated_text_tokens(bytes: usize) -> usize {
87 bytes.div_ceil(4)
88}
89
90fn assemble_planning_messages(
94 round: u32,
95 capability_cache_hit: bool,
96 sections: &[PromptSection<'_>],
97 history_messages: &[Message],
98 correction: Option<&str>,
99) -> Vec<Message> {
100 let system_bytes = sections.iter().map(|section| section.content.len()).sum();
101 let mut system = String::with_capacity(system_bytes);
102 for section in sections {
103 system.push_str(section.content);
104 }
105 let sanitized_history = history::sanitize_for_vlm(history_messages);
106 let history_bytes: usize = sanitized_history
107 .iter()
108 .map(|message| message.content.as_deref().map_or(0, str::len))
109 .sum();
110 let correction_bytes = correction.map_or(0, str::len);
111 let prompt_bytes = system.len() + history_bytes + correction_bytes;
112 let mut section_metrics = sections
113 .iter()
114 .map(|section| {
115 serde_json::json!({
116 "name": section.name,
117 "bytes": section.content.len(),
118 "estimated_tokens": estimated_text_tokens(section.content.len()),
119 })
120 })
121 .collect::<Vec<_>>();
122 section_metrics.push(serde_json::json!({
123 "name": "history",
124 "bytes": history_bytes,
125 "estimated_tokens": estimated_text_tokens(history_bytes),
126 }));
127 section_metrics.push(serde_json::json!({
128 "name": "correction",
129 "bytes": correction_bytes,
130 "estimated_tokens": estimated_text_tokens(correction_bytes),
131 }));
132 info!(
133 "[pilot/prompt] {}",
134 serde_json::json!({
135 "round": round,
136 "prompt_text_bytes": prompt_bytes,
137 "estimated_input_tokens": estimated_text_tokens(prompt_bytes),
138 "history_bytes": history_bytes,
139 "correction_bytes": correction_bytes,
140 "capability_catalog_render_cache_hit": capability_cache_hit,
141 "sections": section_metrics,
142 })
143 );
144
145 let mut messages = Vec::with_capacity(sanitized_history.len() + 2);
146 messages.push(Message::system(&system));
147 messages.extend(sanitized_history);
148 if let Some(correction) = correction {
149 messages.push(Message::user(correction));
150 }
151 messages
152}
153
154fn max_tool_rounds() -> usize {
155 std::env::var("ROBONIX_PILOT_MAX_TOOL_ROUNDS")
156 .ok()
157 .and_then(|s| s.parse().ok())
158 .unwrap_or(64)
159}
160
161fn vlm_idle_timeout() -> Duration {
162 configured_vlm_idle_timeout(
163 std::env::var("ROBONIX_PILOT_VLM_IDLE_TIMEOUT_SECS")
164 .ok()
165 .as_deref(),
166 )
167}
168
169fn configured_vlm_idle_timeout(value: Option<&str>) -> Duration {
170 let seconds = value
171 .and_then(|value| value.parse::<u64>().ok())
172 .unwrap_or(30)
173 .clamp(5, 300);
174 Duration::from_secs(seconds)
175}
176
177const MAX_HISTORY: usize = 200;
178
179#[derive(Clone, Debug, PartialEq, Eq)]
183pub(crate) struct TaskState {
184 goal: String,
185 success_criterion: String,
186 status: String,
187}
188
189const DEFAULT_SUCCESS_CRITERION: &str =
190 "The user's request is completed and the result has been verified.";
191
192impl TaskState {
193 fn is_done(&self) -> bool {
197 self.status == "done"
198 }
199
200 fn prompt_block(&self) -> String {
202 format!(
203 "\n\n## Current user interaction\n- instruction: {}\n\
204 - success_criterion: {}\n- status: {}\n",
205 self.goal, self.success_criterion, self.status
206 )
207 }
208}
209
210fn task_is_session_end(task: &Task) -> bool {
212 let j = task.context_json.trim();
213 if j.is_empty() {
214 return false;
215 }
216 serde_json::from_str::<serde_json::Value>(j)
217 .ok()
218 .and_then(|v| {
219 v.get("session_end")
220 .or_else(|| v.get("robonix_session_end"))
221 .and_then(|x| x.as_bool())
222 })
223 .unwrap_or(false)
224}
225
226fn task_modality(task: &Task) -> Option<String> {
229 let j = task.context_json.trim();
230 if j.is_empty() {
231 return None;
232 }
233 serde_json::from_str::<serde_json::Value>(j)
234 .ok()
235 .and_then(|v| {
236 v.get("modality")
237 .and_then(|x| x.as_str())
238 .map(str::to_string)
239 })
240}
241
242fn skip_memory_prefetch(user_text: &str) -> bool {
244 let t = user_text.trim();
245 let lower = t.to_lowercase();
246 lower == "hi" || lower == "hello"
247}
248
249struct TreeMeta {
253 description: String,
255 control_only: bool,
260 call_signatures: HashSet<String>,
265 steps: Vec<TreeStep>,
269}
270
271struct TreeStep {
272 op_id: String,
273 description: String,
274 capability: String,
275}
276
277enum ForestEvent {
280 NodeState {
284 plan_id: String,
285 node_state: Box<RtdlNodeState>,
286 },
287 PlanDone {
291 plan_id: String,
292 results: Vec<RtdlNodeState>,
293 any_failed: bool,
294 canceled: bool,
301 },
302}
303
304async fn drive_plan(
309 plan: Plan,
310 mut client: RobonixSystemExecutorExecuteClient<Channel>,
311 events_tx: mpsc::Sender<ForestEvent>,
312 forest_revision: Arc<AtomicU64>,
313) {
314 let plan_id = plan.plan_id.clone();
315 let mut stream = match client.execute(Request::new(plan)).await {
316 Ok(resp) => resp.into_inner(),
317 Err(e) => {
318 warn!("[pilot/forest] plan_id={plan_id} Execute RPC failed: {e}");
319 forest_revision.fetch_add(1, Ordering::Release);
320 let _ = events_tx
321 .send(ForestEvent::PlanDone {
322 plan_id,
323 results: Vec::new(),
324 any_failed: true,
325 canceled: false,
326 })
327 .await;
328 return;
329 }
330 };
331
332 let mut results: Vec<RtdlNodeState> = Vec::new();
333 let mut any_failed = false;
334 let mut canceled = false;
335 loop {
336 match stream.message().await {
337 Ok(Some(event)) => {
338 if event.event_kind == RtdlEventEnum::PlanComplete as u32
339 && let Some(pc) = event.plan_complete
340 {
341 any_failed |= pc.any_failed;
342 continue;
343 }
344 if event.event_kind == RtdlEventEnum::NodeState as u32
345 && let Some(ns) = event.node_state
346 {
347 let _ = events_tx
349 .send(ForestEvent::NodeState {
350 plan_id: plan_id.clone(),
351 node_state: Box::new(ns.clone()),
352 })
353 .await;
354 if is_terminal_executor_state(ns.state) {
358 forest_revision.fetch_add(1, Ordering::Release);
359 if ns.state == RtdlNodeStateEnum::Canceled as u32 {
360 canceled = true;
365 } else if ns.state != RtdlNodeStateEnum::Succeeded as u32 {
366 any_failed = true;
367 }
368 results.push(ns);
369 }
370 }
371 }
372 Ok(None) => break,
373 Err(e) => {
374 warn!("[pilot/forest] plan_id={plan_id} stream recv error: {e}");
375 any_failed = true;
376 break;
377 }
378 }
379 }
380
381 forest_revision.fetch_add(1, Ordering::Release);
382 let _ = events_tx
383 .send(ForestEvent::PlanDone {
384 plan_id,
385 results,
386 any_failed,
387 canceled,
388 })
389 .await;
390}
391
392async fn cancel_forest_plans(
397 executor: &mut ExecutorConn,
398 forest: &HashMap<String, TreeMeta>,
399 _session_id: &str,
400) {
401 let targets: Vec<String> = forest
402 .iter()
403 .filter(|(_, meta)| !meta.control_only)
404 .map(|(plan_id, _)| plan_id.clone())
405 .collect();
406 for target in targets {
407 let cancel = executor
408 .control
409 .control_plan(Request::new(ControlPlanRequest {
410 action: "cancel".to_string(),
411 plan_id: target.clone(),
412 op_id: String::new(),
413 when: String::new(),
414 wait_ms: 5_000,
415 }));
416 match tokio::time::timeout(std::time::Duration::from_secs(7), cancel).await {
417 Ok(Ok(response)) => {
418 let response = response.into_inner();
419 if response.success {
420 info!("[pilot] canceled executor plan {target} on abort_turn");
421 } else {
422 warn!(
423 "[pilot] cancel executor plan {target} rejected: {}",
424 response.error
425 );
426 }
427 }
428 Ok(Err(error)) => {
429 warn!("[pilot] cancel executor plan {target} failed: {error}")
430 }
431 Err(_) => warn!("[pilot] cancel executor plan {target} timed out"),
432 }
433 }
434}
435
436#[derive(Clone, Debug, PartialEq, Eq)]
437enum MetaPlanOp {
438 Cancel {
439 plan_id: String,
440 wait_ms: u64,
441 },
442 CancelAll {
443 wait_ms: u64,
444 },
445 StopAt {
446 plan_id: String,
447 op_id: String,
448 when: String,
449 },
450}
451
452impl MetaPlanOp {
453 fn cancellation_targets(&self, forest: &HashMap<String, TreeMeta>) -> Vec<String> {
454 match self {
455 Self::Cancel { plan_id, .. } => vec![plan_id.clone()],
456 Self::CancelAll { .. } => forest
457 .iter()
458 .filter(|(_, meta)| !meta.control_only)
459 .map(|(plan_id, _)| plan_id.clone())
460 .collect::<Vec<_>>(),
461 Self::StopAt { plan_id, .. } => vec![plan_id.clone()],
462 }
463 }
464}
465
466fn parse_meta_plan_op(rtdl: &serde_json::Value) -> Result<Option<MetaPlanOp>> {
467 let Some(obj) = rtdl.as_object() else {
468 return Ok(None);
469 };
470 let Some(op) = obj.get("op").and_then(|value| value.as_str()) else {
471 return Ok(None);
472 };
473 let string = |key: &str| -> Result<String> {
474 let value = obj
475 .get(key)
476 .and_then(|value| value.as_str())
477 .map(str::to_string)
478 .ok_or_else(|| anyhow::anyhow!("meta op `{op}` requires string `{key}`"))?;
479 if value.trim().is_empty() {
480 anyhow::bail!("meta op `{op}` requires non-empty `{key}`");
481 }
482 Ok(value)
483 };
484 let wait_ms = || {
485 obj.get("wait_ms")
486 .and_then(|value| value.as_u64())
487 .unwrap_or(5_000)
488 .min(30_000)
489 };
490 let parsed = match op {
491 "cancel_plan" => MetaPlanOp::Cancel {
492 plan_id: string("plan_id")?,
493 wait_ms: wait_ms(),
494 },
495 "cancel_all" => MetaPlanOp::CancelAll { wait_ms: wait_ms() },
496 "stop_plan_at" => {
497 let when = obj
498 .get("when")
499 .and_then(|value| value.as_str())
500 .unwrap_or("on_complete")
501 .to_string();
502 if !matches!(when.as_str(), "on_enter" | "on_complete") {
503 anyhow::bail!("meta op `stop_plan_at` requires when=on_enter or on_complete");
504 }
505 MetaPlanOp::StopAt {
506 plan_id: string("plan_id")?,
507 op_id: string("target_op_id")?,
508 when,
509 }
510 }
511 _ => return Ok(None),
512 };
513 Ok(Some(parsed))
514}
515
516async fn execute_meta_plan_op(executor: &mut ExecutorConn, op: &MetaPlanOp) -> Result<String> {
517 let request = match op {
518 MetaPlanOp::Cancel { plan_id, wait_ms } => ControlPlanRequest {
519 action: "cancel".to_string(),
520 plan_id: plan_id.clone(),
521 op_id: String::new(),
522 when: String::new(),
523 wait_ms: *wait_ms,
524 },
525 MetaPlanOp::CancelAll { wait_ms } => ControlPlanRequest {
526 action: "cancel_all".to_string(),
527 plan_id: String::new(),
528 op_id: String::new(),
529 when: String::new(),
530 wait_ms: *wait_ms,
531 },
532 MetaPlanOp::StopAt {
533 plan_id,
534 op_id,
535 when,
536 } => ControlPlanRequest {
537 action: "stop_at".to_string(),
538 plan_id: plan_id.clone(),
539 op_id: op_id.clone(),
540 when: when.clone(),
541 wait_ms: 0,
542 },
543 };
544 let timeout = Duration::from_millis(request.wait_ms.saturating_add(2_000).max(2_000));
545 let response = tokio::time::timeout(
546 timeout,
547 executor.control.control_plan(Request::new(request)),
548 )
549 .await
550 .context("Executor plan-control RPC timed out")??
551 .into_inner();
552 if !response.success {
553 anyhow::bail!(response.error);
554 }
555 Ok(response.message)
556}
557
558fn is_control_only(plan: &Plan) -> bool {
565 let mut has_do = false;
566 for n in &plan.nodes {
567 if n.node_kind != RTDL_DO {
568 continue;
569 }
570 has_do = true;
571 let leaf = n
572 .call
573 .as_ref()
574 .map(|c| c.contract_id.rsplit('/').next().unwrap_or(""))
575 .unwrap_or("");
576 if !matches!(
577 leaf,
578 "cancel_plan"
579 | "cancel_all_plans"
580 | "get_all_plans"
581 | "get_plan_status"
582 | "stop_plan_at"
583 ) {
584 return false;
585 }
586 }
587 has_do
588}
589
590fn plan_steps(plan: &Plan) -> Vec<TreeStep> {
591 plan.nodes
592 .iter()
593 .filter(|node| node.node_kind == RTDL_DO)
594 .filter_map(|node| {
595 let call = node.call.as_ref()?;
596 Some(TreeStep {
597 op_id: node.op_id.clone(),
598 description: node.description.clone(),
599 capability: call
600 .contract_id
601 .rsplit('/')
602 .next()
603 .unwrap_or(&call.contract_id)
604 .to_string(),
605 })
606 })
607 .collect()
608}
609
610fn build_forest_block(
611 forest: &HashMap<String, TreeMeta>,
612 cancel_requested: &HashSet<String>,
613) -> String {
614 let mut entries: Vec<(&String, &TreeMeta)> = forest
617 .iter()
618 .filter(|(plan_id, meta)| !meta.control_only && !cancel_requested.contains(*plan_id))
619 .collect();
620 if entries.is_empty() {
621 return String::new();
622 }
623 entries.sort_by_key(|(plan_id, _)| plan_id.parse::<u64>().unwrap_or(u64::MAX));
624 let mut block = String::from(
625 "\n\n## In-flight trees\n\
626 These RTDL trees you dispatched earlier are still running concurrently. \
627 Plan control is NOT a capability call and must never be placed inside a \
628 sequence, parallel, or do node. To stop one immediately, emit a root \
629 `cancel_plan` meta op with its exact `plan_id` below; to stop all work, \
630 emit a root `cancel_all` meta op. Every plan's ordered executable steps \
631 are listed below. To stop at a requested semantic boundary (for example \
632 after step 8 or after reaching the restaurant), emit a root \
633 `stop_plan_at` meta op with that `plan_id`, the chosen `target_op_id`, \
634 and `when` (`on_enter` to stop \
635 before that op runs, `on_complete` to stop after it finishes). Do not \
636 assume the target is the currently running step, and do not query status \
637 first when the requested boundary is already present in this list. Bind \
638 the user's named boundary literally: `after X` means X/on_complete and \
639 `before X` means X/on_enter. Never rewrite `after X` as `before` its \
640 successor because those are not equivalent in branching/parallel trees. It \
641 cancels the whole plan when execution reaches that op. Cancel/stop each \
642 plan_id at most once — a cancel that returned is already stopping; do NOT \
643 re-issue it. Do not reuse these ids for new trees. If an in-flight plan \
644 is already executing the same goal, do not cancel or re-issue it; wait \
645 for it to finish. This block contains trees owned by the current Pilot \
646 supervisor only. The authoritative Executor snapshot below may contain \
647 additional plans started by an earlier interaction. Never use this \
648 local block alone to answer how many tasks are running.\n",
649 );
650 for (plan_id, meta) in entries {
651 block.push_str(&format!(
652 "- plan_id={} running: {}\n",
653 plan_id, meta.description
654 ));
655 for (index, step) in meta.steps.iter().enumerate() {
656 block.push_str(&format!(
657 " {}. op_id={} [{}] {}\n",
658 index + 1,
659 step.op_id,
660 step.capability,
661 step.description
662 ));
663 }
664 }
665 block
666}
667
668fn build_executor_active_block(plans_json: Option<&str>) -> String {
669 let Some(raw) = plans_json else {
670 return String::from(
671 "\n\n## Executor active plans (authoritative live snapshot)\n\
672 - status: unavailable\n\
673 The live query failed. Never guess a task count or claim that no \
674 task is running. Tell the user that current execution state could \
675 not be verified.\n",
676 );
677 };
678 let Ok(value) = serde_json::from_str::<serde_json::Value>(raw) else {
679 return build_executor_active_block(None);
680 };
681 let Some(plans) = value.get("plans").and_then(serde_json::Value::as_array) else {
682 return build_executor_active_block(None);
683 };
684 let normalized = serde_json::json!({
685 "count": plans.len(),
686 "plans": plans,
687 });
688 format!(
689 "\n\n## Executor active plans (authoritative live snapshot)\n\
690 This is the source of truth for every currently running RTDL plan, \
691 including long-running skills started by earlier interactions. For \
692 questions about running task count, names, state, or cancellation \
693 targets, answer from this snapshot rather than conversation history or \
694 the local forest. A plan remains running while listed here even when \
695 its provider is internally idle or motion-gated. Never say that no task \
696 is running unless count is exactly 0.\n\
697 snapshot_json: {}\n",
698 normalized
699 )
700}
701
702async fn fetch_executor_active_block(executor: &mut ExecutorConn) -> String {
703 let request = executor
704 .active
705 .list_active_plans(Request::new(ListActivePlansRequest::default()));
706 match tokio::time::timeout(Duration::from_secs(2), request).await {
707 Ok(Ok(response)) => {
708 let response = response.into_inner();
709 if response.success {
710 build_executor_active_block(Some(&response.plans_json))
711 } else {
712 warn!(
713 "[pilot/state] Executor active-plan query rejected: {}",
714 response.error
715 );
716 build_executor_active_block(None)
717 }
718 }
719 Ok(Err(error)) => {
720 warn!("[pilot/state] Executor active-plan query failed: {error}");
721 build_executor_active_block(None)
722 }
723 Err(_) => {
724 warn!("[pilot/state] Executor active-plan query timed out");
725 build_executor_active_block(None)
726 }
727 }
728}
729
730fn append_steer(
737 task: Task,
738 history: &mut Vec<Message>,
739 current_task: &mut Option<TaskState>,
740) -> bool {
741 let text = task.text.trim();
742 if text.is_empty() {
743 return false;
744 }
745 info!("[pilot/steer] mid-task input: {text}");
746 history.push(Message::user(text));
747 *current_task = Some(TaskState {
748 goal: text.to_string(),
749 success_criterion: DEFAULT_SUCCESS_CRITERION.to_string(),
750 status: "in_progress".to_string(),
751 });
752 true
753}
754
755fn drain_steers(
756 steer_rx: &mut mpsc::Receiver<Task>,
757 history: &mut Vec<Message>,
758 current_task: &mut Option<TaskState>,
759) -> bool {
760 let mut pulled = false;
761 while let Ok(task) = steer_rx.try_recv() {
762 pulled |= append_steer(task, history, current_task);
763 }
764 if pulled {
765 history::trim(history, MAX_HISTORY);
766 }
767 pulled
768}
769
770fn start_or_resume_task(current_task: &mut Option<TaskState>, user_text: &str) {
771 let text = user_text.trim();
772 if text.is_empty() {
773 return;
774 }
775 *current_task = Some(TaskState {
776 goal: text.to_string(),
777 success_criterion: DEFAULT_SUCCESS_CRITERION.to_string(),
778 status: "in_progress".to_string(),
779 });
780}
781
782fn apply_task_update(
788 current_task: &mut Option<TaskState>,
789 update: TaskState,
790 can_finish: bool,
791) -> bool {
792 let Some(state) = current_task.as_mut() else {
793 return false;
794 };
795 let before = state.clone();
796 if update.goal != state.goal {
797 warn!(
798 "[pilot/rtdl] ignoring model goal replacement {:?}; harness goal remains {:?}",
799 update.goal, state.goal
800 );
801 return false;
805 }
806 if state.success_criterion == DEFAULT_SUCCESS_CRITERION
807 && !update.success_criterion.trim().is_empty()
808 {
809 state.success_criterion = update.success_criterion;
810 }
811 state.status = if update.status == "done" && can_finish {
812 "done".to_string()
813 } else {
814 "in_progress".to_string()
815 };
816 *state != before
817}
818
819const HISTORY_COMPACT_TRIGGER_CHARS: usize = 24_000;
822const HISTORY_KEEP_RECENT: usize = 12;
824
825async fn compact_history(history: &mut Vec<Message>, vlm: &VlmClient) {
836 let total: usize = history
837 .iter()
838 .map(|m| m.content.as_deref().map_or(0, str::len))
839 .sum();
840 if total < HISTORY_COMPACT_TRIGGER_CHARS || history.len() <= HISTORY_KEEP_RECENT + 4 {
841 return;
842 }
843
844 let split = history.len() - HISTORY_KEEP_RECENT;
845 let mut msgs = vec![Message::system(
846 "You compact a robot agent's working memory. Summarize the conversation so far \
847 into a concise but COMPLETE note that preserves: the user's goal(s) and any \
848 success criteria, key decisions, important tool results / observations, the \
849 current state of the task, and anything needed to keep going. Compact plain text, \
850 no markdown headings. Do not invent facts.",
851 )];
852 msgs.extend(history::sanitize_for_vlm(&history[..split]));
853 msgs.push(Message::user("Summarize the above conversation now."));
854
855 let summary = match collect_vlm_text(vlm, &msgs).await {
856 Some(s) if !s.trim().is_empty() => s,
857 _ => return,
858 };
859
860 let before = history.len();
861 let mut compacted = Vec::with_capacity(HISTORY_KEEP_RECENT + 1);
862 compacted.push(Message::user(&format!(
863 "[summary of earlier conversation — treat as established context]\n{}",
864 summary.trim()
865 )));
866 compacted.extend_from_slice(&history[split..]);
867 *history = compacted;
868 info!(
869 "[pilot] compacted history {before} -> {} messages (was ~{total} chars)",
870 history.len()
871 );
872}
873
874async fn collect_vlm_text(vlm: &VlmClient, messages: &[Message]) -> Option<String> {
877 let mut stream = vlm.chat_stream(messages, &[], None).await.ok()?;
878 let mut text = String::new();
879 while let Some(item) = stream.next().await {
880 if let Ok(VlmStreamItem::TextDelta(d)) = item {
881 text.push_str(&d);
882 }
883 }
884 Some(text)
885}
886
887fn feed_results_into_history(
890 history: &mut Vec<Message>,
891 plan_id: &str,
892 plan_description: &str,
893 results: &[CapabilityCallResult],
894) {
895 history.push(Message::user(&format!(
896 "Executor feedback scope: plan_id={plan_id}, independent RTDL tree={plan_description:?}. \
897 Attribute the following results only to this tree. A failure here blocks dependent \
898 steps in this tree, but does not cancel or invalidate other in-flight trees."
899 )));
900 let mut deferred_followups: Vec<Message> = Vec::new();
901 for r in results {
902 let mut bounded = r.clone();
903 if !history::is_image_output(&bounded.output) {
904 bounded.output = compact_tool_result(&bounded.contract_id, &bounded.output, 4096);
905 }
906 let mapped = rtdl_result_to_messages(&bounded);
907 history.extend(mapped.tool_messages);
908 deferred_followups.extend(mapped.followup_messages);
909 }
910 history.extend(deferred_followups);
911 history::trim(history, MAX_HISTORY);
912}
913
914fn record_dispatched_plan(history: &mut Vec<Message>, plan: &Plan, description: &str) {
923 let calls: Vec<serde_json::Value> = plan
924 .nodes
925 .iter()
926 .filter_map(|node| {
927 let call = node.call.as_ref()?;
928 let args = serde_json::from_str::<serde_json::Value>(&call.args_json)
929 .unwrap_or_else(|_| serde_json::Value::String(call.args_json.clone()));
930 Some(serde_json::json!({
931 "call_id": call.call_id,
932 "op_id": node.op_id,
933 "step": node.description,
934 "provider_id": call.provider_id,
935 "contract_id": call.contract_id,
936 "args": args,
937 }))
938 })
939 .collect();
940 if calls.is_empty() {
941 return;
942 }
943 let record = serde_json::json!({
944 "plan_id": plan.plan_id,
945 "description": description,
946 "calls": calls,
947 });
948 history.push(Message::user(&format!(
949 "Pilot harness dispatch record (already sent to Executor; not a new user request): {record}"
950 )));
951 history::trim(history, MAX_HISTORY);
952}
953
954#[allow(clippy::too_many_arguments)]
955pub async fn run_turn(
956 task: &Task,
957 history: &mut Vec<Message>,
958 standing_task: &mut Option<TaskState>,
959 vlm: &VlmClient,
960 executor: &mut ExecutorConn,
961 atlas: &mut AtlasClient,
962 consumer_id: &str,
963 tx: &mpsc::Sender<Result<PilotEvent, tonic::Status>>,
964 mut cancel_rx: watch::Receiver<bool>,
965 mut steer_rx: mpsc::Receiver<Task>,
966 plan_seq: Arc<AtomicU64>,
967 soma_prompt_block: &str,
968) -> Result<()> {
969 let session_id = task.session_id.clone();
970 let prompt_cache_key = Uuid::new_v4().simple().to_string();
973
974 macro_rules! return_interrupted {
975 ($forest:expr) => {{
976 cancel_forest_plans(executor, $forest, &session_id).await;
977 let _ = tx
978 .send(Ok(service::pack(
979 &session_id,
980 PilotStreamBody::Status(SessionStatusEvent {
981 session_id: session_id.clone(),
982 state: SessionState::Failed as u32,
983 message: "interrupted".to_string(),
984 }),
985 )))
986 .await;
987 return Ok(());
988 }};
989 }
990
991 if task_is_session_end(task) {
992 info!("[pilot] session_end: invoking compact_memory if available");
993 memory::try_compact(executor, atlas, consumer_id).await;
994 let _ = tx
995 .send(Ok(service::pack(
996 &session_id,
997 PilotStreamBody::Status(SessionStatusEvent {
998 session_id: session_id.clone(),
999 state: SessionState::Completed as u32,
1000 message: String::new(),
1001 }),
1002 )))
1003 .await;
1004 return Ok(());
1005 }
1006
1007 let standing_prompt = build_system_prompt(load_agent_soul().as_deref());
1009
1010 let _ = consumer_id; let initial_caps = discovery::discover(atlas)
1015 .await
1016 .map_err(|e| anyhow::anyhow!("atlas capability discovery failed: {e}"))?;
1017 let non_llm_callable_contract_ids = discovery::non_llm_callable_contract_ids(atlas)
1021 .await
1022 .map_err(|e| anyhow::anyhow!("atlas contract discovery failed: {e}"))?;
1023 let search_memory_target = initial_caps
1027 .iter()
1028 .find(|(_, cap)| cap.contract_id == "robonix/service/memory/search")
1029 .map(|(provider_id, cap)| (provider_id.clone(), cap.contract_id.clone()));
1030
1031 let memory_prompt = if skip_memory_prefetch(&task.text) {
1035 String::new()
1036 } else {
1037 match memory::prefetch(&task.text, executor, search_memory_target).await {
1038 Some(mem) => format!(
1039 "\n\n## Relevant past memories (historical hints only)\n\n\
1040 These entries may be stale or task-specific. They are not current robot state, \
1041 not authorization for a physical action, and not a substitute for resolving a \
1042 named room, region, object, or person through the current capabilities. In \
1043 particular, a remembered grasp or observation pose is not a room navigation \
1044 goal.\n\n{mem}\n\n---\n\n"
1045 ),
1046 None => String::new(),
1047 }
1048 };
1049
1050 let capability_docs_prompt = if let Ok(docs) = discovery::cap_md_index(atlas).await
1058 && !docs.is_empty()
1059 {
1060 let mut prompt = String::from(
1061 "\n\n## Capability docs (lazy-load via `read_capability_doc`)\n\
1062 The providers below ship a CAPABILITY.md manual. Read one by calling \
1063 the `read_capability_doc` builtin with its `provider_id` (shown in \
1064 backticks). IMPORTANT: before the FIRST time you call a capability of \
1065 a provider marked `[skill]`, read that provider's CAPABILITY.md first \
1066 — skills have multi-step usage (e.g. start → poll status → cancel) and \
1067 constraints the terse description omits. For primitives/services, \
1068 reading is optional. Never use `read_file` and never guess a file path \
1069 for docs; `read_capability_doc` is the only way, and only the providers \
1070 listed here have one.\n\n",
1071 );
1072 for d in &docs {
1073 let tag = if d.kind == "skill" { " `[skill]`" } else { "" };
1074 prompt.push_str(&format!(
1080 "- `{}`{}: {}\n",
1081 d.provider_id, tag, d.description
1082 ));
1083 }
1084 prompt
1085 } else {
1086 String::new()
1087 };
1088
1089 let voice_prompt = if task_modality(task).as_deref() == Some("voice") {
1096 "\n\n## Voice mode\n\n\
1097 The user is interacting via voice; this reply will be\n\
1098 spoken back through TTS. Keep the response short (≤ ~30\n\
1099 characters Chinese / ~50 words English), no markdown\n\
1100 lists, no headings, no code blocks, plain conversational\n\
1101 tone. If the answer genuinely needs structure, summarise\n\
1102 out loud and offer to elaborate when asked.\n"
1103 } else {
1104 ""
1105 };
1106
1107 history.push(Message::user(&task.text));
1109 history::trim(history, MAX_HISTORY);
1110 start_or_resume_task(standing_task, &task.text);
1111 if let Some(state) = standing_task.as_ref() {
1112 let _ = tx
1113 .send(Ok(service::pack(
1114 &session_id,
1115 PilotStreamBody::TaskState(TaskStateEvent {
1116 goal: state.goal.clone(),
1117 success_criterion: state.success_criterion.clone(),
1118 status: state.status.clone(),
1119 }),
1120 )))
1121 .await;
1122 }
1123
1124 let max_rounds = max_tool_rounds();
1125 let mut round: u32 = 0;
1126
1127 let (forest_tx, mut forest_rx) = mpsc::channel::<ForestEvent>(256);
1140 let mut forest: HashMap<String, TreeMeta> = HashMap::new();
1141 let mut cancel_requested: HashSet<String> = HashSet::new();
1142 let forest_revision = Arc::new(AtomicU64::new(0));
1143 let mut should_plan = true;
1144 let mut capability_prompt_cache = CapabilityPromptCache::default();
1145 let mut last_content = String::new();
1147
1148 'supervisor: loop {
1149 if *cancel_rx.borrow() {
1151 return_interrupted!(&forest);
1152 }
1153
1154 if !should_plan {
1155 let task_done = standing_task
1157 .as_ref()
1158 .map(TaskState::is_done)
1159 .unwrap_or(false);
1160 if forest.is_empty() {
1161 if task_done || standing_task.is_none() {
1162 let _ = tx
1163 .send(Ok(service::pack(
1164 &session_id,
1165 PilotStreamBody::FinalText(last_content.clone()),
1166 )))
1167 .await;
1168 break;
1169 }
1170 tokio::select! {
1175 biased;
1176 _ = cancel_rx.changed() => {
1177 return_interrupted!(&forest);
1178 }
1179 steer = steer_rx.recv() => {
1180 match steer {
1181 Some(task) => {
1182 if append_steer(task, history, standing_task) {
1183 history::trim(history, MAX_HISTORY);
1184 should_plan = true;
1185 }
1186 }
1187 None => break,
1188 }
1189 }
1190 }
1191 continue;
1192 }
1193 tokio::select! {
1196 biased;
1197 _ = cancel_rx.changed() => {
1198 return_interrupted!(&forest);
1199 }
1200 steer = steer_rx.recv() => {
1201 if let Some(task) = steer
1202 && append_steer(task, history, standing_task)
1203 {
1204 history::trim(history, MAX_HISTORY);
1205 should_plan = true;
1208 }
1209 }
1210 ev = forest_rx.recv() => {
1211 match ev {
1212 Some(ForestEvent::NodeState { plan_id, node_state }) => {
1213 let mut ns = *node_state;
1214 ns.plan_id = plan_id.clone();
1218 const TERMINAL: [u32; 4] = [2, 3, 4, 5];
1226 if TERMINAL.contains(&ns.state)
1227 && let Some(r) = ns.leaf_result.as_ref()
1228 {
1229 let description = forest
1230 .get(&plan_id)
1231 .map(|meta| meta.description.as_str())
1232 .unwrap_or("unknown tree");
1233 feed_results_into_history(
1234 history,
1235 &plan_id,
1236 description,
1237 std::slice::from_ref(r),
1238 );
1239 }
1240 if is_terminal_executor_state(ns.state)
1249 && ns.state != RtdlNodeStateEnum::Succeeded as u32
1250 && !cancel_requested.contains(&plan_id)
1251 && standing_task.as_ref().is_some_and(|state| !state.is_done())
1252 {
1253 should_plan = true;
1254 }
1255 log_node_state(&plan_id, &ns);
1256 let _ = tx
1260 .send(Ok(service::pack(
1261 &session_id,
1262 PilotStreamBody::NodeState(ns),
1263 )))
1264 .await;
1265 }
1266 Some(ForestEvent::PlanDone { plan_id, results, any_failed, canceled }) => {
1267 forest.remove(&plan_id);
1268 let requested_cancellation = cancel_requested.remove(&plan_id);
1269 if requested_cancellation {
1270 history.push(Message::user(&format!(
1271 "Pilot harness event: the requested cancellation of RTDL plan \
1272 {plan_id} is complete. Do not query or cancel that plan again. \
1273 Unrelated in-flight trees remain independent. If the current \
1274 interaction requested only this stop and has no successor action, \
1275 mark it done and report the completed stop now."
1276 )));
1277 history::trim(history, MAX_HISTORY);
1278 }
1279 log_plan_complete(&plan_id, &results, any_failed);
1282 let batch = BatchResult {
1283 plan_id: plan_id.clone(),
1284 session_id: session_id.clone(),
1285 round,
1286 results,
1287 any_failed,
1288 };
1289 let _ = tx
1290 .send(Ok(service::pack(
1291 &session_id,
1292 PilotStreamBody::BatchResult(batch),
1293 )))
1294 .await;
1295 if should_replan_after_plan_done(
1301 canceled,
1302 requested_cancellation,
1303 cancel_requested.is_empty(),
1304 standing_task.as_ref().is_some_and(|state| !state.is_done()),
1305 ) {
1306 should_plan = true;
1307 }
1308 }
1309 None => {
1310 should_plan = forest.is_empty();
1313 }
1314 }
1315 }
1316 }
1317 continue;
1318 }
1319
1320 should_plan = false;
1322
1323 drain_steers(&mut steer_rx, history, standing_task);
1326
1327 compact_history(history, vlm).await;
1330
1331 let cap_list = discovery::discover(atlas)
1334 .await
1335 .map_err(|e| anyhow::anyhow!("atlas capability discovery failed: {e}"))?;
1336
1337 let embodiment_block =
1338 crate::soma_context::fetch_runtime_prompt_block(atlas, consumer_id).await;
1339 let environment_block = state_context::collect(executor, atlas, &cap_list).await;
1340
1341 let display_caps = build_display_capabilities(&cap_list, &non_llm_callable_contract_ids);
1342 let target_map = build_capability_target_map(&display_caps);
1343 let protocol_prompt = rtdl_protocol(round == 0);
1344 let (capability_prompt, capability_cache_hit) =
1345 capability_prompt_cache.render(&display_caps);
1346
1347 let task_block = standing_task
1348 .as_ref()
1349 .map(TaskState::prompt_block)
1350 .unwrap_or_default();
1351 let forest_block = build_forest_block(&forest, &cancel_requested);
1352 let executor_active_block = fetch_executor_active_block(executor).await;
1353 let _ = tx
1354 .send(Ok(service::pack(
1355 &session_id,
1356 PilotStreamBody::Status(SessionStatusEvent {
1357 session_id: session_id.clone(),
1358 state: SessionState::Active as u32,
1359 message: "Planning the next step".to_string(),
1360 }),
1361 )))
1362 .await;
1363 let mut correction: Option<String> = None;
1369 let (assistant_content, rtdl_description, graph, meta_op, plan_id, task_update, recovered) = loop {
1370 let sections = [
1371 PromptSection {
1372 name: "standing_system",
1373 content: &standing_prompt,
1374 },
1375 PromptSection {
1376 name: "embodiment_static",
1377 content: soma_prompt_block,
1378 },
1379 PromptSection {
1380 name: "memory",
1381 content: &memory_prompt,
1382 },
1383 PromptSection {
1384 name: "capability_docs",
1385 content: &capability_docs_prompt,
1386 },
1387 PromptSection {
1388 name: "voice",
1389 content: voice_prompt,
1390 },
1391 PromptSection {
1392 name: "rtdl_protocol",
1393 content: protocol_prompt,
1394 },
1395 PromptSection {
1396 name: "capability_catalog",
1397 content: capability_prompt,
1398 },
1399 PromptSection {
1400 name: "task",
1401 content: &task_block,
1402 },
1403 PromptSection {
1404 name: "in_flight_trees",
1405 content: &forest_block,
1406 },
1407 PromptSection {
1408 name: "executor_state",
1409 content: &executor_active_block,
1410 },
1411 PromptSection {
1412 name: "embodiment_live",
1413 content: &embodiment_block,
1414 },
1415 PromptSection {
1416 name: "environment_live",
1417 content: &environment_block,
1418 },
1419 ];
1420 let messages = assemble_planning_messages(
1421 round,
1422 capability_cache_hit,
1423 §ions,
1424 history,
1425 correction.as_deref(),
1426 );
1427
1428 let planning_revision = forest_revision.load(Ordering::Acquire);
1429 let mut vlm_attempt = 0_u8;
1430 let (content, raw_tool_calls) = loop {
1431 let mut stream = match tokio::time::timeout(
1432 vlm_idle_timeout(),
1433 vlm.chat_stream(&messages, &[], Some(&prompt_cache_key)),
1434 )
1435 .await
1436 {
1437 Ok(Ok(stream)) => stream,
1438 Ok(Err(error)) if vlm_attempt == 0 => {
1439 warn!("[pilot/vlm] opening stream failed; retrying once: {error:#}");
1440 vlm_attempt += 1;
1441 continue;
1442 }
1443 Ok(Err(error)) => {
1444 return Err(anyhow::anyhow!("VLM stream error: {error:#}"));
1445 }
1446 Err(_) if vlm_attempt == 0 => {
1447 warn!("[pilot/vlm] opening stream timed out; retrying once");
1448 vlm_attempt += 1;
1449 continue;
1450 }
1451 Err(_) => return Err(anyhow::anyhow!("VLM stream open timed out")),
1452 };
1453 let mut full_text = String::new();
1454 let mut tool_calls: Vec<crate::vlm::ToolCall> = Vec::new();
1455
1456 let receive_result: anyhow::Result<()> = loop {
1457 tokio::select! {
1458 biased;
1459 _ = cancel_rx.changed() => {
1461 drop(stream);
1462 return_interrupted!(&forest);
1463 }
1464 steer = steer_rx.recv() => {
1465 if let Some(task) = steer {
1466 append_steer(task, history, standing_task);
1467 drain_steers(&mut steer_rx, history, standing_task);
1468 history::trim(history, MAX_HISTORY);
1469 }
1470 drop(stream);
1474 should_plan = true;
1475 continue 'supervisor;
1476 }
1477 item = stream.next() => {
1478 let item = match item {
1479 Some(Ok(it)) => it,
1480 Some(Err(error)) => break Err(anyhow::anyhow!("VLM stream recv: {error:#}")),
1481 None => break Ok(()),
1482 };
1483 match item {
1484 VlmStreamItem::TextDelta(delta) => full_text.push_str(&delta),
1485 VlmStreamItem::ToolCall(tc) => tool_calls.push(tc),
1486 VlmStreamItem::Usage(usage) => info!(
1487 "[pilot/prompt] {}",
1488 serde_json::json!({
1489 "round": round,
1490 "provider_prompt_tokens": usage.prompt_tokens,
1491 "provider_completion_tokens": usage.completion_tokens,
1492 "provider_cached_tokens": usage.cached_tokens,
1493 })
1494 ),
1495 VlmStreamItem::Finish => {}
1496 }
1497 }
1498 _ = tokio::time::sleep(vlm_idle_timeout()) => {
1499 break Err(anyhow::anyhow!("VLM stream idle timeout"));
1500 }
1501 }
1502 };
1503
1504 if let Err(error) = receive_result {
1505 if vlm_attempt == 0 {
1506 warn!("[pilot/vlm] {error:#}; retrying once");
1507 let _ = tx
1508 .send(Ok(service::pack(
1509 &session_id,
1510 PilotStreamBody::Status(SessionStatusEvent {
1511 session_id: session_id.clone(),
1512 state: SessionState::Active as u32,
1513 message: "VLM response delayed; retrying once".to_string(),
1514 }),
1515 )))
1516 .await;
1517 vlm_attempt += 1;
1518 continue;
1519 }
1520 return Err(error);
1521 }
1522
1523 let content = if full_text.is_empty() {
1524 None
1525 } else {
1526 Some(full_text)
1527 };
1528 break (content, tool_calls);
1529 };
1530
1531 if forest_revision.load(Ordering::Acquire) != planning_revision {
1532 should_plan = false;
1536 continue 'supervisor;
1537 }
1538
1539 if !raw_tool_calls.is_empty() {
1540 anyhow::bail!("VLM returned tool_calls in RTDL mode");
1541 }
1542
1543 let raw_content = content.unwrap_or_default();
1544 debug!("[pilot/rtdl/raw] raw_content={raw_content}");
1545 let parsed = parse_rtdl_assistant_response(&raw_content).with_context(|| {
1546 format!(
1547 "parse RTDL assistant response: {}",
1548 raw_preview(&raw_content)
1549 )
1550 });
1551 let RtdlEnvelope {
1552 content: assistant_content,
1553 rtdl_description,
1554 rtdl,
1555 task_update,
1556 } = match parsed {
1557 Ok(env) => env,
1558 Err(e) if correction.is_none() => {
1559 warn!("[pilot/rtdl] parse failed round={round}, retrying once: {e:#}");
1560 correction = Some(build_rtdl_retry_prompt(&e, &raw_content, &display_caps));
1561 continue;
1562 }
1563 Err(e) => {
1564 warn!(
1565 "[pilot/rtdl] parse failed again round={round}, ending turn gracefully: {e:#}"
1566 );
1567 let plan_id = String::new();
1568 let graph = empty_sequence_plan(plan_id.clone(), session_id.clone(), round);
1569 break (
1570 rtdl_recovery_final_text(),
1571 String::new(),
1572 Some(graph),
1573 None,
1574 plan_id,
1575 None,
1576 true,
1577 );
1578 }
1579 };
1580
1581 debug!(
1582 "[pilot/rtdl/raw] model_rtdl={}",
1583 serde_json::to_string(&rtdl).unwrap_or_else(|_| "<unserializable>".into())
1584 );
1585
1586 match parse_meta_plan_op(&rtdl).context("parse RTDL meta op") {
1587 Ok(Some(meta_op)) => {
1588 break (
1589 assistant_content,
1590 rtdl_description,
1591 None,
1592 Some(meta_op),
1593 String::new(),
1594 task_update,
1595 false,
1596 );
1597 }
1598 Ok(None) => {}
1599 Err(e) if correction.is_none() => {
1600 warn!("[pilot/rtdl] meta op invalid round={round}, retrying once: {e:#}");
1601 correction = Some(build_rtdl_retry_prompt(&e, &raw_content, &display_caps));
1602 continue;
1603 }
1604 Err(e) => {
1605 warn!(
1606 "[pilot/rtdl] meta op invalid again round={round}, ending turn gracefully: {e:#}"
1607 );
1608 let plan_id = String::new();
1609 let graph = empty_sequence_plan(plan_id.clone(), session_id.clone(), round);
1610 break (
1611 rtdl_recovery_final_text(),
1612 String::new(),
1613 Some(graph),
1614 None,
1615 plan_id,
1616 None,
1617 true,
1618 );
1619 }
1620 }
1621
1622 let plan_id = (plan_seq.fetch_add(1, Ordering::Relaxed) + 1).to_string();
1626 match expand_rtdl_to_plan(
1627 &rtdl,
1628 &target_map,
1629 plan_id.clone(),
1630 session_id.clone(),
1631 round,
1632 &rtdl_description,
1633 )
1634 .context("expand RTDL to Plan")
1635 {
1636 Ok(graph) => {
1640 break (
1641 assistant_content,
1642 rtdl_description,
1643 Some(graph),
1644 None,
1645 plan_id,
1646 task_update,
1647 false,
1648 );
1649 }
1650 Err(e) if correction.is_none() => {
1651 warn!("[pilot/rtdl] expand failed round={round}, retrying once: {e:#}");
1652 correction = Some(build_rtdl_retry_prompt(&e, &raw_content, &display_caps));
1653 }
1654 Err(e) => {
1655 warn!(
1656 "[pilot/rtdl] expand failed again round={round}, ending turn gracefully: {e:#}"
1657 );
1658 let plan_id = String::new();
1659 let graph = empty_sequence_plan(plan_id.clone(), session_id.clone(), round);
1660 break (
1661 rtdl_recovery_final_text(),
1662 String::new(),
1663 Some(graph),
1664 None,
1665 plan_id,
1666 None,
1667 true,
1668 );
1669 }
1670 }
1671 };
1672
1673 if recovered {
1677 if !assistant_content.is_empty() {
1678 history.push(Message::assistant(&assistant_content));
1679 }
1680 let _ = tx
1681 .send(Ok(service::pack(
1682 &session_id,
1683 PilotStreamBody::FinalText(assistant_content),
1684 )))
1685 .await;
1686 break;
1687 }
1688
1689 if let Some(meta_op) = meta_op {
1690 let targets = meta_op.cancellation_targets(&forest);
1691 if let Some(target) = invalid_cancel_target(&targets, &forest, &cancel_requested) {
1692 warn!("[pilot/harness] suppressed stale or duplicate meta op for plan {target}");
1693 history.push(Message::user(&format!(
1694 "Pilot harness feedback: plan-control target {target} is not active or is already stopping. Re-read In-flight trees and choose a currently listed plan_id. Do not retry a completed control operation."
1695 )));
1696 history::trim(history, MAX_HISTORY);
1697 should_plan = true;
1698 continue 'supervisor;
1699 }
1700 if let MetaPlanOp::StopAt { plan_id, op_id, .. } = &meta_op
1701 && forest
1702 .get(plan_id)
1703 .is_none_or(|meta| !meta.steps.iter().any(|step| step.op_id == *op_id))
1704 {
1705 warn!("[pilot/harness] suppressed stop_at for unknown op {plan_id}/{op_id}");
1706 history.push(Message::user(&format!(
1707 "Pilot harness feedback: RTDL plan {plan_id} has no listed target_op_id {op_id}. Copy an exact op_id from In-flight trees and do not guess which step is current."
1708 )));
1709 history::trim(history, MAX_HISTORY);
1710 should_plan = true;
1711 continue 'supervisor;
1712 }
1713
1714 if let Some(updated) = task_update {
1715 let changed = apply_task_update(standing_task, updated, false);
1716 if changed && let Some(state) = standing_task.as_ref() {
1717 let _ = tx
1718 .send(Ok(service::pack(
1719 &session_id,
1720 PilotStreamBody::TaskState(TaskStateEvent {
1721 goal: state.goal.clone(),
1722 success_criterion: state.success_criterion.clone(),
1723 status: state.status.clone(),
1724 }),
1725 )))
1726 .await;
1727 }
1728 }
1729 if !assistant_content.trim().is_empty() {
1730 history.push(Message::assistant(&assistant_content));
1731 history::trim(history, MAX_HISTORY);
1732 last_content = assistant_content.clone();
1733 let _ = tx
1734 .send(Ok(service::pack(
1735 &session_id,
1736 PilotStreamBody::TextChunk(assistant_content),
1737 )))
1738 .await;
1739 }
1740
1741 cancel_requested.extend(targets.iter().cloned());
1742 let result = execute_meta_plan_op(executor, &meta_op).await;
1743 round += 1;
1744 match result {
1745 Ok(message) => {
1746 info!("[pilot/control] {message}");
1747 history.push(Message::user(&format!(
1748 "Pilot plan-control result: {message} This was an out-of-band meta operation, not an RTDL tree. Do not issue it again."
1749 )));
1750 history::trim(history, MAX_HISTORY);
1751 let _ = tx
1752 .send(Ok(service::pack(
1753 &session_id,
1754 PilotStreamBody::Status(SessionStatusEvent {
1755 session_id: session_id.clone(),
1756 state: SessionState::Active as u32,
1757 message: "Plan control accepted".to_string(),
1758 }),
1759 )))
1760 .await;
1761 should_plan = targets.is_empty();
1764 }
1765 Err(error) => {
1766 warn!("[pilot/control] meta operation failed: {error:#}");
1767 for target in &targets {
1768 cancel_requested.remove(target);
1769 }
1770 history.push(Message::user(&format!(
1771 "Pilot plan-control failure: {error:#}. The operation was not accepted; inspect the current In-flight trees before deciding whether to retry."
1772 )));
1773 history::trim(history, MAX_HISTORY);
1774 should_plan = true;
1775 }
1776 }
1777 continue 'supervisor;
1778 }
1779
1780 let graph = graph.expect("non-meta RTDL response must carry a graph");
1781
1782 let calls = plan_call_count(&graph);
1783 let call_signatures = plan_call_signatures(&graph);
1784 let cancel_targets = plan_cancel_targets(&graph);
1785 if mixes_control_inspection_with_action(&graph) {
1786 warn!("[pilot/harness] suppressed mixed control inspection and action tree");
1787 history.push(Message::user(
1788 "Pilot harness feedback: legacy plan-control builtins cannot be mixed with business RTDL. Use a root cancel_plan, cancel_all, or stop_plan_at meta op instead; dispatch successor work only after control completion.",
1789 ));
1790 history::trim(history, MAX_HISTORY);
1791 should_plan = true;
1792 continue 'supervisor;
1793 }
1794 if let Some(target) = invalid_cancel_target(&cancel_targets, &forest, &cancel_requested) {
1795 warn!("[pilot/harness] suppressed stale or duplicate cancel for plan {target}");
1796 history.push(Message::user(
1797 "Pilot harness feedback: that legacy cancel target is not cancellable now. Re-read In-flight trees and use one root plan-control meta op; do not retry a finished target or create a cancel RTDL tree.",
1798 ));
1799 history::trim(history, MAX_HISTORY);
1800 should_plan = true;
1801 continue 'supervisor;
1802 }
1803 if let Some(duplicate) = duplicate_in_flight_signature(&call_signatures, &forest) {
1804 warn!("[pilot/harness] suppressed duplicate in-flight call: {duplicate}");
1805 history.push(Message::user(
1806 "Pilot harness feedback: that exact capability call is already in flight. Do not dispatch or cancel it again; wait for its result.",
1807 ));
1808 history::trim(history, MAX_HISTORY);
1809 should_plan = false;
1810 continue 'supervisor;
1811 }
1812
1813 if let Some(updated) = task_update {
1817 info!(
1818 "[pilot/rtdl] task_update goal='{}' status='{}'",
1819 updated.goal, updated.status
1820 );
1821 let changed = apply_task_update(standing_task, updated, calls == 0);
1822 if changed && let Some(state) = standing_task.as_ref() {
1823 let _ = tx
1824 .send(Ok(service::pack(
1825 &session_id,
1826 PilotStreamBody::TaskState(TaskStateEvent {
1827 goal: state.goal.clone(),
1828 success_criterion: state.success_criterion.clone(),
1829 status: state.status.clone(),
1830 }),
1831 )))
1832 .await;
1833 }
1834 }
1835
1836 log_plan_start(&graph, &rtdl_description, round, calls);
1837
1838 if !assistant_content.is_empty() {
1842 history.push(Message::assistant(&assistant_content));
1843 last_content = assistant_content.clone();
1844 }
1845
1846 round += 1;
1847 let hit_cap = round as usize >= max_rounds;
1848 let task_done = standing_task
1849 .as_ref()
1850 .map(TaskState::is_done)
1851 .unwrap_or(false);
1852
1853 if calls == 0 {
1854 if forest.is_empty() {
1859 if hit_cap && !(task_done || standing_task.is_none()) {
1860 warn!("[pilot] hit max tool rounds ({max_rounds}), stopping turn");
1861 }
1862 let reply = if assistant_content.trim().is_empty() && !task_done {
1863 "I need more information before I can continue.".to_string()
1864 } else {
1865 assistant_content
1866 };
1867 let _ = tx
1868 .send(Ok(service::pack(
1869 &session_id,
1870 PilotStreamBody::FinalText(reply),
1871 )))
1872 .await;
1873 break;
1874 }
1875 if !assistant_content.trim().is_empty() {
1880 let body = if task_done {
1881 PilotStreamBody::FinalText(assistant_content.clone())
1882 } else {
1883 PilotStreamBody::TextChunk(assistant_content.clone())
1884 };
1885 let _ = tx.send(Ok(service::pack(&session_id, body))).await;
1886 if !task_done {
1887 let _ = tx
1891 .send(Ok(service::pack(
1892 &session_id,
1893 PilotStreamBody::Status(SessionStatusEvent {
1894 session_id: session_id.clone(),
1895 state: SessionState::Active as u32,
1896 message: "Waiting for in-flight work".to_string(),
1897 }),
1898 )))
1899 .await;
1900 }
1901 }
1902 if hit_cap {
1903 warn!("[pilot] hit max tool rounds ({max_rounds}), stopping turn");
1904 break;
1905 }
1906 continue;
1909 }
1910
1911 if !assistant_content.trim().is_empty() {
1912 let _ = tx
1913 .send(Ok(service::pack(
1914 &session_id,
1915 PilotStreamBody::TextChunk(assistant_content.clone()),
1916 )))
1917 .await;
1918 }
1919
1920 let _ = tx
1923 .send(Ok(service::pack(
1924 &session_id,
1925 PilotStreamBody::Plan(graph.clone()),
1926 )))
1927 .await;
1928 cancel_requested.extend(cancel_targets);
1929 record_dispatched_plan(history, &graph, &rtdl_description);
1930 forest.insert(
1931 plan_id.clone(),
1932 TreeMeta {
1933 description: rtdl_description,
1934 control_only: is_control_only(&graph),
1935 call_signatures,
1936 steps: plan_steps(&graph),
1937 },
1938 );
1939 tokio::spawn(drive_plan(
1940 graph,
1941 executor.graph.clone(),
1942 forest_tx.clone(),
1943 Arc::clone(&forest_revision),
1944 ));
1945 info!(
1946 "[pilot/forest] plan_id={plan_id} dispatched forest_size={}",
1947 forest.len()
1948 );
1949
1950 if hit_cap {
1951 warn!("[pilot] hit max tool rounds ({max_rounds}), stopping turn");
1952 break;
1953 }
1954 }
1956
1957 let _ = tx
1959 .send(Ok(service::pack(
1960 &session_id,
1961 PilotStreamBody::Status(SessionStatusEvent {
1962 session_id: session_id.clone(),
1963 state: SessionState::Completed as u32,
1964 message: String::new(),
1965 }),
1966 )))
1967 .await;
1968
1969 Ok(())
1970}
1971
1972fn build_display_capabilities<'a>(
1975 cap_list: &'a [(String, atlas_pb::Capability)],
1976 non_llm_callable_contract_ids: &HashSet<String>,
1977) -> Vec<DisplayCapability<'a>> {
1978 let mut display = cap_list
1979 .iter()
1980 .filter(|(_, cap)| {
1981 !is_legacy_plan_control_contract(&cap.contract_id)
1982 && !non_llm_callable_contract_ids.contains(&cap.contract_id)
1983 })
1984 .map(|(provider_id, cap)| DisplayCapability {
1985 display_name: format!("{}.{}", provider_id, llm_name(&cap.contract_id)),
1986 provider_id: provider_id.as_str(),
1987 cap,
1988 })
1989 .collect::<Vec<_>>();
1990 display.sort_by(|left, right| left.display_name.cmp(&right.display_name));
1991 display
1992}
1993
1994fn is_legacy_plan_control_contract(contract_id: &str) -> bool {
1995 if !contract_id.starts_with("robonix/system/executor/builtin/") {
1996 return false;
1997 }
1998 matches!(
1999 contract_id.rsplit('/').next().unwrap_or_default(),
2000 "cancel_plan" | "cancel_all_plans" | "stop_plan_at" | "get_all_plans" | "get_plan_status"
2001 )
2002}
2003
2004fn build_capability_target_map(display_caps: &[DisplayCapability<'_>]) -> CapabilityTargetMap {
2005 let mut out = HashMap::new();
2006 for cap in display_caps {
2007 out.insert(
2008 cap.display_name.clone(),
2009 (cap.provider_id.to_string(), cap.cap.contract_id.clone()),
2010 );
2011 }
2012 out
2013}
2014
2015const RTDL_PROTOCOL_REMINDER: &str = r#"## RTDL output (same frozen contract as round 0)
2019Reply with exactly one JSON object and no surrounding prose:
2020{"content":"...","rtdl_description":"...","rtdl":<node>,"task_update":null|{"goal":"...","success_criterion":"...","status":"in_progress"|"done"}}
2021
2022Nodes are exactly one of:
2023- {"op":"sequence","op_id":0,"description":"...","children":[...]}
2024- {"op":"parallel","op_id":0,"description":"...","children":[...]}
2025- {"op":"do","op_id":0,"description":"...","cap":"<exact capability_name>","args":{...}}
2026Write op_id=0; Pilot assigns the real ID. Do not add node fields. Compose every currently-known dependent step into one sequence and every independent step into one parallel tree; do not drip one known call per round. With no new call, return an empty sequence.
2027
2028Plan control is the entire rtdl value, never a nested node or capability: cancel_plan, cancel_all, or stop_plan_at using an exact listed plan_id/op_id. Never repeat a completed/cancelled control operation and never cancel an unrelated tree after another tree fails.
2029
2030Resolve a named room through current Scene regions before navigation; never use a remembered grasp or observation pose as a room goal. A navigation SUCCEEDED result proves only the resolved requested destination; a zero-distance result does not prove that the robot moved. Never call a skill's cancel capability; Executor propagates RTDL cancellation.
2031
2032Copy each cap exactly from the current catalog. Executor feedback and dispatch records are scoped by plan_id/call_id and prove what was already sent; do not repeat a successful call. task_update.goal must exactly copy Current user interaction. Use status=done only when its success criterion is verified and its relevant tree is no longer running.
2033"#;
2034
2035fn rtdl_protocol(full: bool) -> &'static str {
2036 if full {
2037 include_str!("../rtdl_protocol.md")
2038 } else {
2039 RTDL_PROTOCOL_REMINDER
2040 }
2041}
2042
2043fn capability_prompt_fingerprint(display_caps: &[DisplayCapability<'_>]) -> u64 {
2046 let mut hasher = DefaultHasher::new();
2047 for cap in display_caps {
2048 cap.display_name.hash(&mut hasher);
2049 cap.provider_id.hash(&mut hasher);
2050 cap.cap.contract_id.hash(&mut hasher);
2051 cap.cap.description.hash(&mut hasher);
2052 if let Some(atlas_pb::transport_params::Kind::Mcp(mcp)) = cap
2053 .cap
2054 .params
2055 .as_ref()
2056 .and_then(|params| params.kind.as_ref())
2057 {
2058 mcp.input_schema_json.hash(&mut hasher);
2059 }
2060 }
2061 hasher.finish()
2062}
2063
2064fn render_capability_prompt(display_caps: &[DisplayCapability<'_>]) -> String {
2068 let mut prompt = String::from("\n## Available capabilities\n\n");
2069 for cap in display_caps {
2070 let c = cap.cap;
2071 let Some(atlas_pb::transport_params::Kind::Mcp(mcp)) =
2072 c.params.as_ref().and_then(|params| params.kind.as_ref())
2073 else {
2074 continue;
2075 };
2076 let schema: serde_json::Value =
2077 serde_json::from_str(&mcp.input_schema_json).unwrap_or(serde_json::Value::Null);
2078 let description =
2079 serde_json::to_string(c.description.trim()).unwrap_or_else(|_| "\"\"".to_string());
2080 prompt.push_str(&format!(
2081 "- capability_name: {}\n description: {}\n args_schema: {}\n",
2082 cap.display_name, description, schema
2083 ));
2084 }
2085 prompt
2086}
2087
2088#[derive(Debug)]
2090struct RtdlEnvelope {
2091 content: String,
2093 rtdl_description: String,
2096 rtdl: serde_json::Value,
2098 task_update: Option<TaskState>,
2101}
2102
2103fn extract_json_object(raw: &str) -> Option<&str> {
2119 let bytes = raw.as_bytes();
2120 let start = bytes.iter().position(|&b| b == b'{')?;
2121 let mut depth = 0usize;
2122 let mut in_str = false;
2123 let mut escaped = false;
2124 for (i, &b) in bytes.iter().enumerate().skip(start) {
2125 if in_str {
2126 if escaped {
2127 escaped = false;
2128 } else if b == b'\\' {
2129 escaped = true;
2130 } else if b == b'"' {
2131 in_str = false;
2132 }
2133 continue;
2134 }
2135 match b {
2136 b'"' => in_str = true,
2137 b'{' => depth += 1,
2138 b'}' => {
2139 depth -= 1;
2140 if depth == 0 {
2141 return Some(&raw[start..=i]);
2142 }
2143 }
2144 _ => {}
2145 }
2146 }
2147 None
2148}
2149
2150fn parse_rtdl_assistant_response(raw: &str) -> Result<RtdlEnvelope> {
2161 let candidate = extract_json_object(raw).unwrap_or(raw);
2162 let v: serde_json::Value = serde_json::from_str(candidate)?;
2163 let obj = v
2164 .as_object()
2165 .ok_or_else(|| anyhow::anyhow!("assistant response must be a JSON object"))?;
2166 const KEYS: [&str; 4] = ["content", "rtdl_description", "rtdl", "task_update"];
2167 if obj.len() != KEYS.len() || !KEYS.iter().all(|k| obj.contains_key(*k)) {
2168 anyhow::bail!(
2169 "assistant response must contain exactly `content`, `rtdl_description`, `rtdl`, and `task_update`"
2170 );
2171 }
2172 let content = obj
2173 .get("content")
2174 .and_then(|x| x.as_str())
2175 .ok_or_else(|| anyhow::anyhow!("assistant `content` must be a string"))?
2176 .to_string();
2177 let rtdl_description = obj
2178 .get("rtdl_description")
2179 .and_then(|x| x.as_str())
2180 .ok_or_else(|| anyhow::anyhow!("assistant `rtdl_description` must be a string"))?
2181 .to_string();
2182 let rtdl = obj
2183 .get("rtdl")
2184 .filter(|x| x.is_object())
2185 .ok_or_else(|| anyhow::anyhow!("assistant `rtdl` must be an object"))?
2186 .clone();
2187 let task_update = match obj.get("task_update") {
2188 None | Some(serde_json::Value::Null) => None,
2189 Some(v) => Some(parse_task_update(v)?),
2190 };
2191 Ok(RtdlEnvelope {
2192 content,
2193 rtdl_description,
2194 rtdl,
2195 task_update,
2196 })
2197}
2198
2199fn parse_task_update(v: &serde_json::Value) -> Result<TaskState> {
2204 let obj = v
2205 .as_object()
2206 .ok_or_else(|| anyhow::anyhow!("`task_update` must be null or an object"))?;
2207 const KEYS: [&str; 3] = ["goal", "success_criterion", "status"];
2208 if obj.len() != KEYS.len() || !KEYS.iter().all(|k| obj.contains_key(*k)) {
2209 anyhow::bail!(
2210 "`task_update` object must contain exactly `goal`, `success_criterion`, and `status`"
2211 );
2212 }
2213 let get = |key: &str| -> Result<String> {
2214 obj.get(key)
2215 .and_then(|x| x.as_str())
2216 .map(str::to_string)
2217 .ok_or_else(|| anyhow::anyhow!("`task_update.{key}` must be a string"))
2218 };
2219 let status = get("status")?;
2220 if status != "in_progress" && status != "done" {
2221 anyhow::bail!("`task_update.status` must be \"in_progress\" or \"done\"");
2222 }
2223 Ok(TaskState {
2224 goal: get("goal")?,
2225 success_criterion: get("success_criterion")?,
2226 status,
2227 })
2228}
2229
2230fn raw_preview(raw: &str) -> String {
2231 if raw.is_empty() {
2232 return "assistant content was empty".to_string();
2233 }
2234 let preview: String = raw.chars().take(240).collect();
2235 let ellipsis = if raw.chars().count() > 240 { "..." } else { "" };
2236 format!("assistant content preview: {preview:?}{ellipsis}")
2237}
2238
2239fn build_rtdl_retry_prompt(
2245 err: &anyhow::Error,
2246 raw_content: &str,
2247 display_caps: &[DisplayCapability<'_>],
2248) -> String {
2249 let mut p = format!(
2250 "Your previous RTDL response could not be parsed or expanded by Pilot.\n\
2251 Error: {err:#}\n\
2252 Previous response preview: {}\n\n\
2253 Fix the RTDL error and retry the same user request exactly once. If the error \
2254 mentions an unknown capability, do not repeat that capability. Return ONLY a JSON object \
2255 with exactly `content`, `rtdl_description`, `rtdl`, and `task_update`. The reply MUST begin \
2256 with `{{` and end with `}}`: no prose, narration, or markdown fences before or after it (put \
2257 any user-facing text inside `content`). Use only \
2258 capability_name values from this list; do not invent provider names, method names, or \
2259 aliases:\n",
2260 raw_preview(raw_content)
2261 );
2262 for cap in display_caps {
2263 p.push_str("- ");
2264 p.push_str(&cap.display_name);
2265 p.push('\n');
2266 }
2267 p.push_str(
2268 "\nIf no further capability call is needed, use \
2269 {\"op\":\"sequence\",\"children\":[]} as `rtdl`. If the user's requested action cannot \
2270 be performed using the listed capabilities, explain the missing capability in `content` \
2271 and return an empty RTDL sequence instead of inventing a capability.\n",
2272 );
2273 p
2274}
2275
2276fn empty_sequence_plan(plan_id: String, session_id: String, round: u32) -> Plan {
2280 Plan {
2281 plan_id,
2282 session_id,
2283 round,
2284 nodes: vec![RtdlNode {
2285 node_kind: RTDL_SEQUENCE,
2286 children: Vec::new(),
2287 call: None,
2288 op_id: "recovery".to_string(),
2289 description: "recovery: no valid plan produced".to_string(),
2290 }],
2291 root_index: 0,
2292 }
2293}
2294
2295fn rtdl_recovery_final_text() -> String {
2297 "I couldn't produce a valid robot plan after retrying once. Please try again or rephrase the request."
2298 .to_string()
2299}
2300
2301static OP_ID_SEQ: AtomicU64 = AtomicU64::new(0);
2308
2309fn next_op_id() -> String {
2311 (OP_ID_SEQ.fetch_add(1, Ordering::Relaxed) + 1).to_string()
2312}
2313
2314fn expand_rtdl_to_plan(
2315 rtdl: &serde_json::Value,
2316 target_map: &CapabilityTargetMap,
2317 plan_id: String,
2318 session_id: String,
2319 round: u32,
2320 root_description: &str,
2321) -> Result<Plan> {
2322 let mut nodes = Vec::new();
2323 let mut next_call = 0usize;
2324 let root_index = expand_rtdl_node(
2325 rtdl,
2326 "$",
2327 target_map,
2328 plan_id.as_str(),
2329 root_description,
2330 &mut next_call,
2331 &mut nodes,
2332 )?;
2333 Ok(Plan {
2334 plan_id,
2335 session_id,
2336 round,
2337 nodes,
2338 root_index,
2339 })
2340}
2341
2342fn pick_description(
2351 obj: &serde_json::Map<String, serde_json::Value>,
2352 path: &str,
2353 root_description: &str,
2354 synthesized: String,
2355) -> String {
2356 if let Some(d) = obj.get("description").and_then(|x| x.as_str()) {
2357 let d = d.trim();
2358 if !d.is_empty() {
2359 return d.to_string();
2360 }
2361 }
2362 if path == "$" && !root_description.is_empty() {
2363 return root_description.to_string();
2364 }
2365 synthesized
2366}
2367
2368fn reject_unknown_node_keys(
2376 obj: &serde_json::Map<String, serde_json::Value>,
2377 path: &str,
2378 op: &str,
2379 required: &[&str],
2380) -> Result<()> {
2381 const OPTIONAL: [&str; 2] = ["op_id", "description"];
2382 for key in obj.keys() {
2383 let known =
2384 key == "op" || required.contains(&key.as_str()) || OPTIONAL.contains(&key.as_str());
2385 if !known {
2386 anyhow::bail!("{path}: {op} node has unexpected field `{key}`");
2387 }
2388 }
2389 for req in required {
2390 if !obj.contains_key(*req) {
2391 anyhow::bail!("{path}: {op} node must contain `{req}`");
2392 }
2393 }
2394 Ok(())
2395}
2396
2397fn expand_rtdl_node(
2398 node: &serde_json::Value,
2399 path: &str,
2400 target_map: &CapabilityTargetMap,
2401 plan_id: &str,
2402 root_description: &str,
2403 next_call: &mut usize,
2404 nodes: &mut Vec<RtdlNode>,
2405) -> Result<u32> {
2406 let obj = node
2407 .as_object()
2408 .ok_or_else(|| anyhow::anyhow!("{path}: RTDL node must be an object"))?;
2409 let op = obj
2410 .get("op")
2411 .and_then(|x| x.as_str())
2412 .ok_or_else(|| anyhow::anyhow!("{path}.op must be a string"))?;
2413
2414 match op {
2415 "sequence" | "parallel" => {
2416 reject_unknown_node_keys(obj, path, op, &["children"])?;
2417 let children = obj
2418 .get("children")
2419 .and_then(|x| x.as_array())
2420 .ok_or_else(|| anyhow::anyhow!("{path}.children must be an array"))?;
2421 let node_index = nodes.len() as u32;
2422 let node_kind = if op == "sequence" {
2423 RTDL_SEQUENCE
2424 } else {
2425 RTDL_PARALLEL
2426 };
2427 let description = pick_description(
2428 obj,
2429 path,
2430 root_description,
2431 format!("{op} of {} step(s)", children.len()),
2432 );
2433 nodes.push(RtdlNode {
2434 node_kind,
2435 children: Vec::new(),
2436 call: None,
2437 op_id: next_op_id(),
2438 description,
2439 });
2440 let mut child_indices = Vec::with_capacity(children.len());
2441 for (idx, child) in children.iter().enumerate() {
2442 let child_index = expand_rtdl_node(
2443 child,
2444 &format!("{path}.children[{idx}]"),
2445 target_map,
2446 plan_id,
2447 root_description,
2448 next_call,
2449 nodes,
2450 )?;
2451 child_indices.push(child_index);
2452 }
2453 nodes[node_index as usize].children = child_indices;
2454 Ok(node_index)
2455 }
2456 "do" => {
2457 reject_unknown_node_keys(obj, path, op, &["cap", "args"])?;
2458 let cap = obj
2459 .get("cap")
2460 .and_then(|x| x.as_str())
2461 .ok_or_else(|| anyhow::anyhow!("{path}.cap must be a string"))?;
2462 let args = obj
2463 .get("args")
2464 .filter(|x| x.is_object())
2465 .ok_or_else(|| anyhow::anyhow!("{path}.args must be an object"))?;
2466 let (provider_id, contract_id) = target_map
2467 .get(cap)
2468 .cloned()
2469 .ok_or_else(|| anyhow::anyhow!("{path}.cap unknown capability `{cap}`"))?;
2470 let call_index = *next_call;
2471 *next_call += 1;
2472 let node_index = nodes.len() as u32;
2473 let description = pick_description(obj, path, root_description, format!("call {cap}"));
2474 nodes.push(RtdlNode {
2475 node_kind: RTDL_DO,
2476 children: Vec::new(),
2477 call: Some(CapabilityCall {
2478 call_id: format!("{plan_id}:{call_index}"),
2479 provider_id,
2480 contract_id,
2481 args_json: serde_json::to_string(args)?,
2482 }),
2483 op_id: next_op_id(),
2484 description,
2485 });
2486 Ok(node_index)
2487 }
2488 other => anyhow::bail!("{path}.op unknown operator `{other}`"),
2489 }
2490}
2491
2492fn plan_call_count(plan: &Plan) -> usize {
2493 plan.nodes
2494 .iter()
2495 .filter(|node| node.node_kind == RTDL_DO && node.call.is_some())
2496 .count()
2497}
2498
2499fn mixes_control_inspection_with_action(plan: &Plan) -> bool {
2500 let leaves: Vec<&str> = plan
2501 .nodes
2502 .iter()
2503 .filter_map(|node| node.call.as_ref())
2504 .filter_map(|call| call.contract_id.rsplit('/').next())
2505 .collect();
2506 let has_inspection = leaves
2507 .iter()
2508 .any(|leaf| matches!(*leaf, "get_plan_status" | "get_all_plans"));
2509 has_inspection
2510 && leaves
2511 .iter()
2512 .any(|leaf| !matches!(*leaf, "get_plan_status" | "get_all_plans"))
2513}
2514
2515fn plan_call_signatures(plan: &Plan) -> HashSet<String> {
2516 plan.nodes
2517 .iter()
2518 .filter_map(|node| node.call.as_ref())
2519 .map(|call| {
2520 let args = serde_json::from_str::<serde_json::Value>(&call.args_json)
2521 .ok()
2522 .and_then(|value| serde_json::to_string(&value).ok())
2523 .unwrap_or_else(|| call.args_json.clone());
2524 format!("{}|{}|{args}", call.provider_id, call.contract_id)
2525 })
2526 .collect()
2527}
2528
2529fn duplicate_in_flight_signature(
2530 signatures: &HashSet<String>,
2531 forest: &HashMap<String, TreeMeta>,
2532) -> Option<String> {
2533 signatures.iter().find_map(|signature| {
2534 forest
2535 .values()
2536 .any(|meta| meta.call_signatures.contains(signature))
2537 .then(|| signature.clone())
2538 })
2539}
2540
2541fn plan_cancel_targets(plan: &Plan) -> Vec<String> {
2542 plan.nodes
2543 .iter()
2544 .filter_map(|node| node.call.as_ref())
2545 .filter(|call| call.contract_id.rsplit('/').next() == Some("cancel_plan"))
2546 .filter_map(|call| serde_json::from_str::<serde_json::Value>(&call.args_json).ok())
2547 .filter_map(|args| {
2548 args.get("plan_id")
2549 .and_then(|value| value.as_str())
2550 .map(str::to_string)
2551 })
2552 .collect()
2553}
2554
2555fn invalid_cancel_target(
2556 targets: &[String],
2557 forest: &HashMap<String, TreeMeta>,
2558 cancel_requested: &HashSet<String>,
2559) -> Option<String> {
2560 targets.iter().find_map(|target| {
2561 let invalid = cancel_requested.contains(target)
2562 || forest.get(target).is_none_or(|meta| meta.control_only);
2563 invalid.then(|| target.clone())
2564 })
2565}
2566
2567fn should_replan_after_plan_done(
2568 canceled: bool,
2569 requested_cancellation: bool,
2570 cancellation_batch_complete: bool,
2571 interaction_active: bool,
2572) -> bool {
2573 interaction_active && (!canceled || (requested_cancellation && cancellation_batch_complete))
2574}
2575
2576fn rtdl_state_name(state: u32) -> String {
2578 match RtdlNodeStateEnum::try_from(state as i32) {
2579 Ok(RtdlNodeStateEnum::Pending) => "Pending".to_string(),
2580 Ok(RtdlNodeStateEnum::Running) => "Running".to_string(),
2581 Ok(RtdlNodeStateEnum::Succeeded) => "Succeeded".to_string(),
2582 Ok(RtdlNodeStateEnum::Failed) => "Failed".to_string(),
2583 Ok(RtdlNodeStateEnum::Canceled) => "Canceled".to_string(),
2584 Ok(RtdlNodeStateEnum::Timeout) => "Timeout".to_string(),
2585 Ok(RtdlNodeStateEnum::Paused) => "Paused".to_string(),
2586 Err(_) => format!("Unknown({state})"),
2587 }
2588}
2589
2590fn rtdl_node_kind_name(kind: u32) -> String {
2592 match kind {
2593 RTDL_SEQUENCE => "sequence".to_string(),
2594 RTDL_PARALLEL => "parallel".to_string(),
2595 RTDL_DO => "do".to_string(),
2596 _ => format!("unknown({kind})"),
2597 }
2598}
2599
2600fn compact_preview(value: &str, max_chars: usize) -> String {
2602 let flattened = value.replace('\n', "\\n");
2603 let mut preview: String = flattened.chars().take(max_chars).collect();
2604 if flattened.chars().count() > max_chars {
2605 preview.push_str("...");
2606 }
2607 preview
2608}
2609
2610fn compact_tool_result(contract_id: &str, value: &str, max_chars: usize) -> String {
2614 let original_chars = value.chars().count();
2615 if original_chars <= max_chars {
2616 return value.to_string();
2617 }
2618
2619 let projection = match contract_id {
2620 "robonix/system/scene/list_objects" => Some(("objects", &["id", "label"][..])),
2621 "robonix/system/scene/list_regions" => Some((
2622 "regions",
2623 &["id", "kind", "name", "stale", "stale_reason"][..],
2624 )),
2625 _ => None,
2626 };
2627 if let (Some((array_key, fields)), Ok(parsed)) =
2628 (projection, serde_json::from_str::<serde_json::Value>(value))
2629 && let Some(items) = parsed.get(array_key).and_then(|entry| entry.as_array())
2630 {
2631 let mut projected: Vec<serde_json::Value> = items
2632 .iter()
2633 .map(|item| {
2634 let mut record = serde_json::Map::new();
2635 if let Some(source) = item.as_object() {
2636 for field in fields {
2637 if let Some(field_value) = source.get(*field) {
2638 record.insert((*field).to_string(), field_value.clone());
2639 }
2640 }
2641 }
2642 serde_json::Value::Object(record)
2643 })
2644 .collect();
2645 loop {
2646 let returned = projected.len();
2647 let mut root = serde_json::Map::new();
2648 root.insert(
2649 array_key.to_string(),
2650 serde_json::Value::Array(projected.clone()),
2651 );
2652 for key in ["map_id", "stamp_unix"] {
2653 if let Some(field_value) = parsed.get(key) {
2654 root.insert(key.to_string(), field_value.clone());
2655 }
2656 }
2657 root.insert(
2658 "_robonix_truncation".to_string(),
2659 serde_json::json!({
2660 "truncated": true,
2661 "complete_record_index": returned == items.len(),
2662 "original_chars": original_chars,
2663 "total_records": items.len(),
2664 "returned_records": returned,
2665 "omitted_fields": true,
2666 "instruction": "Use a narrower capability for full record details; never infer absence when complete_record_index is false."
2667 }),
2668 );
2669 let encoded = serde_json::Value::Object(root).to_string();
2670 if encoded.chars().count() <= max_chars {
2671 return encoded;
2672 }
2673 if projected.is_empty() {
2674 break;
2675 }
2676 projected.pop();
2677 }
2678 }
2679
2680 let mut preview_chars = max_chars / 3;
2681 loop {
2682 let encoded = serde_json::json!({
2683 "_robonix_truncation": {
2684 "truncated": true,
2685 "original_chars": original_chars,
2686 "complete": false,
2687 "instruction": "The preview is incomplete; do not infer that an omitted value is absent."
2688 },
2689 "preview": compact_preview(value, preview_chars),
2690 })
2691 .to_string();
2692 if encoded.chars().count() <= max_chars || preview_chars == 0 {
2693 return encoded;
2694 }
2695 preview_chars /= 2;
2696 }
2697}
2698
2699fn call_display_name(call: &CapabilityCall) -> String {
2701 format!("{}.{}", call.provider_id, llm_name(&call.contract_id))
2702}
2703
2704fn append_plan_node_summary(plan: &Plan, node_index: usize, depth: usize, out: &mut Vec<String>) {
2706 let Some(node) = plan.nodes.get(node_index) else {
2707 out.push(format!("{}[{node_index}] missing-node", " ".repeat(depth)));
2708 return;
2709 };
2710 let indent = " ".repeat(depth);
2711 let mut line = format!(
2712 "{indent}[{node_index}] {} op_id={} desc='{}'",
2713 rtdl_node_kind_name(node.node_kind),
2714 node.op_id,
2715 compact_preview(&node.description, 160),
2716 );
2717 if let Some(call) = node.call.as_ref() {
2718 line.push_str(&format!(
2719 " cap={} args={}",
2720 call_display_name(call),
2721 compact_preview(&call.args_json, 240)
2722 ));
2723 }
2724 out.push(line);
2725 for child in &node.children {
2726 append_plan_node_summary(plan, *child as usize, depth + 1, out);
2727 }
2728}
2729
2730fn format_plan_summary(plan: &Plan) -> Vec<String> {
2732 let mut lines = Vec::new();
2733 append_plan_node_summary(plan, plan.root_index as usize, 0, &mut lines);
2734 lines
2735}
2736
2737fn log_plan_start(plan: &Plan, description: &str, round: u32, calls: usize) {
2739 info!(
2740 "[pilot/rtdl] -- plan start plan_id={} round={} calls={} --",
2741 plan.plan_id, round, calls
2742 );
2743 info!(
2744 "[pilot/rtdl] rtdl_plan_description='{}'",
2745 compact_preview(description, 240)
2746 );
2747 info!("[pilot/rtdl] rtdl_plan:");
2748 for line in format_plan_summary(plan) {
2749 info!("[pilot/rtdl] {line}");
2750 }
2751}
2752
2753fn terminal_node_detail(ns: &RtdlNodeState) -> String {
2755 if !is_terminal_executor_state(ns.state) || ns.state == RtdlNodeStateEnum::Succeeded as u32 {
2756 return String::new();
2757 }
2758 let Some(result) = ns.leaf_result.as_ref() else {
2759 return String::new();
2760 };
2761 if !result.error.trim().is_empty() {
2762 return format!(" error='{}'", compact_preview(&result.error, 180));
2763 }
2764 if !result.output.trim().is_empty() {
2765 return format!(" output='{}'", compact_preview(&result.output, 180));
2766 }
2767 String::new()
2768}
2769
2770fn log_node_state(plan_id: &str, ns: &RtdlNodeState) {
2772 let mut line = format!(
2773 "[pilot/forest] plan_id={} node={} op_id={} state={} desc='{}'",
2774 plan_id,
2775 ns.node_index,
2776 ns.op_id,
2777 rtdl_state_name(ns.state),
2778 compact_preview(&ns.description, 160),
2779 );
2780 if !ns.operator_detail.trim().is_empty() {
2781 line.push_str(&format!(
2782 " detail='{}'",
2783 compact_preview(&ns.operator_detail, 180)
2784 ));
2785 }
2786 line.push_str(&terminal_node_detail(ns));
2787 debug!("{line}");
2788}
2789
2790fn plan_completion_state(results: &[RtdlNodeState], any_failed: bool) -> String {
2792 if !any_failed {
2793 return "Succeeded".to_string();
2794 }
2795 for preferred in [
2796 RtdlNodeStateEnum::Failed as u32,
2797 RtdlNodeStateEnum::Timeout as u32,
2798 RtdlNodeStateEnum::Canceled as u32,
2799 ] {
2800 if results.iter().any(|ns| ns.state == preferred) {
2801 return rtdl_state_name(preferred);
2802 }
2803 }
2804 results
2805 .iter()
2806 .find(|ns| ns.state != RtdlNodeStateEnum::Succeeded as u32)
2807 .map(|ns| rtdl_state_name(ns.state))
2808 .unwrap_or_else(|| "Failed".to_string())
2809}
2810
2811fn log_plan_complete(plan_id: &str, results: &[RtdlNodeState], any_failed: bool) {
2813 let state = plan_completion_state(results, any_failed);
2814 let mut line = format!(
2815 "[pilot/forest] plan_id={} complete state={} terminal_nodes={}",
2816 plan_id,
2817 state,
2818 results.len()
2819 );
2820 let non_success: Vec<String> = results
2821 .iter()
2822 .filter(|ns| ns.state != RtdlNodeStateEnum::Succeeded as u32)
2823 .map(|ns| format!("node={} state={}", ns.node_index, rtdl_state_name(ns.state)))
2824 .collect();
2825 if !non_success.is_empty() {
2826 line.push_str(" non_success=[");
2827 line.push_str(&non_success.join(", "));
2828 line.push(']');
2829 }
2830 line.push_str("; replanning");
2831 info!("{line}");
2832}
2833
2834fn is_terminal_executor_state(state: u32) -> bool {
2835 matches!(
2836 RtdlNodeStateEnum::try_from(state as i32),
2837 Ok(RtdlNodeStateEnum::Succeeded
2838 | RtdlNodeStateEnum::Failed
2839 | RtdlNodeStateEnum::Canceled
2840 | RtdlNodeStateEnum::Timeout)
2841 )
2842}
2843
2844fn rtdl_result_to_messages(r: &CapabilityCallResult) -> history::ToolResultHistory {
2845 let mapped = if r.success {
2846 history::tool_result_to_messages(&r.call_id, &r.output)
2847 } else {
2848 history::ToolResultHistory {
2849 tool_messages: vec![Message::user(&r.output)],
2850 followup_messages: vec![],
2851 }
2852 };
2853
2854 let tool_messages = mapped
2855 .tool_messages
2856 .into_iter()
2857 .map(|msg| {
2858 let output = msg.content.unwrap_or_default();
2859 let feedback = serde_json::json!({
2860 "leaf_result": {
2861 "call_id": r.call_id,
2862 "contract_id": r.contract_id,
2863 "success": r.success,
2864 "output": output,
2865 "error": r.error,
2866 }
2867 });
2868 Message::user(&format!(
2869 "Executor feedback for the current RTDL leaf (not a new user request): {}",
2870 feedback
2871 ))
2872 })
2873 .collect();
2874
2875 history::ToolResultHistory {
2876 tool_messages,
2877 followup_messages: mapped.followup_messages,
2878 }
2879}
2880
2881fn load_agent_soul() -> Option<String> {
2888 if let Ok(p) = std::env::var("ROBONIX_PILOT_SOUL") {
2889 let p = p.trim();
2890 if !p.is_empty() {
2891 return std::fs::read_to_string(p).ok();
2892 }
2893 }
2894 let home = std::env::var_os("HOME").map(PathBuf::from)?;
2895 let soul = home.join(".robonix").join("SOUL.md");
2896 if soul.is_file() {
2897 return std::fs::read_to_string(soul).ok();
2898 }
2899 None
2900}
2901
2902fn build_system_prompt(soul: Option<&str>) -> String {
2903 let mut p = String::new();
2904 if let Some(s) = soul {
2905 let t = s.trim();
2906 if !t.is_empty() {
2907 p.push_str("## Agent SOUL\n\n");
2908 p.push_str(t);
2909 p.push_str("\n\n---\n\n");
2910 }
2911 }
2912 p.push_str(
2913 "\
2914You are the Robonix Pilot — the reasoning and planning component of a robot system.
2915You receive requests from a user or higher-level system and translate them into actions
2916by planning capability calls available to you.
2917
2918## Operating principles
2919- ACT immediately using available capabilities. Do not ask the user to run things themselves.
2920- Each capability call you plan is dispatched to the Executor runtime, which handles the
2921 actual robot hardware or service call.
2922- COMPOSE multi-step RTDL trees. When you already know several steps that don't
2923 depend on each other's results, put them ALL in one `sequence` (ordered) or
2924 `parallel` (independent) tree in a single round — that is the entire point of
2925 RTDL. Emitting one single-node tree per round (ReAct-style drip) is wrong
2926 UNLESS the next step genuinely needs to see the previous step's result.
2927- Do NOT claim missing capabilities unless verified from the current capability list/results.
2928 - If `memory_search` / `memory_save` / `memory_compact` capabilities are available,
2929 treat long-term memory as available via those capabilities.
2930- Prefer structured output; report capability results concisely.
2931- Scope every result to the `plan_id` and independent RTDL tree named in its
2932 Executor feedback. If a capability fails, times out, returns success=false,
2933 or gives an unsafe/unexpected result, stop only steps that depend on that
2934 result. Report that branch failure, but let unrelated in-flight trees continue.
2935 Never cancel a different in-flight tree merely because this tree failed.
2936- Cancel a running tree only when the latest user steer explicitly asks to stop
2937 work covered by that tree, or when continuing that same tree is unsafe. A
2938 failure in an independent monitoring, greeting, observation, or query branch
2939 is not permission to cancel navigation or another physical task.
2940- For any boundary stop, select the explicitly requested step from the ordered
2941 in-flight RTDL step list and call `builtin_stop_plan_at` once. The target may
2942 be any step in the plan; never assume it means the currently running step.
2943 Use `on_complete` for 'after step X' and `on_enter` for 'before step X'.
2944 Bind X itself; never substitute X's predecessor or successor.
2945- Do not execute a later physical step unless its required earlier steps have succeeded.
2946- For semantic navigation, resolve names through Scene before calling navigation:
2947 - call Scene `list_regions` first to discover the stable ID for a named room
2948 or region, and call `list_objects` for a physical object; pass that exact
2949 full ID to the goal tool, never its label, room number, or a guessed ID;
2950 use `get_scene_graph` only when object relationships are needed;
2951 - named rooms or regions MUST use Scene `goal_room`; never use `goal_near`,
2952 Memory coordinates, or guessed coordinates for a room destination;
2953 - physical objects MUST use Scene `goal_near` to obtain an approach pose;
2954 - call navigation only when Scene returns `reachable=true`.
2955- Long-term Memory is historical context, not live spatial state. It may help
2956 recall what the user called a place, but it never replaces current Scene
2957 resolution for a named destination and never turns a task-specific grasp or
2958 observation pose into a room goal.
2959- After navigation, relate the result to the resolved requested destination.
2960 A transport/action status of `SUCCEEDED` does not by itself prove that the
2961 requested movement occurred: if the submitted pose was already the current
2962 pose, state that the robot was already there instead of claiming it moved.
2963- Some later messages may be labelled `Executor feedback for the current task`.
2964 Treat those as results of capability calls you already planned, not as new
2965 user requests.
2966- `Pilot harness dispatch record` messages are the authoritative record of RTDL
2967 calls already sent to Executor. Correlate each result by `plan_id` and
2968 `call_id`. When a recorded step succeeds, do not plan that same user-requested
2969 step again from newly observed state; use the recorded args and result to
2970 decide whether the success criterion is met. A genuinely different dependent
2971 step may still use the same capability.
2972- If executor feedback already contains enough information to answer the
2973 user's request, answer in `content`, set `task_update.status` to `done`, and
2974 output an empty RTDL sequence. Do not repeat the same observation capability
2975 just to confirm unchanged data.
2976
2977## Interaction and execution lifetime
2978The harness owns the latest instruction shown in \"Current user interaction\".
2979Copy it exactly into a non-null `task_update.goal`; `task_update` reports
2980progress and never replaces user intent. Older conversation remains in message
2981history. Independently running work appears only in \"In-flight RTDL trees\"
2982and may outlive this interaction. Preserve unrelated trees; if the latest
2983instruction conflicts with one, target that specific plan with cancel/stop
2984before dispatching its replacement.
2985
2986Mark the current interaction `done` once its own requested outcome is verified,
2987even when an unrelated long-running tree remains active. An empty RTDL sequence
2988alone does not prove completion; it may also mean waiting for an in-flight tree.
2989Concretely:
2990
2991- Set a concrete `task_update.success_criterion` as soon as you understand the
2992 goal (e.g. for 'turn around': yaw delta ≈ 180° from the starting pose; for
2993 'find the door': a door is visible in a camera observation).
2994- For pure observation or visual question-answering tasks, one successful
2995 observation is usually enough. After answering from that observation, mark
2996 `status: \"done\"` with an empty RTDL sequence.
2997- For tasks that change robot or world state, batch the steps you can already
2998 foresee into one tree, then verify at meaningful checkpoints — not after
2999 literally every action. Re-observe and re-plan when the NEXT step depends on
3000 what you'd see (e.g. you must confirm an object moved before grasping it), not
3001 as a reflex after each call.
3002- A single short chassis movement burst typically rotates ~0.4–0.8 rad
3003 (≈ 25–45°) or translates ~0.1–0.2 m. To turn 180° you need MULTIPLE
3004 bursts; do not assume one call finishes the rotation.
3005- Only mark `status: \"done\"` once the criterion is met OR you've exhausted
3006 reasonable attempts and need to report a blocker. 'Done.' with no
3007 verification is wrong — verify first.
3008- On the very rare case where the user explicitly cancels, you may stop
3009 early; otherwise keep going.
3010- For every action-producing RTDL response, put one concise user-facing progress
3011 update in `content` that says what is happening now. Do not repeat an unchanged
3012 update. When the task completes or needs clarification, use `content` for the
3013 concise final result or question.
3014",
3015 );
3016 p
3017}
3018
3019#[cfg(test)]
3020mod tests {
3021 use super::{
3022 CapabilityPromptCache, CapabilityTargetMap, DEFAULT_SUCCESS_CRITERION, MetaPlanOp, RTDL_DO,
3023 RTDL_PARALLEL, RTDL_PROTOCOL_REMINDER, RTDL_SEQUENCE, TaskState, TreeMeta, TreeStep,
3024 append_steer, apply_task_update, build_capability_target_map, build_display_capabilities,
3025 build_executor_active_block, build_forest_block, compact_tool_result,
3026 configured_vlm_idle_timeout, duplicate_in_flight_signature, expand_rtdl_to_plan,
3027 extract_json_object, feed_results_into_history, format_plan_summary, invalid_cancel_target,
3028 is_control_only, is_legacy_plan_control_contract, mixes_control_inspection_with_action,
3029 parse_meta_plan_op, parse_rtdl_assistant_response, parse_task_update, plan_call_signatures,
3030 record_dispatched_plan, rtdl_node_kind_name, rtdl_recovery_final_text, rtdl_state_name,
3031 should_replan_after_plan_done, skip_memory_prefetch, start_or_resume_task,
3032 task_is_session_end,
3033 };
3034 use crate::pb::pilot::{CapabilityCall, CapabilityCallResult, Plan, RtdlNode, Task};
3035 use robonix_atlas::pb as atlas_pb;
3036 use serde_json::json;
3037 use std::collections::{HashMap, HashSet};
3038 use std::time::Duration;
3039
3040 #[test]
3041 fn vlm_idle_timeout_is_bounded_and_has_a_responsive_default() {
3042 assert_eq!(configured_vlm_idle_timeout(None), Duration::from_secs(30));
3043 assert_eq!(
3044 configured_vlm_idle_timeout(Some("1")),
3045 Duration::from_secs(5)
3046 );
3047 assert_eq!(
3048 configured_vlm_idle_timeout(Some("600")),
3049 Duration::from_secs(300)
3050 );
3051 assert_eq!(
3052 configured_vlm_idle_timeout(Some("bad")),
3053 Duration::from_secs(30)
3054 );
3055 }
3056
3057 fn test_capability(provider: &str, leaf: &str) -> (String, atlas_pb::Capability) {
3058 (
3059 provider.to_string(),
3060 atlas_pb::Capability {
3061 provider_id: provider.to_string(),
3062 contract_id: format!("robonix/service/test/{leaf}"),
3063 transport: atlas_pb::Transport::Mcp as i32,
3064 params: Some(atlas_pb::TransportParams {
3065 kind: Some(atlas_pb::transport_params::Kind::Mcp(atlas_pb::McpParams {
3066 input_schema_json: format!(
3067 r#"{{"type":"object","properties":{{"{leaf}":{{"type":"string"}}}}}}"#
3068 ),
3069 })),
3070 }),
3071 description: format!("Run {leaf}"),
3072 ..Default::default()
3073 },
3074 )
3075 }
3076
3077 #[test]
3078 fn later_round_protocol_is_compact_but_keeps_admission_rules() {
3079 assert!(RTDL_PROTOCOL_REMINDER.len() < 2_000);
3080 for required in [
3081 "capability_name",
3082 "sequence",
3083 "parallel",
3084 "cancel_plan",
3085 "plan_id/call_id",
3086 "task_update.goal",
3087 "Scene regions",
3088 "Never call a skill's cancel capability",
3089 ] {
3090 assert!(RTDL_PROTOCOL_REMINDER.contains(required));
3091 }
3092 }
3093
3094 #[test]
3095 fn stable_catalog_is_cached_and_three_step_tree_stays_one_plan() {
3096 let capabilities = vec![
3097 test_capability("demo", "observe"),
3098 test_capability("demo", "remember"),
3099 test_capability("demo", "report"),
3100 ];
3101 let display = build_display_capabilities(&capabilities, &HashSet::new());
3102 let mut cache = CapabilityPromptCache::default();
3103 let (first, first_hit) = cache.render(&display);
3104 let first = first.to_string();
3105 let (second, second_hit) = cache.render(&display);
3106 assert!(!first_hit);
3107 assert!(second_hit);
3108 assert_eq!(first, second);
3109
3110 let targets = build_capability_target_map(&display);
3111 let rtdl = json!({
3112 "op": "sequence",
3113 "op_id": 0,
3114 "description": "observe, remember, then report",
3115 "children": [
3116 {"op":"do","op_id":0,"description":"observe","cap":"demo.test_observe","args":{"observe":"room"}},
3117 {"op":"do","op_id":0,"description":"remember","cap":"demo.test_remember","args":{"remember":"room"}},
3118 {"op":"do","op_id":0,"description":"report","cap":"demo.test_report","args":{"report":"room"}}
3119 ]
3120 });
3121 let plan =
3122 expand_rtdl_to_plan(&rtdl, &targets, "p".into(), "s".into(), 1, "multi-step").unwrap();
3123 assert_eq!(super::plan_call_count(&plan), 3);
3124 assert_eq!(plan.round, 1);
3125 }
3126
3127 #[test]
3128 fn contract_metadata_excludes_capability_from_model_catalog() {
3129 let capabilities = vec![
3130 test_capability("pick", "pick"),
3131 test_capability("vlm_verifier", "verify"),
3132 ];
3133 let hidden = HashSet::from([capabilities[1].1.contract_id.clone()]);
3134
3135 let display = build_display_capabilities(&capabilities, &hidden);
3136
3137 assert_eq!(display.len(), 1);
3138 assert_eq!(display[0].provider_id, "pick");
3139 }
3140
3141 #[test]
3142 fn large_region_results_remain_valid_json_and_keep_stable_ids() {
3143 let regions: Vec<_> = (0..24)
3144 .map(|index| {
3145 json!({
3146 "id": format!("scene.room.anno.{index}"),
3147 "kind": "room",
3148 "name": format!("room {index}"),
3149 "points_xy": vec![index as f64; 300],
3150 "stale": false,
3151 "stale_reason": "",
3152 })
3153 })
3154 .collect();
3155 let original = json!({
3156 "regions": regions,
3157 "map_id": "3f_demo",
3158 "stamp_unix": 123.0,
3159 })
3160 .to_string();
3161 assert!(original.chars().count() > 4096);
3162
3163 let compact = compact_tool_result("robonix/system/scene/list_regions", &original, 4096);
3164 let parsed: serde_json::Value = serde_json::from_str(&compact).unwrap();
3165 let compact_regions = parsed["regions"].as_array().unwrap();
3166 assert_eq!(compact_regions.len(), 24);
3167 assert_eq!(compact_regions[23]["id"], "scene.room.anno.23");
3168 assert_eq!(parsed["_robonix_truncation"]["complete_record_index"], true);
3169 assert!(compact.chars().count() <= 4096);
3170 }
3171
3172 #[test]
3173 fn arbitrary_large_text_is_marked_incomplete_in_valid_json() {
3174 let compact = compact_tool_result("example/large", &"x".repeat(9000), 4096);
3175 let parsed: serde_json::Value = serde_json::from_str(&compact).unwrap();
3176 assert_eq!(parsed["_robonix_truncation"]["truncated"], true);
3177 assert_eq!(parsed["_robonix_truncation"]["complete"], false);
3178 assert!(compact.chars().count() <= 4096);
3179 }
3180
3181 #[test]
3182 fn malformed_image_shape_is_bounded_as_text() {
3183 let original = json!({
3184 "width": 640,
3185 "height": 480,
3186 "encoding": "error",
3187 "data": "x".repeat(9000),
3188 })
3189 .to_string();
3190 assert!(!crate::history::is_image_output(&original));
3191 let compact = compact_tool_result("camera/snapshot", &original, 4096);
3192 assert!(compact.chars().count() <= 4096);
3193 }
3194
3195 #[test]
3196 fn oversized_projected_scalar_and_escaped_preview_stay_bounded() {
3197 let original = json!({
3198 "regions": [{
3199 "id": format!("scene.room.{}", "\\\"\n".repeat(3000)),
3200 "kind": "room",
3201 "name": "large",
3202 }],
3203 "map_id": "demo",
3204 })
3205 .to_string();
3206 let compact = compact_tool_result("robonix/system/scene/list_regions", &original, 4096);
3207 serde_json::from_str::<serde_json::Value>(&compact).unwrap();
3208 assert!(compact.chars().count() <= 4096);
3209 }
3210
3211 #[test]
3212 fn executor_snapshot_is_authoritative_across_turns() {
3213 let block = build_executor_active_block(Some(
3214 r#"{"count":99,"plans":[{"plan_id":"8","description":"greet","ops":[]}]}"#,
3215 ));
3216 assert!(block.contains("\"count\":1"));
3217 assert!(block.contains("\"plan_id\":\"8\""));
3218 assert!(block.contains("long-running skills started by earlier interactions"));
3219 }
3220
3221 #[test]
3222 fn unavailable_executor_snapshot_forbids_guessing_zero() {
3223 let block = build_executor_active_block(None);
3224 assert!(block.contains("status: unavailable"));
3225 assert!(block.contains("Never guess a task count"));
3226 }
3227
3228 #[test]
3229 fn only_requested_cancellation_replans_for_final_confirmation() {
3230 assert!(should_replan_after_plan_done(false, false, true, true));
3231 assert!(should_replan_after_plan_done(true, true, true, true));
3232 assert!(!should_replan_after_plan_done(true, true, false, true));
3233 assert!(!should_replan_after_plan_done(true, false, true, true));
3234 assert!(!should_replan_after_plan_done(true, true, true, false));
3235 }
3236
3237 #[test]
3238 fn root_meta_ops_parse_without_becoming_rtdl_nodes() {
3239 assert_eq!(
3240 parse_meta_plan_op(&json!({"op":"cancel_plan","plan_id":"7"})).unwrap(),
3241 Some(MetaPlanOp::Cancel {
3242 plan_id: "7".into(),
3243 wait_ms: 5_000,
3244 })
3245 );
3246 assert_eq!(
3247 parse_meta_plan_op(&json!({"op":"cancel_all","wait_ms":99_999})).unwrap(),
3248 Some(MetaPlanOp::CancelAll { wait_ms: 30_000 })
3249 );
3250 assert_eq!(
3251 parse_meta_plan_op(&json!({
3252 "op":"stop_plan_at",
3253 "plan_id":"9",
3254 "target_op_id":"13",
3255 "when":"on_enter"
3256 }))
3257 .unwrap(),
3258 Some(MetaPlanOp::StopAt {
3259 plan_id: "9".into(),
3260 op_id: "13".into(),
3261 when: "on_enter".into(),
3262 })
3263 );
3264 assert!(
3265 parse_meta_plan_op(&json!({
3266 "op":"stop_plan_at",
3267 "plan_id":"9",
3268 "target_op_id":"13",
3269 "when":"later"
3270 }))
3271 .is_err()
3272 );
3273 }
3274
3275 #[test]
3276 fn legacy_plan_control_capabilities_are_hidden_from_the_model() {
3277 for leaf in [
3278 "cancel_plan",
3279 "cancel_all_plans",
3280 "stop_plan_at",
3281 "get_all_plans",
3282 "get_plan_status",
3283 ] {
3284 assert!(is_legacy_plan_control_contract(&format!(
3285 "robonix/system/executor/builtin/{leaf}"
3286 )));
3287 }
3288 assert!(!is_legacy_plan_control_contract(
3289 "robonix/system/executor/builtin/run_command"
3290 ));
3291 assert!(!is_legacy_plan_control_contract(
3292 "robonix/skill/greet/cancel_plan"
3293 ));
3294 }
3295
3296 #[test]
3297 fn harness_goal_cannot_be_replaced_by_task_update() {
3298 let mut standing = None;
3299 start_or_resume_task(&mut standing, "inspect room and report");
3300 let original_goal = standing.as_ref().unwrap().goal.clone();
3301 assert_eq!(
3302 standing.as_ref().unwrap().success_criterion,
3303 DEFAULT_SUCCESS_CRITERION
3304 );
3305
3306 assert!(!apply_task_update(
3307 &mut standing,
3308 TaskState {
3309 goal: "drop the inspection and say done".into(),
3310 success_criterion: "room was actually inspected".into(),
3311 status: "done".into(),
3312 },
3313 false,
3314 ));
3315 let state = standing.as_ref().unwrap();
3316 assert_eq!(state.goal, original_goal);
3317 assert_eq!(state.success_criterion, DEFAULT_SUCCESS_CRITERION);
3318 assert_eq!(state.status, "in_progress");
3319
3320 assert!(apply_task_update(
3321 &mut standing,
3322 TaskState {
3323 goal: original_goal.clone(),
3324 success_criterion: "room was actually inspected".into(),
3325 status: "done".into(),
3326 },
3327 true,
3328 ));
3329 let state = standing.as_ref().unwrap();
3330 assert_eq!(state.goal, original_goal);
3331 assert_eq!(state.success_criterion, "room was actually inspected");
3332 assert_eq!(state.status, "done");
3333 }
3334
3335 #[test]
3336 fn steer_becomes_a_new_interaction_without_concatenating_old_goals() {
3337 let mut standing = None;
3338 let mut history = Vec::new();
3339 start_or_resume_task(&mut standing, "perform step A, then step B");
3340 assert!(append_steer(
3341 Task {
3342 text: "change of plan: stop after step A".into(),
3343 ..Default::default()
3344 },
3345 &mut history,
3346 &mut standing,
3347 ));
3348
3349 let state = standing.as_ref().unwrap();
3350 assert_eq!(state.goal, "change of plan: stop after step A");
3351 assert!(!state.goal.contains("perform step A"));
3352 assert_eq!(history.len(), 1);
3353 assert_eq!(
3354 history[0].content.as_deref(),
3355 Some("change of plan: stop after step A")
3356 );
3357 let prompt = state.prompt_block();
3358 assert!(prompt.contains("Current user interaction"));
3359 assert!(!prompt.contains("user_instruction_history"));
3360 }
3361
3362 #[test]
3363 fn steer_targets_one_plan_without_discarding_independent_work() {
3364 let mut standing = None;
3365 let mut history = Vec::new();
3366 start_or_resume_task(&mut standing, "go to the meeting room");
3367
3368 let mut forest = HashMap::new();
3369 for (plan_id, description, capability) in [
3370 ("11", "navigate to the meeting room", "navigation_navigate"),
3371 ("5", "watch for passersby", "greet_greet"),
3372 ] {
3373 forest.insert(
3374 plan_id.to_string(),
3375 TreeMeta {
3376 description: description.into(),
3377 control_only: false,
3378 call_signatures: HashSet::new(),
3379 steps: vec![TreeStep {
3380 op_id: format!("op-{plan_id}"),
3381 description: description.into(),
3382 capability: capability.into(),
3383 }],
3384 },
3385 );
3386 }
3387
3388 assert!(append_steer(
3389 Task {
3390 text: "cancel the meeting-room trip and return to room 315".into(),
3391 ..Default::default()
3392 },
3393 &mut history,
3394 &mut standing,
3395 ));
3396 assert_eq!(
3397 standing.as_ref().unwrap().goal,
3398 "cancel the meeting-room trip and return to room 315"
3399 );
3400
3401 let prompt = build_forest_block(&forest, &HashSet::new());
3402 assert!(prompt.contains("plan_id=11"));
3403 assert!(prompt.contains("plan_id=5"));
3404 assert!(prompt.contains("navigate to the meeting room"));
3405 assert!(prompt.contains("watch for passersby"));
3406 assert_eq!(
3407 invalid_cancel_target(&["11".into()], &forest, &HashSet::new()),
3408 None
3409 );
3410 assert_eq!(
3411 invalid_cancel_target(&["5".into()], &forest, &HashSet::new()),
3412 None
3413 );
3414
3415 let requested = HashSet::from(["11".to_string()]);
3416 assert_eq!(
3417 invalid_cancel_target(&["11".into()], &forest, &requested),
3418 Some("11".to_string())
3419 );
3420 assert_eq!(
3421 invalid_cancel_target(&["5".into()], &forest, &requested),
3422 None
3423 );
3424 }
3425
3426 #[test]
3427 fn forest_prompt_distinguishes_immediate_cancel_from_boundary_stop() {
3428 let mut forest = HashMap::new();
3429 forest.insert(
3430 "4".to_string(),
3431 TreeMeta {
3432 description: "ordered multi-step task".into(),
3433 control_only: false,
3434 call_signatures: HashSet::new(),
3435 steps: vec![
3436 TreeStep {
3437 op_id: "op-restaurant".into(),
3438 description: "move to restaurant".into(),
3439 capability: "navigate".into(),
3440 },
3441 TreeStep {
3442 op_id: "op-meeting".into(),
3443 description: "move to meeting room".into(),
3444 capability: "navigate".into(),
3445 },
3446 ],
3447 },
3448 );
3449 let prompt = build_forest_block(&forest, &HashSet::new());
3450 assert!(prompt.contains("op_id=op-restaurant"));
3451 assert!(prompt.contains("move to meeting room"));
3452 assert!(prompt.contains("target is the currently running step"));
3453 assert!(prompt.contains("do not query status"));
3454 assert!(prompt.contains("on_complete"));
3455 assert!(prompt.contains("on_enter"));
3456 }
3457
3458 #[test]
3459 fn executor_feedback_is_scoped_to_its_independent_tree() {
3460 let mut history = Vec::new();
3461 feed_results_into_history(
3462 &mut history,
3463 "9",
3464 "start greet watch",
3465 &[CapabilityCallResult {
3466 call_id: "9:0".into(),
3467 contract_id: "robonix/skill/greet/greet".into(),
3468 success: false,
3469 error: "activation failed".into(),
3470 ..Default::default()
3471 }],
3472 );
3473 let scope = history[0].content.as_deref().unwrap_or_default();
3474 assert!(scope.contains("plan_id=9"));
3475 assert!(scope.contains("start greet watch"));
3476 assert!(scope.contains("does not cancel or invalidate other in-flight trees"));
3477 }
3478
3479 #[test]
3480 fn completed_plan_context_preserves_original_call_before_replanning() {
3481 let original_target = 1.4430711285352669;
3482 let plan = Plan {
3483 plan_id: "5".into(),
3484 nodes: vec![RtdlNode {
3485 node_kind: RTDL_DO,
3486 op_id: "6".into(),
3487 description: "navigate to the original one-metre target".into(),
3488 call: Some(CapabilityCall {
3489 call_id: "5:0".into(),
3490 provider_id: "nav2".into(),
3491 contract_id: "robonix/service/navigation/navigate".into(),
3492 args_json: json!({
3493 "goal": {
3494 "header": {"frame_id": "map"},
3495 "pose": {"position": {"x": original_target, "y": -0.0019468723}}
3496 }
3497 })
3498 .to_string(),
3499 }),
3500 ..Default::default()
3501 }],
3502 ..Default::default()
3503 };
3504 let mut history = Vec::new();
3505 record_dispatched_plan(&mut history, &plan, "move forward one metre");
3506 feed_results_into_history(
3507 &mut history,
3508 "5",
3509 "move forward one metre",
3510 &[CapabilityCallResult {
3511 call_id: "5:0".into(),
3512 contract_id: "robonix/service/navigation/navigate".into(),
3513 success: true,
3514 output: r#"{"state":"SUCCEEDED","detail":"last_pose=(1.160,-0.050)"}"#.into(),
3515 ..Default::default()
3516 }],
3517 );
3518
3519 let visible = crate::history::sanitize_for_vlm(&history);
3520 let context = visible
3521 .iter()
3522 .filter_map(|message| message.content.as_deref())
3523 .collect::<Vec<_>>()
3524 .join("\n");
3525 assert!(context.contains("Pilot harness dispatch record"));
3526 assert!(context.contains("\"plan_id\":\"5\""));
3527 assert!(context.contains("\"call_id\":\"5:0\""));
3528 assert!(context.contains(&original_target.to_string()));
3529 assert!(context.contains("SUCCEEDED"));
3530 }
3531
3532 #[test]
3533 fn duplicate_in_flight_calls_are_detected_by_canonical_signature() {
3534 let plan = Plan {
3535 plan_id: "2".into(),
3536 nodes: vec![RtdlNode {
3537 node_kind: RTDL_DO,
3538 call: Some(CapabilityCall {
3539 provider_id: "executor".into(),
3540 contract_id: "test/run".into(),
3541 args_json: r#"{"b":2,"a":1}"#.into(),
3542 ..Default::default()
3543 }),
3544 ..Default::default()
3545 }],
3546 ..Default::default()
3547 };
3548 let signatures = plan_call_signatures(&plan);
3549 let mut forest = HashMap::new();
3550 forest.insert(
3551 "1".to_string(),
3552 TreeMeta {
3553 description: "same call".into(),
3554 control_only: false,
3555 call_signatures: signatures.clone(),
3556 steps: Vec::new(),
3557 },
3558 );
3559 assert!(duplicate_in_flight_signature(&signatures, &forest).is_some());
3560 }
3561
3562 #[test]
3563 fn inspection_result_must_arrive_before_new_action_is_admitted() {
3564 let plan = Plan {
3565 nodes: vec![
3566 RtdlNode {
3567 node_kind: RTDL_DO,
3568 call: Some(CapabilityCall {
3569 contract_id: "robonix/system/executor/builtin/get_plan_status".into(),
3570 ..Default::default()
3571 }),
3572 ..Default::default()
3573 },
3574 RtdlNode {
3575 node_kind: RTDL_DO,
3576 call: Some(CapabilityCall {
3577 contract_id: "robonix/system/executor/builtin/run_command".into(),
3578 ..Default::default()
3579 }),
3580 ..Default::default()
3581 },
3582 ],
3583 ..Default::default()
3584 };
3585 assert!(mixes_control_inspection_with_action(&plan));
3586
3587 let inspection_only = Plan {
3588 nodes: vec![plan.nodes[0].clone()],
3589 ..Default::default()
3590 };
3591 assert!(!mixes_control_inspection_with_action(&inspection_only));
3592 }
3593
3594 #[test]
3595 fn cancel_target_must_be_live_and_not_already_requested() {
3596 let mut forest = HashMap::new();
3597 forest.insert(
3598 "7".to_string(),
3599 TreeMeta {
3600 description: "drive".into(),
3601 control_only: false,
3602 call_signatures: HashSet::new(),
3603 steps: Vec::new(),
3604 },
3605 );
3606 let targets = vec!["7".to_string()];
3607 assert!(invalid_cancel_target(&targets, &forest, &HashSet::new()).is_none());
3608 assert_eq!(
3609 invalid_cancel_target(&targets, &forest, &HashSet::from(["7".to_string()])),
3610 Some("7".to_string())
3611 );
3612 assert_eq!(
3613 invalid_cancel_target(&["8".to_string()], &forest, &HashSet::new()),
3614 Some("8".to_string())
3615 );
3616 }
3617
3618 #[test]
3619 fn rtdl_recovery_final_text_hides_internal_error() {
3620 let text = rtdl_recovery_final_text();
3621 assert!(text.contains("valid robot plan"));
3622 assert!(!text.contains("expand RTDL"));
3623 assert!(!text.contains("capability call"));
3624 assert!(!text.contains("assistant content preview"));
3625 }
3626
3627 fn task(ctx: &str) -> Task {
3628 Task {
3629 task_id: "t".into(),
3630 session_id: "s".into(),
3631 source: 0,
3632 text: String::new(),
3633 audio_data: Vec::new(),
3634 context_json: ctx.into(),
3635 timestamp_ms: 0,
3636 }
3637 }
3638
3639 #[test]
3640 fn session_end_explicit() {
3641 assert!(task_is_session_end(&task(r#"{"session_end":true}"#)));
3642 }
3643
3644 #[test]
3645 fn session_end_legacy_alias() {
3646 assert!(task_is_session_end(&task(
3647 r#"{"robonix_session_end":true}"#
3648 )));
3649 }
3650
3651 #[test]
3652 fn session_end_false_or_absent() {
3653 assert!(!task_is_session_end(&task("")));
3654 assert!(!task_is_session_end(&task(r#"{"foo":1}"#)));
3655 assert!(!task_is_session_end(&task(r#"{"session_end":false}"#)));
3656 }
3657
3658 #[test]
3659 fn skip_prefetch_chitchat() {
3660 assert!(skip_memory_prefetch("hi"));
3661 assert!(skip_memory_prefetch("Hello"));
3662 }
3663
3664 #[test]
3665 fn no_skip_prefetch_real_query() {
3666 assert!(!skip_memory_prefetch("open the door"));
3667 assert!(!skip_memory_prefetch("find me a red cup"));
3668 }
3669
3670 fn single_do_plan(contract_leaf: &str) -> Plan {
3671 Plan {
3672 plan_id: "p".into(),
3673 session_id: "s".into(),
3674 round: 0,
3675 root_index: 0,
3676 nodes: vec![RtdlNode {
3677 node_kind: RTDL_DO,
3678 children: vec![],
3679 call: Some(CapabilityCall {
3680 call_id: "p:0".into(),
3681 provider_id: "executor".into(),
3682 contract_id: format!("robonix/system/executor/builtin/{contract_leaf}"),
3683 args_json: "{}".into(),
3684 }),
3685 op_id: "op_1".into(),
3686 description: "control action".into(),
3687 }],
3688 }
3689 }
3690
3691 #[test]
3692 fn plan_control_builtins_are_control_only() {
3693 for leaf in [
3694 "cancel_plan",
3695 "cancel_all_plans",
3696 "get_all_plans",
3697 "get_plan_status",
3698 "stop_plan_at",
3699 ] {
3700 assert!(is_control_only(&single_do_plan(leaf)), "{leaf}");
3701 }
3702 assert!(!is_control_only(&single_do_plan("list_dir")));
3703 }
3704
3705 #[test]
3706 fn rtdl_state_names_are_human_readable() {
3707 assert_eq!(rtdl_state_name(0), "Pending");
3708 assert_eq!(rtdl_state_name(2), "Succeeded");
3709 assert_eq!(rtdl_state_name(3), "Failed");
3710 assert_eq!(rtdl_state_name(4), "Canceled");
3711 assert_eq!(rtdl_state_name(5), "Timeout");
3712 assert_eq!(rtdl_state_name(999), "Unknown(999)");
3713 }
3714
3715 #[test]
3716 fn rtdl_node_kind_names_are_human_readable() {
3717 assert_eq!(rtdl_node_kind_name(RTDL_SEQUENCE), "sequence");
3718 assert_eq!(rtdl_node_kind_name(RTDL_PARALLEL), "parallel");
3719 assert_eq!(rtdl_node_kind_name(RTDL_DO), "do");
3720 assert_eq!(rtdl_node_kind_name(99), "unknown(99)");
3721 }
3722
3723 #[test]
3724 fn rtdl_response_requires_exact_top_level_keys() {
3725 let err = parse_rtdl_assistant_response(
3727 r#"{"content":"x","rtdl":{"op":"sequence","children":[]}}"#,
3728 )
3729 .unwrap_err();
3730 assert!(
3731 err.to_string()
3732 .contains("exactly `content`, `rtdl_description`, `rtdl`, and `task_update`")
3733 );
3734 }
3735
3736 #[test]
3737 fn rtdl_response_parses_full_envelope() {
3738 let env = parse_rtdl_assistant_response(
3739 r#"{
3740 "content":"on it",
3741 "rtdl_description":"fetch water",
3742 "rtdl":{"op":"sequence","children":[]},
3743 "task_update":{"goal":"bring water","success_criterion":"cup by user","status":"in_progress"}
3744 }"#,
3745 )
3746 .unwrap();
3747 assert_eq!(env.content, "on it");
3748 assert_eq!(env.rtdl_description, "fetch water");
3749 assert!(env.rtdl.is_object());
3750 assert_eq!(
3751 env.task_update,
3752 Some(TaskState {
3753 goal: "bring water".into(),
3754 success_criterion: "cup by user".into(),
3755 status: "in_progress".into(),
3756 })
3757 );
3758 }
3759
3760 #[test]
3761 fn rtdl_response_tolerates_prose_preamble() {
3762 let env = parse_rtdl_assistant_response(
3765 "Let me take a photo to check the scene, then turn left.\n{\"content\":\"on it\",\"rtdl_description\":\"turn\",\"rtdl\":{\"op\":\"sequence\",\"children\":[]},\"task_update\":null}",
3766 )
3767 .unwrap();
3768 assert_eq!(env.content, "on it");
3769 assert!(env.task_update.is_none());
3770 }
3771
3772 #[test]
3773 fn extract_json_object_skips_prose_and_braces_in_strings() {
3774 let got = extract_json_object("hi: {\"a\":\"x}y\",\"b\":1} trailing");
3776 assert_eq!(got, Some("{\"a\":\"x}y\",\"b\":1}"));
3777 assert_eq!(extract_json_object("no json here"), None);
3779 }
3780
3781 #[test]
3782 fn rtdl_response_task_update_null_is_none() {
3783 let env = parse_rtdl_assistant_response(
3784 r#"{"content":"x","rtdl_description":"","rtdl":{"op":"sequence","children":[]},"task_update":null}"#,
3785 )
3786 .unwrap();
3787 assert!(env.task_update.is_none());
3788 }
3789
3790 #[test]
3791 fn task_update_rejects_unknown_status() {
3792 let err = parse_task_update(&json!({
3793 "goal":"g","success_criterion":"c","status":"paused"
3794 }))
3795 .unwrap_err();
3796 assert!(err.to_string().contains("status"));
3797 }
3798
3799 #[test]
3800 fn task_update_rejects_missing_field() {
3801 let err = parse_task_update(&json!({ "goal":"g","status":"done" })).unwrap_err();
3802 assert!(err.to_string().contains("exactly"));
3803 }
3804
3805 #[test]
3806 fn rtdl_expands_sequence_to_plan_calls() {
3807 let mut targets = CapabilityTargetMap::new();
3808 targets.insert(
3809 "camera_snapshot".to_string(),
3810 (
3811 "cap-camera".to_string(),
3812 "robonix/primitive/camera/snapshot".to_string(),
3813 ),
3814 );
3815 targets.insert(
3816 "chassis_move".to_string(),
3817 (
3818 "cap-chassis".to_string(),
3819 "robonix/primitive/chassis/move".to_string(),
3820 ),
3821 );
3822
3823 let rtdl = json!({
3824 "op": "sequence",
3825 "children": [
3826 { "op": "do", "cap": "camera_snapshot", "args": {} },
3827 { "op": "do", "cap": "chassis_move", "args": { "linear": 0.1 } }
3828 ]
3829 });
3830 let plan = expand_rtdl_to_plan(&rtdl, &targets, "p".into(), "s".into(), 7, "").unwrap();
3831
3832 assert_eq!(plan.plan_id, "p");
3833 assert_eq!(plan.session_id, "s");
3834 assert_eq!(plan.round, 7);
3835 assert_eq!(plan.nodes.len(), 3);
3836 assert_eq!(plan.root_index, 0);
3837 assert_eq!(plan.nodes[0].node_kind, RTDL_SEQUENCE);
3838 assert_eq!(plan.nodes[0].children, vec![1, 2]);
3839 let first = plan.nodes[1].call.as_ref().unwrap();
3840 let second = plan.nodes[2].call.as_ref().unwrap();
3841 assert_eq!(plan.nodes[1].node_kind, RTDL_DO);
3842 assert_eq!(first.call_id, "p:0");
3843 assert_eq!(first.provider_id, "cap-camera");
3844 assert_eq!(first.contract_id, "robonix/primitive/camera/snapshot");
3845 assert_eq!(first.args_json, "{}");
3846 assert_eq!(second.call_id, "p:1");
3847 assert_eq!(second.args_json, r#"{"linear":0.1}"#);
3848 }
3849
3850 #[test]
3851 fn rtdl_expands_parallel_root() {
3852 let mut targets = CapabilityTargetMap::new();
3853 targets.insert(
3854 "camera_snapshot".to_string(),
3855 (
3856 "cap-camera".to_string(),
3857 "robonix/primitive/camera/snapshot".to_string(),
3858 ),
3859 );
3860 targets.insert(
3861 "read_temp".to_string(),
3862 (
3863 "cap-temp".to_string(),
3864 "robonix/primitive/sensor/temp".to_string(),
3865 ),
3866 );
3867
3868 let rtdl = json!({
3869 "op": "parallel",
3870 "children": [
3871 { "op": "do", "cap": "camera_snapshot", "args": {} },
3872 { "op": "do", "cap": "read_temp", "args": { "unit": "c" } }
3873 ]
3874 });
3875 let plan = expand_rtdl_to_plan(&rtdl, &targets, "p".into(), "s".into(), 1, "").unwrap();
3876
3877 assert_eq!(plan.root_index, 0);
3878 assert_eq!(plan.nodes[0].node_kind, RTDL_PARALLEL);
3879 assert_eq!(plan.nodes[0].children, vec![1, 2]);
3880 assert_eq!(plan.nodes[1].call.as_ref().unwrap().call_id, "p:0");
3881 assert_eq!(plan.nodes[2].call.as_ref().unwrap().call_id, "p:1");
3882 }
3883
3884 #[test]
3885 fn format_plan_summary_uses_tree_shape_and_compact_cap_names() {
3886 let mut targets = CapabilityTargetMap::new();
3887 targets.insert(
3888 "nav2.navigation_status".to_string(),
3889 (
3890 "nav2".to_string(),
3891 "robonix/service/navigation/status".to_string(),
3892 ),
3893 );
3894 let rtdl = json!({
3895 "op": "sequence",
3896 "description": "poll navigation status",
3897 "children": [
3898 {
3899 "op": "do",
3900 "description": "check current navigation goal",
3901 "cap": "nav2.navigation_status",
3902 "args": { "goal_id": "" }
3903 }
3904 ]
3905 });
3906 let plan = expand_rtdl_to_plan(&rtdl, &targets, "p".into(), "s".into(), 1, "").unwrap();
3907 let summary = format_plan_summary(&plan).join("\n");
3908
3909 assert!(summary.contains("[0] sequence"));
3910 assert!(summary.contains("[1] do"));
3911 assert!(summary.contains("cap=nav2.navigation_status"));
3912 assert!(summary.contains(r#"args={"goal_id":""}"#));
3913 assert!(summary.contains(" [1] do"));
3914 assert!(!summary.contains("kind="));
3915 assert!(!summary.contains("state="));
3916 assert!(!summary.contains("children"));
3917 assert!(!summary.contains("robonix/service/navigation/status"));
3918 assert!(!summary.contains("call_id"));
3919 }
3920
3921 #[test]
3922 fn rtdl_nested_call_ids_follow_json_traversal_order() {
3923 let mut targets = CapabilityTargetMap::new();
3924 for name in ["a", "b", "c"] {
3925 targets.insert(
3926 name.to_string(),
3927 (format!("provider-{name}"), format!("robonix/test/{name}")),
3928 );
3929 }
3930
3931 let rtdl = json!({
3932 "op": "sequence",
3933 "children": [
3934 { "op": "do", "cap": "a", "args": {} },
3935 {
3936 "op": "parallel",
3937 "children": [
3938 { "op": "do", "cap": "b", "args": {} },
3939 { "op": "do", "cap": "c", "args": {} }
3940 ]
3941 }
3942 ]
3943 });
3944 let plan = expand_rtdl_to_plan(&rtdl, &targets, "p".into(), "s".into(), 1, "").unwrap();
3945 let calls: Vec<_> = plan
3946 .nodes
3947 .iter()
3948 .filter_map(|node| node.call.as_ref())
3949 .map(|call| call.call_id.as_str())
3950 .collect();
3951 assert_eq!(calls, vec!["p:0", "p:1", "p:2"]);
3952 }
3953
3954 #[test]
3955 fn rtdl_empty_sequence_generates_root_node() {
3956 let targets = CapabilityTargetMap::new();
3957 let rtdl = json!({ "op": "sequence", "children": [] });
3958 let plan = expand_rtdl_to_plan(&rtdl, &targets, "p".into(), "s".into(), 0, "").unwrap();
3959
3960 assert_eq!(plan.root_index, 0);
3961 assert_eq!(plan.nodes.len(), 1);
3962 assert_eq!(plan.nodes[0].node_kind, RTDL_SEQUENCE);
3963 assert!(plan.nodes[0].children.is_empty());
3964 }
3965
3966 #[test]
3967 fn rtdl_rejects_out_field() {
3968 let mut targets = CapabilityTargetMap::new();
3969 targets.insert(
3970 "camera_snapshot".to_string(),
3971 (
3972 "cap-camera".to_string(),
3973 "robonix/primitive/camera/snapshot".to_string(),
3974 ),
3975 );
3976 let rtdl = json!({
3977 "op": "do",
3978 "cap": "camera_snapshot",
3979 "args": {},
3980 "out": { "image": "img" }
3981 });
3982 let err = expand_rtdl_to_plan(&rtdl, &targets, "p".into(), "s".into(), 0, "").unwrap_err();
3983 assert!(err.to_string().contains("unexpected field `out`"));
3984 }
3985
3986 #[test]
3987 fn rtdl_uses_model_node_description_over_synthesized() {
3988 let mut targets = CapabilityTargetMap::new();
3989 targets.insert(
3990 "camera_snapshot".to_string(),
3991 (
3992 "cap-camera".to_string(),
3993 "robonix/primitive/camera/snapshot".to_string(),
3994 ),
3995 );
3996 let rtdl = json!({
3999 "op": "sequence",
4000 "op_id": 0,
4001 "description": "inspect the doorway",
4002 "children": [
4003 {
4004 "op": "do",
4005 "op_id": 0,
4006 "description": "take a camera snapshot of the door",
4007 "cap": "camera_snapshot",
4008 "args": {}
4009 }
4010 ]
4011 });
4012 let plan = expand_rtdl_to_plan(&rtdl, &targets, "p".into(), "s".into(), 0, "").unwrap();
4013 assert_eq!(plan.nodes[0].description, "inspect the doorway");
4014 assert_eq!(
4015 plan.nodes[1].description,
4016 "take a camera snapshot of the door"
4017 );
4018 assert!(!plan.nodes[0].op_id.is_empty());
4020 assert_ne!(plan.nodes[0].op_id, plan.nodes[1].op_id);
4021 }
4022
4023 #[test]
4024 fn rtdl_rejects_parallel_non_array_children() {
4025 let targets = CapabilityTargetMap::new();
4026 let rtdl = json!({ "op": "parallel", "children": {} });
4027 let err = expand_rtdl_to_plan(&rtdl, &targets, "p".into(), "s".into(), 0, "").unwrap_err();
4028 assert!(err.to_string().contains("children must be an array"));
4029 }
4030
4031 #[test]
4032 fn rtdl_rejects_do_non_object_args() {
4033 let mut targets = CapabilityTargetMap::new();
4034 targets.insert(
4035 "camera_snapshot".to_string(),
4036 (
4037 "cap-camera".to_string(),
4038 "robonix/primitive/camera/snapshot".to_string(),
4039 ),
4040 );
4041 let rtdl = json!({ "op": "do", "cap": "camera_snapshot", "args": [] });
4042 let err = expand_rtdl_to_plan(&rtdl, &targets, "p".into(), "s".into(), 0, "").unwrap_err();
4043 assert!(err.to_string().contains("args must be an object"));
4044 }
4045
4046 #[test]
4047 fn rtdl_rejects_unknown_op() {
4048 let targets = CapabilityTargetMap::new();
4049 let rtdl = json!({ "op": "race", "children": [] });
4050 let err = expand_rtdl_to_plan(&rtdl, &targets, "p".into(), "s".into(), 0, "").unwrap_err();
4051 assert!(err.to_string().contains("unknown operator"));
4052 }
4053}