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::{HashMap, HashSet};
26use std::path::PathBuf;
27use std::sync::Arc;
28use std::sync::atomic::{AtomicU64, Ordering};
29use std::time::Duration;
30use tokio::sync::{mpsc, watch};
31use tonic::Request;
32use tonic::transport::Channel;
33
34pub struct ExecutorConn {
37 pub graph: RobonixSystemExecutorExecuteClient<Channel>,
38 pub control: RobonixSystemExecutorControlPlanClient<Channel>,
39 pub active: RobonixSystemExecutorListActivePlansClient<Channel>,
40}
41
42type CapabilityTarget = (String, String);
43type CapabilityTargetMap = HashMap<String, CapabilityTarget>;
44
45const RTDL_SEQUENCE: u32 = 0;
46const RTDL_PARALLEL: u32 = 1;
47const RTDL_DO: u32 = 2;
48
49struct DisplayCapability<'a> {
50 display_name: String,
51 provider_id: &'a str,
52 cap: &'a atlas_pb::Capability,
53}
54
55fn max_tool_rounds() -> usize {
56 std::env::var("ROBONIX_PILOT_MAX_TOOL_ROUNDS")
57 .ok()
58 .and_then(|s| s.parse().ok())
59 .unwrap_or(64)
60}
61
62fn vlm_idle_timeout() -> Duration {
63 configured_vlm_idle_timeout(
64 std::env::var("ROBONIX_PILOT_VLM_IDLE_TIMEOUT_SECS")
65 .ok()
66 .as_deref(),
67 )
68}
69
70fn configured_vlm_idle_timeout(value: Option<&str>) -> Duration {
71 let seconds = value
72 .and_then(|value| value.parse::<u64>().ok())
73 .unwrap_or(30)
74 .clamp(5, 300);
75 Duration::from_secs(seconds)
76}
77
78const MAX_HISTORY: usize = 200;
79
80#[derive(Clone, Debug, PartialEq, Eq)]
84pub(crate) struct TaskState {
85 goal: String,
86 success_criterion: String,
87 status: String,
88}
89
90const DEFAULT_SUCCESS_CRITERION: &str =
91 "The user's request is completed and the result has been verified.";
92
93impl TaskState {
94 fn is_done(&self) -> bool {
98 self.status == "done"
99 }
100
101 fn prompt_block(&self) -> String {
103 format!(
104 "\n\n## Current user interaction\n- instruction: {}\n\
105 - success_criterion: {}\n- status: {}\n",
106 self.goal, self.success_criterion, self.status
107 )
108 }
109}
110
111fn task_is_session_end(task: &Task) -> bool {
113 let j = task.context_json.trim();
114 if j.is_empty() {
115 return false;
116 }
117 serde_json::from_str::<serde_json::Value>(j)
118 .ok()
119 .and_then(|v| {
120 v.get("session_end")
121 .or_else(|| v.get("robonix_session_end"))
122 .and_then(|x| x.as_bool())
123 })
124 .unwrap_or(false)
125}
126
127fn task_modality(task: &Task) -> Option<String> {
130 let j = task.context_json.trim();
131 if j.is_empty() {
132 return None;
133 }
134 serde_json::from_str::<serde_json::Value>(j)
135 .ok()
136 .and_then(|v| {
137 v.get("modality")
138 .and_then(|x| x.as_str())
139 .map(str::to_string)
140 })
141}
142
143fn skip_memory_prefetch(user_text: &str) -> bool {
145 let t = user_text.trim();
146 let lower = t.to_lowercase();
147 lower == "hi" || lower == "hello"
148}
149
150struct TreeMeta {
154 description: String,
156 control_only: bool,
161 call_signatures: HashSet<String>,
166 steps: Vec<TreeStep>,
170}
171
172struct TreeStep {
173 op_id: String,
174 description: String,
175 capability: String,
176}
177
178enum ForestEvent {
181 NodeState {
185 plan_id: String,
186 node_state: Box<RtdlNodeState>,
187 },
188 PlanDone {
192 plan_id: String,
193 results: Vec<RtdlNodeState>,
194 any_failed: bool,
195 canceled: bool,
202 },
203}
204
205async fn drive_plan(
210 plan: Plan,
211 mut client: RobonixSystemExecutorExecuteClient<Channel>,
212 events_tx: mpsc::Sender<ForestEvent>,
213 forest_revision: Arc<AtomicU64>,
214) {
215 let plan_id = plan.plan_id.clone();
216 let mut stream = match client.execute(Request::new(plan)).await {
217 Ok(resp) => resp.into_inner(),
218 Err(e) => {
219 warn!("[pilot/forest] plan_id={plan_id} Execute RPC failed: {e}");
220 forest_revision.fetch_add(1, Ordering::Release);
221 let _ = events_tx
222 .send(ForestEvent::PlanDone {
223 plan_id,
224 results: Vec::new(),
225 any_failed: true,
226 canceled: false,
227 })
228 .await;
229 return;
230 }
231 };
232
233 let mut results: Vec<RtdlNodeState> = Vec::new();
234 let mut any_failed = false;
235 let mut canceled = false;
236 loop {
237 match stream.message().await {
238 Ok(Some(event)) => {
239 if event.event_kind == RtdlEventEnum::PlanComplete as u32
240 && let Some(pc) = event.plan_complete
241 {
242 any_failed |= pc.any_failed;
243 continue;
244 }
245 if event.event_kind == RtdlEventEnum::NodeState as u32
246 && let Some(ns) = event.node_state
247 {
248 let _ = events_tx
250 .send(ForestEvent::NodeState {
251 plan_id: plan_id.clone(),
252 node_state: Box::new(ns.clone()),
253 })
254 .await;
255 if is_terminal_executor_state(ns.state) {
259 forest_revision.fetch_add(1, Ordering::Release);
260 if ns.state == RtdlNodeStateEnum::Canceled as u32 {
261 canceled = true;
266 } else if ns.state != RtdlNodeStateEnum::Succeeded as u32 {
267 any_failed = true;
268 }
269 results.push(ns);
270 }
271 }
272 }
273 Ok(None) => break,
274 Err(e) => {
275 warn!("[pilot/forest] plan_id={plan_id} stream recv error: {e}");
276 any_failed = true;
277 break;
278 }
279 }
280 }
281
282 forest_revision.fetch_add(1, Ordering::Release);
283 let _ = events_tx
284 .send(ForestEvent::PlanDone {
285 plan_id,
286 results,
287 any_failed,
288 canceled,
289 })
290 .await;
291}
292
293async fn cancel_forest_plans(
298 executor: &mut ExecutorConn,
299 forest: &HashMap<String, TreeMeta>,
300 _session_id: &str,
301) {
302 let targets: Vec<String> = forest
303 .iter()
304 .filter(|(_, meta)| !meta.control_only)
305 .map(|(plan_id, _)| plan_id.clone())
306 .collect();
307 for target in targets {
308 let cancel = executor
309 .control
310 .control_plan(Request::new(ControlPlanRequest {
311 action: "cancel".to_string(),
312 plan_id: target.clone(),
313 op_id: String::new(),
314 when: String::new(),
315 wait_ms: 5_000,
316 }));
317 match tokio::time::timeout(std::time::Duration::from_secs(7), cancel).await {
318 Ok(Ok(response)) => {
319 let response = response.into_inner();
320 if response.success {
321 info!("[pilot] canceled executor plan {target} on abort_turn");
322 } else {
323 warn!(
324 "[pilot] cancel executor plan {target} rejected: {}",
325 response.error
326 );
327 }
328 }
329 Ok(Err(error)) => {
330 warn!("[pilot] cancel executor plan {target} failed: {error}")
331 }
332 Err(_) => warn!("[pilot] cancel executor plan {target} timed out"),
333 }
334 }
335}
336
337#[derive(Clone, Debug, PartialEq, Eq)]
338enum MetaPlanOp {
339 Cancel {
340 plan_id: String,
341 wait_ms: u64,
342 },
343 CancelAll {
344 wait_ms: u64,
345 },
346 StopAt {
347 plan_id: String,
348 op_id: String,
349 when: String,
350 },
351}
352
353impl MetaPlanOp {
354 fn cancellation_targets(&self, forest: &HashMap<String, TreeMeta>) -> Vec<String> {
355 match self {
356 Self::Cancel { plan_id, .. } => vec![plan_id.clone()],
357 Self::CancelAll { .. } => forest
358 .iter()
359 .filter(|(_, meta)| !meta.control_only)
360 .map(|(plan_id, _)| plan_id.clone())
361 .collect::<Vec<_>>(),
362 Self::StopAt { plan_id, .. } => vec![plan_id.clone()],
363 }
364 }
365}
366
367fn parse_meta_plan_op(rtdl: &serde_json::Value) -> Result<Option<MetaPlanOp>> {
368 let Some(obj) = rtdl.as_object() else {
369 return Ok(None);
370 };
371 let Some(op) = obj.get("op").and_then(|value| value.as_str()) else {
372 return Ok(None);
373 };
374 let string = |key: &str| -> Result<String> {
375 let value = obj
376 .get(key)
377 .and_then(|value| value.as_str())
378 .map(str::to_string)
379 .ok_or_else(|| anyhow::anyhow!("meta op `{op}` requires string `{key}`"))?;
380 if value.trim().is_empty() {
381 anyhow::bail!("meta op `{op}` requires non-empty `{key}`");
382 }
383 Ok(value)
384 };
385 let wait_ms = || {
386 obj.get("wait_ms")
387 .and_then(|value| value.as_u64())
388 .unwrap_or(5_000)
389 .min(30_000)
390 };
391 let parsed = match op {
392 "cancel_plan" => MetaPlanOp::Cancel {
393 plan_id: string("plan_id")?,
394 wait_ms: wait_ms(),
395 },
396 "cancel_all" => MetaPlanOp::CancelAll { wait_ms: wait_ms() },
397 "stop_plan_at" => {
398 let when = obj
399 .get("when")
400 .and_then(|value| value.as_str())
401 .unwrap_or("on_complete")
402 .to_string();
403 if !matches!(when.as_str(), "on_enter" | "on_complete") {
404 anyhow::bail!("meta op `stop_plan_at` requires when=on_enter or on_complete");
405 }
406 MetaPlanOp::StopAt {
407 plan_id: string("plan_id")?,
408 op_id: string("target_op_id")?,
409 when,
410 }
411 }
412 _ => return Ok(None),
413 };
414 Ok(Some(parsed))
415}
416
417async fn execute_meta_plan_op(executor: &mut ExecutorConn, op: &MetaPlanOp) -> Result<String> {
418 let request = match op {
419 MetaPlanOp::Cancel { plan_id, wait_ms } => ControlPlanRequest {
420 action: "cancel".to_string(),
421 plan_id: plan_id.clone(),
422 op_id: String::new(),
423 when: String::new(),
424 wait_ms: *wait_ms,
425 },
426 MetaPlanOp::CancelAll { wait_ms } => ControlPlanRequest {
427 action: "cancel_all".to_string(),
428 plan_id: String::new(),
429 op_id: String::new(),
430 when: String::new(),
431 wait_ms: *wait_ms,
432 },
433 MetaPlanOp::StopAt {
434 plan_id,
435 op_id,
436 when,
437 } => ControlPlanRequest {
438 action: "stop_at".to_string(),
439 plan_id: plan_id.clone(),
440 op_id: op_id.clone(),
441 when: when.clone(),
442 wait_ms: 0,
443 },
444 };
445 let timeout = Duration::from_millis(request.wait_ms.saturating_add(2_000).max(2_000));
446 let response = tokio::time::timeout(
447 timeout,
448 executor.control.control_plan(Request::new(request)),
449 )
450 .await
451 .context("Executor plan-control RPC timed out")??
452 .into_inner();
453 if !response.success {
454 anyhow::bail!(response.error);
455 }
456 Ok(response.message)
457}
458
459fn is_control_only(plan: &Plan) -> bool {
466 let mut has_do = false;
467 for n in &plan.nodes {
468 if n.node_kind != RTDL_DO {
469 continue;
470 }
471 has_do = true;
472 let leaf = n
473 .call
474 .as_ref()
475 .map(|c| c.contract_id.rsplit('/').next().unwrap_or(""))
476 .unwrap_or("");
477 if !matches!(
478 leaf,
479 "cancel_plan"
480 | "cancel_all_plans"
481 | "get_all_plans"
482 | "get_plan_status"
483 | "stop_plan_at"
484 ) {
485 return false;
486 }
487 }
488 has_do
489}
490
491fn plan_steps(plan: &Plan) -> Vec<TreeStep> {
492 plan.nodes
493 .iter()
494 .filter(|node| node.node_kind == RTDL_DO)
495 .filter_map(|node| {
496 let call = node.call.as_ref()?;
497 Some(TreeStep {
498 op_id: node.op_id.clone(),
499 description: node.description.clone(),
500 capability: call
501 .contract_id
502 .rsplit('/')
503 .next()
504 .unwrap_or(&call.contract_id)
505 .to_string(),
506 })
507 })
508 .collect()
509}
510
511fn build_forest_block(
512 forest: &HashMap<String, TreeMeta>,
513 cancel_requested: &HashSet<String>,
514) -> String {
515 let mut entries: Vec<(&String, &TreeMeta)> = forest
518 .iter()
519 .filter(|(plan_id, meta)| !meta.control_only && !cancel_requested.contains(*plan_id))
520 .collect();
521 if entries.is_empty() {
522 return String::new();
523 }
524 entries.sort_by_key(|(plan_id, _)| plan_id.parse::<u64>().unwrap_or(u64::MAX));
525 let mut block = String::from(
526 "\n\n## In-flight trees\n\
527 These RTDL trees you dispatched earlier are still running concurrently. \
528 Plan control is NOT a capability call and must never be placed inside a \
529 sequence, parallel, or do node. To stop one immediately, emit a root \
530 `cancel_plan` meta op with its exact `plan_id` below; to stop all work, \
531 emit a root `cancel_all` meta op. Every plan's ordered executable steps \
532 are listed below. To stop at a requested semantic boundary (for example \
533 after step 8 or after reaching the restaurant), emit a root \
534 `stop_plan_at` meta op with that `plan_id`, the chosen `target_op_id`, \
535 and `when` (`on_enter` to stop \
536 before that op runs, `on_complete` to stop after it finishes). Do not \
537 assume the target is the currently running step, and do not query status \
538 first when the requested boundary is already present in this list. Bind \
539 the user's named boundary literally: `after X` means X/on_complete and \
540 `before X` means X/on_enter. Never rewrite `after X` as `before` its \
541 successor because those are not equivalent in branching/parallel trees. It \
542 cancels the whole plan when execution reaches that op. Cancel/stop each \
543 plan_id at most once — a cancel that returned is already stopping; do NOT \
544 re-issue it. Do not reuse these ids for new trees. If an in-flight plan \
545 is already executing the same goal, do not cancel or re-issue it; wait \
546 for it to finish. This block contains trees owned by the current Pilot \
547 supervisor only. The authoritative Executor snapshot below may contain \
548 additional plans started by an earlier interaction. Never use this \
549 local block alone to answer how many tasks are running.\n",
550 );
551 for (plan_id, meta) in entries {
552 block.push_str(&format!(
553 "- plan_id={} running: {}\n",
554 plan_id, meta.description
555 ));
556 for (index, step) in meta.steps.iter().enumerate() {
557 block.push_str(&format!(
558 " {}. op_id={} [{}] {}\n",
559 index + 1,
560 step.op_id,
561 step.capability,
562 step.description
563 ));
564 }
565 }
566 block
567}
568
569fn build_executor_active_block(plans_json: Option<&str>) -> String {
570 let Some(raw) = plans_json else {
571 return String::from(
572 "\n\n## Executor active plans (authoritative live snapshot)\n\
573 - status: unavailable\n\
574 The live query failed. Never guess a task count or claim that no \
575 task is running. Tell the user that current execution state could \
576 not be verified.\n",
577 );
578 };
579 let Ok(value) = serde_json::from_str::<serde_json::Value>(raw) else {
580 return build_executor_active_block(None);
581 };
582 let Some(plans) = value.get("plans").and_then(serde_json::Value::as_array) else {
583 return build_executor_active_block(None);
584 };
585 let normalized = serde_json::json!({
586 "count": plans.len(),
587 "plans": plans,
588 });
589 format!(
590 "\n\n## Executor active plans (authoritative live snapshot)\n\
591 This is the source of truth for every currently running RTDL plan, \
592 including long-running skills started by earlier interactions. For \
593 questions about running task count, names, state, or cancellation \
594 targets, answer from this snapshot rather than conversation history or \
595 the local forest. A plan remains running while listed here even when \
596 its provider is internally idle or motion-gated. Never say that no task \
597 is running unless count is exactly 0.\n\
598 snapshot_json: {}\n",
599 normalized
600 )
601}
602
603async fn fetch_executor_active_block(executor: &mut ExecutorConn) -> String {
604 let request = executor
605 .active
606 .list_active_plans(Request::new(ListActivePlansRequest::default()));
607 match tokio::time::timeout(Duration::from_secs(2), request).await {
608 Ok(Ok(response)) => {
609 let response = response.into_inner();
610 if response.success {
611 build_executor_active_block(Some(&response.plans_json))
612 } else {
613 warn!(
614 "[pilot/state] Executor active-plan query rejected: {}",
615 response.error
616 );
617 build_executor_active_block(None)
618 }
619 }
620 Ok(Err(error)) => {
621 warn!("[pilot/state] Executor active-plan query failed: {error}");
622 build_executor_active_block(None)
623 }
624 Err(_) => {
625 warn!("[pilot/state] Executor active-plan query timed out");
626 build_executor_active_block(None)
627 }
628 }
629}
630
631fn append_steer(
638 task: Task,
639 history: &mut Vec<Message>,
640 current_task: &mut Option<TaskState>,
641) -> bool {
642 let text = task.text.trim();
643 if text.is_empty() {
644 return false;
645 }
646 info!("[pilot/steer] mid-task input: {text}");
647 history.push(Message::user(text));
648 *current_task = Some(TaskState {
649 goal: text.to_string(),
650 success_criterion: DEFAULT_SUCCESS_CRITERION.to_string(),
651 status: "in_progress".to_string(),
652 });
653 true
654}
655
656fn drain_steers(
657 steer_rx: &mut mpsc::Receiver<Task>,
658 history: &mut Vec<Message>,
659 current_task: &mut Option<TaskState>,
660) -> bool {
661 let mut pulled = false;
662 while let Ok(task) = steer_rx.try_recv() {
663 pulled |= append_steer(task, history, current_task);
664 }
665 if pulled {
666 history::trim(history, MAX_HISTORY);
667 }
668 pulled
669}
670
671fn start_or_resume_task(current_task: &mut Option<TaskState>, user_text: &str) {
672 let text = user_text.trim();
673 if text.is_empty() {
674 return;
675 }
676 *current_task = Some(TaskState {
677 goal: text.to_string(),
678 success_criterion: DEFAULT_SUCCESS_CRITERION.to_string(),
679 status: "in_progress".to_string(),
680 });
681}
682
683fn apply_task_update(
689 current_task: &mut Option<TaskState>,
690 update: TaskState,
691 can_finish: bool,
692) -> bool {
693 let Some(state) = current_task.as_mut() else {
694 return false;
695 };
696 let before = state.clone();
697 if update.goal != state.goal {
698 warn!(
699 "[pilot/rtdl] ignoring model goal replacement {:?}; harness goal remains {:?}",
700 update.goal, state.goal
701 );
702 return false;
706 }
707 if state.success_criterion == DEFAULT_SUCCESS_CRITERION
708 && !update.success_criterion.trim().is_empty()
709 {
710 state.success_criterion = update.success_criterion;
711 }
712 state.status = if update.status == "done" && can_finish {
713 "done".to_string()
714 } else {
715 "in_progress".to_string()
716 };
717 *state != before
718}
719
720const HISTORY_COMPACT_TRIGGER_CHARS: usize = 24_000;
723const HISTORY_KEEP_RECENT: usize = 12;
725
726async fn compact_history(history: &mut Vec<Message>, vlm: &VlmClient) {
737 let total: usize = history
738 .iter()
739 .map(|m| m.content.as_deref().map_or(0, str::len))
740 .sum();
741 if total < HISTORY_COMPACT_TRIGGER_CHARS || history.len() <= HISTORY_KEEP_RECENT + 4 {
742 return;
743 }
744
745 let split = history.len() - HISTORY_KEEP_RECENT;
746 let mut msgs = vec![Message::system(
747 "You compact a robot agent's working memory. Summarize the conversation so far \
748 into a concise but COMPLETE note that preserves: the user's goal(s) and any \
749 success criteria, key decisions, important tool results / observations, the \
750 current state of the task, and anything needed to keep going. Compact plain text, \
751 no markdown headings. Do not invent facts.",
752 )];
753 msgs.extend(history::sanitize_for_vlm(&history[..split]));
754 msgs.push(Message::user("Summarize the above conversation now."));
755
756 let summary = match collect_vlm_text(vlm, &msgs).await {
757 Some(s) if !s.trim().is_empty() => s,
758 _ => return,
759 };
760
761 let before = history.len();
762 let mut compacted = Vec::with_capacity(HISTORY_KEEP_RECENT + 1);
763 compacted.push(Message::user(&format!(
764 "[summary of earlier conversation — treat as established context]\n{}",
765 summary.trim()
766 )));
767 compacted.extend_from_slice(&history[split..]);
768 *history = compacted;
769 info!(
770 "[pilot] compacted history {before} -> {} messages (was ~{total} chars)",
771 history.len()
772 );
773}
774
775async fn collect_vlm_text(vlm: &VlmClient, messages: &[Message]) -> Option<String> {
778 let mut stream = vlm.chat_stream(messages, &[]).await.ok()?;
779 let mut text = String::new();
780 while let Some(item) = stream.next().await {
781 if let Ok(VlmStreamItem::TextDelta(d)) = item {
782 text.push_str(&d);
783 }
784 }
785 Some(text)
786}
787
788fn feed_results_into_history(
791 history: &mut Vec<Message>,
792 plan_id: &str,
793 plan_description: &str,
794 results: &[CapabilityCallResult],
795) {
796 history.push(Message::user(&format!(
797 "Executor feedback scope: plan_id={plan_id}, independent RTDL tree={plan_description:?}. \
798 Attribute the following results only to this tree. A failure here blocks dependent \
799 steps in this tree, but does not cancel or invalidate other in-flight trees."
800 )));
801 let mut deferred_followups: Vec<Message> = Vec::new();
802 for r in results {
803 let mapped = rtdl_result_to_messages(r);
804 history.extend(mapped.tool_messages);
805 deferred_followups.extend(mapped.followup_messages);
806 }
807 history.extend(deferred_followups);
808 history::trim(history, MAX_HISTORY);
809}
810
811#[allow(clippy::too_many_arguments)]
812pub async fn run_turn(
813 task: &Task,
814 history: &mut Vec<Message>,
815 standing_task: &mut Option<TaskState>,
816 vlm: &VlmClient,
817 executor: &mut ExecutorConn,
818 atlas: &mut AtlasClient,
819 consumer_id: &str,
820 tx: &mpsc::Sender<Result<PilotEvent, tonic::Status>>,
821 mut cancel_rx: watch::Receiver<bool>,
822 mut steer_rx: mpsc::Receiver<Task>,
823 plan_seq: Arc<AtomicU64>,
824 soma_prompt_block: &str,
825) -> Result<()> {
826 let session_id = task.session_id.clone();
827
828 macro_rules! return_interrupted {
829 ($forest:expr) => {{
830 cancel_forest_plans(executor, $forest, &session_id).await;
831 let _ = tx
832 .send(Ok(service::pack(
833 &session_id,
834 PilotStreamBody::Status(SessionStatusEvent {
835 session_id: session_id.clone(),
836 state: SessionState::Failed as u32,
837 message: "interrupted".to_string(),
838 }),
839 )))
840 .await;
841 return Ok(());
842 }};
843 }
844
845 if task_is_session_end(task) {
846 info!("[pilot] session_end: invoking compact_memory if available");
847 memory::try_compact(executor, atlas, consumer_id).await;
848 let _ = tx
849 .send(Ok(service::pack(
850 &session_id,
851 PilotStreamBody::Status(SessionStatusEvent {
852 session_id: session_id.clone(),
853 state: SessionState::Completed as u32,
854 message: String::new(),
855 }),
856 )))
857 .await;
858 return Ok(());
859 }
860
861 let mut base_prompt = build_system_prompt(load_agent_soul().as_deref());
863 if !soma_prompt_block.trim().is_empty() {
864 base_prompt.push_str(soma_prompt_block);
865 }
866
867 let _ = consumer_id; let initial_caps = discovery::discover(atlas)
872 .await
873 .map_err(|e| anyhow::anyhow!("atlas capability discovery failed: {e}"))?;
874 let search_memory_target = initial_caps
878 .iter()
879 .find(|(_, cap)| cap.contract_id == "robonix/service/memory/search")
880 .map(|(provider_id, cap)| (provider_id.clone(), cap.contract_id.clone()));
881
882 let mut system_prompt = if skip_memory_prefetch(&task.text) {
886 base_prompt
887 } else {
888 match memory::prefetch(&task.text, executor, search_memory_target).await {
889 Some(mem) => format!(
890 "{base_prompt}\n\n## Relevant past memories (historical hints only)\n\n\
891 These entries may be stale or task-specific. They are not current robot state, \
892 not authorization for a physical action, and not a substitute for resolving a \
893 named room, region, object, or person through the current capabilities. In \
894 particular, a remembered grasp or observation pose is not a room navigation \
895 goal.\n\n{mem}\n\n---\n\n"
896 ),
897 None => base_prompt,
898 }
899 };
900
901 if let Ok(docs) = discovery::cap_md_index(atlas).await
909 && !docs.is_empty()
910 {
911 let mut block = String::from(
912 "\n\n## Capability docs (lazy-load via `read_capability_doc`)\n\
913 The providers below ship a CAPABILITY.md manual. Read one by calling \
914 the `read_capability_doc` builtin with its `provider_id` (shown in \
915 backticks). IMPORTANT: before the FIRST time you call a capability of \
916 a provider marked `[skill]`, read that provider's CAPABILITY.md first \
917 — skills have multi-step usage (e.g. start → poll status → cancel) and \
918 constraints the terse description omits. For primitives/services, \
919 reading is optional. Never use `read_file` and never guess a file path \
920 for docs; `read_capability_doc` is the only way, and only the providers \
921 listed here have one.\n\n",
922 );
923 for d in &docs {
924 let tag = if d.kind == "skill" { " `[skill]`" } else { "" };
925 block.push_str(&format!(
931 "- `{}`{}: {}\n",
932 d.provider_id, tag, d.description
933 ));
934 }
935 system_prompt.push_str(&block);
936 }
937
938 if task_modality(task).as_deref() == Some("voice") {
945 system_prompt.push_str(
946 "\n\n## Voice mode\n\n\
947 The user is interacting via voice; this reply will be\n\
948 spoken back through TTS. Keep the response short (≤ ~30\n\
949 characters Chinese / ~50 words English), no markdown\n\
950 lists, no headings, no code blocks, plain conversational\n\
951 tone. If the answer genuinely needs structure, summarise\n\
952 out loud and offer to elaborate when asked.\n",
953 );
954 }
955 let system_prompt = system_prompt;
956
957 history.push(Message::user(&task.text));
959 history::trim(history, MAX_HISTORY);
960 start_or_resume_task(standing_task, &task.text);
961 if let Some(state) = standing_task.as_ref() {
962 let _ = tx
963 .send(Ok(service::pack(
964 &session_id,
965 PilotStreamBody::TaskState(TaskStateEvent {
966 goal: state.goal.clone(),
967 success_criterion: state.success_criterion.clone(),
968 status: state.status.clone(),
969 }),
970 )))
971 .await;
972 }
973
974 let max_rounds = max_tool_rounds();
975 let mut round: u32 = 0;
976
977 let (forest_tx, mut forest_rx) = mpsc::channel::<ForestEvent>(256);
990 let mut forest: HashMap<String, TreeMeta> = HashMap::new();
991 let mut cancel_requested: HashSet<String> = HashSet::new();
992 let forest_revision = Arc::new(AtomicU64::new(0));
993 let mut should_plan = true;
994 let mut last_content = String::new();
996
997 'supervisor: loop {
998 if *cancel_rx.borrow() {
1000 return_interrupted!(&forest);
1001 }
1002
1003 if !should_plan {
1004 let task_done = standing_task
1006 .as_ref()
1007 .map(TaskState::is_done)
1008 .unwrap_or(false);
1009 if forest.is_empty() {
1010 if task_done || standing_task.is_none() {
1011 let _ = tx
1012 .send(Ok(service::pack(
1013 &session_id,
1014 PilotStreamBody::FinalText(last_content.clone()),
1015 )))
1016 .await;
1017 break;
1018 }
1019 tokio::select! {
1024 biased;
1025 _ = cancel_rx.changed() => {
1026 return_interrupted!(&forest);
1027 }
1028 steer = steer_rx.recv() => {
1029 match steer {
1030 Some(task) => {
1031 if append_steer(task, history, standing_task) {
1032 history::trim(history, MAX_HISTORY);
1033 should_plan = true;
1034 }
1035 }
1036 None => break,
1037 }
1038 }
1039 }
1040 continue;
1041 }
1042 tokio::select! {
1045 biased;
1046 _ = cancel_rx.changed() => {
1047 return_interrupted!(&forest);
1048 }
1049 steer = steer_rx.recv() => {
1050 if let Some(task) = steer
1051 && append_steer(task, history, standing_task)
1052 {
1053 history::trim(history, MAX_HISTORY);
1054 should_plan = true;
1057 }
1058 }
1059 ev = forest_rx.recv() => {
1060 match ev {
1061 Some(ForestEvent::NodeState { plan_id, node_state }) => {
1062 let mut ns = *node_state;
1063 ns.plan_id = plan_id.clone();
1067 const TERMINAL: [u32; 4] = [2, 3, 4, 5];
1075 if TERMINAL.contains(&ns.state)
1076 && let Some(r) = ns.leaf_result.as_ref()
1077 {
1078 let description = forest
1079 .get(&plan_id)
1080 .map(|meta| meta.description.as_str())
1081 .unwrap_or("unknown tree");
1082 feed_results_into_history(
1083 history,
1084 &plan_id,
1085 description,
1086 std::slice::from_ref(r),
1087 );
1088 }
1089 if is_terminal_executor_state(ns.state)
1098 && ns.state != RtdlNodeStateEnum::Succeeded as u32
1099 && !cancel_requested.contains(&plan_id)
1100 && standing_task.as_ref().is_some_and(|state| !state.is_done())
1101 {
1102 should_plan = true;
1103 }
1104 log_node_state(&plan_id, &ns);
1105 let _ = tx
1109 .send(Ok(service::pack(
1110 &session_id,
1111 PilotStreamBody::NodeState(ns),
1112 )))
1113 .await;
1114 }
1115 Some(ForestEvent::PlanDone { plan_id, results, any_failed, canceled }) => {
1116 forest.remove(&plan_id);
1117 let requested_cancellation = cancel_requested.remove(&plan_id);
1118 if requested_cancellation {
1119 history.push(Message::user(&format!(
1120 "Pilot harness event: the requested cancellation of RTDL plan \
1121 {plan_id} is complete. Do not query or cancel that plan again. \
1122 Unrelated in-flight trees remain independent. If the current \
1123 interaction requested only this stop and has no successor action, \
1124 mark it done and report the completed stop now."
1125 )));
1126 history::trim(history, MAX_HISTORY);
1127 }
1128 log_plan_complete(&plan_id, &results, any_failed);
1131 let batch = BatchResult {
1132 plan_id: plan_id.clone(),
1133 session_id: session_id.clone(),
1134 round,
1135 results,
1136 any_failed,
1137 };
1138 let _ = tx
1139 .send(Ok(service::pack(
1140 &session_id,
1141 PilotStreamBody::BatchResult(batch),
1142 )))
1143 .await;
1144 if should_replan_after_plan_done(
1150 canceled,
1151 requested_cancellation,
1152 cancel_requested.is_empty(),
1153 standing_task.as_ref().is_some_and(|state| !state.is_done()),
1154 ) {
1155 should_plan = true;
1156 }
1157 }
1158 None => {
1159 should_plan = forest.is_empty();
1162 }
1163 }
1164 }
1165 }
1166 continue;
1167 }
1168
1169 should_plan = false;
1171
1172 drain_steers(&mut steer_rx, history, standing_task);
1175
1176 compact_history(history, vlm).await;
1179
1180 let cap_list = discovery::discover(atlas)
1183 .await
1184 .map_err(|e| anyhow::anyhow!("atlas capability discovery failed: {e}"))?;
1185
1186 let embodiment_block =
1187 crate::soma_context::fetch_runtime_prompt_block(atlas, consumer_id).await;
1188 let environment_block = state_context::collect(executor, atlas, &cap_list).await;
1189
1190 let display_caps = build_display_capabilities(&cap_list);
1191 let target_map = build_capability_target_map(&display_caps);
1192 let rtdl_prompt = build_rtdl_prompt(&display_caps, round == 0)?;
1193
1194 let task_block = standing_task
1195 .as_ref()
1196 .map(TaskState::prompt_block)
1197 .unwrap_or_default();
1198 let forest_block = build_forest_block(&forest, &cancel_requested);
1199 let executor_active_block = fetch_executor_active_block(executor).await;
1200 let _ = tx
1201 .send(Ok(service::pack(
1202 &session_id,
1203 PilotStreamBody::Status(SessionStatusEvent {
1204 session_id: session_id.clone(),
1205 state: SessionState::Active as u32,
1206 message: "Planning the next step".to_string(),
1207 }),
1208 )))
1209 .await;
1210 let mut correction: Option<String> = None;
1216 let (assistant_content, rtdl_description, graph, meta_op, plan_id, task_update, recovered) = loop {
1217 let mut messages = vec![Message::system(&format!(
1218 "{system_prompt}\n\n{rtdl_prompt}{task_block}{forest_block}{executor_active_block}{embodiment_block}{environment_block}"
1219 ))];
1220 messages.extend(history::sanitize_for_vlm(history));
1221 if let Some(ref correction) = correction {
1222 messages.push(Message::user(correction));
1223 }
1224
1225 let planning_revision = forest_revision.load(Ordering::Acquire);
1226 let mut vlm_attempt = 0_u8;
1227 let (content, raw_tool_calls) = loop {
1228 let mut stream =
1229 match tokio::time::timeout(vlm_idle_timeout(), vlm.chat_stream(&messages, &[]))
1230 .await
1231 {
1232 Ok(Ok(stream)) => stream,
1233 Ok(Err(error)) if vlm_attempt == 0 => {
1234 warn!("[pilot/vlm] opening stream failed; retrying once: {error:#}");
1235 vlm_attempt += 1;
1236 continue;
1237 }
1238 Ok(Err(error)) => {
1239 return Err(anyhow::anyhow!("VLM stream error: {error:#}"));
1240 }
1241 Err(_) if vlm_attempt == 0 => {
1242 warn!("[pilot/vlm] opening stream timed out; retrying once");
1243 vlm_attempt += 1;
1244 continue;
1245 }
1246 Err(_) => return Err(anyhow::anyhow!("VLM stream open timed out")),
1247 };
1248 let mut full_text = String::new();
1249 let mut tool_calls: Vec<crate::vlm::ToolCall> = Vec::new();
1250
1251 let receive_result: anyhow::Result<()> = loop {
1252 tokio::select! {
1253 biased;
1254 _ = cancel_rx.changed() => {
1256 drop(stream);
1257 return_interrupted!(&forest);
1258 }
1259 steer = steer_rx.recv() => {
1260 if let Some(task) = steer {
1261 append_steer(task, history, standing_task);
1262 drain_steers(&mut steer_rx, history, standing_task);
1263 history::trim(history, MAX_HISTORY);
1264 }
1265 drop(stream);
1269 should_plan = true;
1270 continue 'supervisor;
1271 }
1272 item = stream.next() => {
1273 let item = match item {
1274 Some(Ok(it)) => it,
1275 Some(Err(error)) => break Err(anyhow::anyhow!("VLM stream recv: {error:#}")),
1276 None => break Ok(()),
1277 };
1278 match item {
1279 VlmStreamItem::TextDelta(delta) => full_text.push_str(&delta),
1280 VlmStreamItem::ToolCall(tc) => tool_calls.push(tc),
1281 VlmStreamItem::Finish => {}
1282 }
1283 }
1284 _ = tokio::time::sleep(vlm_idle_timeout()) => {
1285 break Err(anyhow::anyhow!("VLM stream idle timeout"));
1286 }
1287 }
1288 };
1289
1290 if let Err(error) = receive_result {
1291 if vlm_attempt == 0 {
1292 warn!("[pilot/vlm] {error:#}; retrying once");
1293 let _ = tx
1294 .send(Ok(service::pack(
1295 &session_id,
1296 PilotStreamBody::Status(SessionStatusEvent {
1297 session_id: session_id.clone(),
1298 state: SessionState::Active as u32,
1299 message: "VLM response delayed; retrying once".to_string(),
1300 }),
1301 )))
1302 .await;
1303 vlm_attempt += 1;
1304 continue;
1305 }
1306 return Err(error);
1307 }
1308
1309 let content = if full_text.is_empty() {
1310 None
1311 } else {
1312 Some(full_text)
1313 };
1314 break (content, tool_calls);
1315 };
1316
1317 if forest_revision.load(Ordering::Acquire) != planning_revision {
1318 should_plan = false;
1322 continue 'supervisor;
1323 }
1324
1325 if !raw_tool_calls.is_empty() {
1326 anyhow::bail!("VLM returned tool_calls in RTDL mode");
1327 }
1328
1329 let raw_content = content.unwrap_or_default();
1330 debug!("[pilot/rtdl/raw] raw_content={raw_content}");
1331 let parsed = parse_rtdl_assistant_response(&raw_content).with_context(|| {
1332 format!(
1333 "parse RTDL assistant response: {}",
1334 raw_preview(&raw_content)
1335 )
1336 });
1337 let RtdlEnvelope {
1338 content: assistant_content,
1339 rtdl_description,
1340 rtdl,
1341 task_update,
1342 } = match parsed {
1343 Ok(env) => env,
1344 Err(e) if correction.is_none() => {
1345 warn!("[pilot/rtdl] parse failed round={round}, retrying once: {e:#}");
1346 correction = Some(build_rtdl_retry_prompt(&e, &raw_content, &display_caps));
1347 continue;
1348 }
1349 Err(e) => {
1350 warn!(
1351 "[pilot/rtdl] parse failed again round={round}, ending turn gracefully: {e:#}"
1352 );
1353 let plan_id = String::new();
1354 let graph = empty_sequence_plan(plan_id.clone(), session_id.clone(), round);
1355 break (
1356 rtdl_recovery_final_text(),
1357 String::new(),
1358 Some(graph),
1359 None,
1360 plan_id,
1361 None,
1362 true,
1363 );
1364 }
1365 };
1366
1367 debug!(
1368 "[pilot/rtdl/raw] model_rtdl={}",
1369 serde_json::to_string(&rtdl).unwrap_or_else(|_| "<unserializable>".into())
1370 );
1371
1372 match parse_meta_plan_op(&rtdl).context("parse RTDL meta op") {
1373 Ok(Some(meta_op)) => {
1374 break (
1375 assistant_content,
1376 rtdl_description,
1377 None,
1378 Some(meta_op),
1379 String::new(),
1380 task_update,
1381 false,
1382 );
1383 }
1384 Ok(None) => {}
1385 Err(e) if correction.is_none() => {
1386 warn!("[pilot/rtdl] meta op invalid round={round}, retrying once: {e:#}");
1387 correction = Some(build_rtdl_retry_prompt(&e, &raw_content, &display_caps));
1388 continue;
1389 }
1390 Err(e) => {
1391 warn!(
1392 "[pilot/rtdl] meta op invalid again round={round}, ending turn gracefully: {e:#}"
1393 );
1394 let plan_id = String::new();
1395 let graph = empty_sequence_plan(plan_id.clone(), session_id.clone(), round);
1396 break (
1397 rtdl_recovery_final_text(),
1398 String::new(),
1399 Some(graph),
1400 None,
1401 plan_id,
1402 None,
1403 true,
1404 );
1405 }
1406 }
1407
1408 let plan_id = (plan_seq.fetch_add(1, Ordering::Relaxed) + 1).to_string();
1412 match expand_rtdl_to_plan(
1413 &rtdl,
1414 &target_map,
1415 plan_id.clone(),
1416 session_id.clone(),
1417 round,
1418 &rtdl_description,
1419 )
1420 .context("expand RTDL to Plan")
1421 {
1422 Ok(graph) => {
1426 break (
1427 assistant_content,
1428 rtdl_description,
1429 Some(graph),
1430 None,
1431 plan_id,
1432 task_update,
1433 false,
1434 );
1435 }
1436 Err(e) if correction.is_none() => {
1437 warn!("[pilot/rtdl] expand failed round={round}, retrying once: {e:#}");
1438 correction = Some(build_rtdl_retry_prompt(&e, &raw_content, &display_caps));
1439 }
1440 Err(e) => {
1441 warn!(
1442 "[pilot/rtdl] expand failed again round={round}, ending turn gracefully: {e:#}"
1443 );
1444 let plan_id = String::new();
1445 let graph = empty_sequence_plan(plan_id.clone(), session_id.clone(), round);
1446 break (
1447 rtdl_recovery_final_text(),
1448 String::new(),
1449 Some(graph),
1450 None,
1451 plan_id,
1452 None,
1453 true,
1454 );
1455 }
1456 }
1457 };
1458
1459 if recovered {
1463 if !assistant_content.is_empty() {
1464 history.push(Message::assistant(&assistant_content));
1465 }
1466 let _ = tx
1467 .send(Ok(service::pack(
1468 &session_id,
1469 PilotStreamBody::FinalText(assistant_content),
1470 )))
1471 .await;
1472 break;
1473 }
1474
1475 if let Some(meta_op) = meta_op {
1476 let targets = meta_op.cancellation_targets(&forest);
1477 if let Some(target) = invalid_cancel_target(&targets, &forest, &cancel_requested) {
1478 warn!("[pilot/harness] suppressed stale or duplicate meta op for plan {target}");
1479 history.push(Message::user(&format!(
1480 "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."
1481 )));
1482 history::trim(history, MAX_HISTORY);
1483 should_plan = true;
1484 continue 'supervisor;
1485 }
1486 if let MetaPlanOp::StopAt { plan_id, op_id, .. } = &meta_op
1487 && forest
1488 .get(plan_id)
1489 .is_none_or(|meta| !meta.steps.iter().any(|step| step.op_id == *op_id))
1490 {
1491 warn!("[pilot/harness] suppressed stop_at for unknown op {plan_id}/{op_id}");
1492 history.push(Message::user(&format!(
1493 "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."
1494 )));
1495 history::trim(history, MAX_HISTORY);
1496 should_plan = true;
1497 continue 'supervisor;
1498 }
1499
1500 if let Some(updated) = task_update {
1501 let changed = apply_task_update(standing_task, updated, false);
1502 if changed && let Some(state) = standing_task.as_ref() {
1503 let _ = tx
1504 .send(Ok(service::pack(
1505 &session_id,
1506 PilotStreamBody::TaskState(TaskStateEvent {
1507 goal: state.goal.clone(),
1508 success_criterion: state.success_criterion.clone(),
1509 status: state.status.clone(),
1510 }),
1511 )))
1512 .await;
1513 }
1514 }
1515 if !assistant_content.trim().is_empty() {
1516 history.push(Message::assistant(&assistant_content));
1517 history::trim(history, MAX_HISTORY);
1518 last_content = assistant_content.clone();
1519 let _ = tx
1520 .send(Ok(service::pack(
1521 &session_id,
1522 PilotStreamBody::TextChunk(assistant_content),
1523 )))
1524 .await;
1525 }
1526
1527 cancel_requested.extend(targets.iter().cloned());
1528 let result = execute_meta_plan_op(executor, &meta_op).await;
1529 round += 1;
1530 match result {
1531 Ok(message) => {
1532 info!("[pilot/control] {message}");
1533 history.push(Message::user(&format!(
1534 "Pilot plan-control result: {message} This was an out-of-band meta operation, not an RTDL tree. Do not issue it again."
1535 )));
1536 history::trim(history, MAX_HISTORY);
1537 let _ = tx
1538 .send(Ok(service::pack(
1539 &session_id,
1540 PilotStreamBody::Status(SessionStatusEvent {
1541 session_id: session_id.clone(),
1542 state: SessionState::Active as u32,
1543 message: "Plan control accepted".to_string(),
1544 }),
1545 )))
1546 .await;
1547 should_plan = targets.is_empty();
1550 }
1551 Err(error) => {
1552 warn!("[pilot/control] meta operation failed: {error:#}");
1553 for target in &targets {
1554 cancel_requested.remove(target);
1555 }
1556 history.push(Message::user(&format!(
1557 "Pilot plan-control failure: {error:#}. The operation was not accepted; inspect the current In-flight trees before deciding whether to retry."
1558 )));
1559 history::trim(history, MAX_HISTORY);
1560 should_plan = true;
1561 }
1562 }
1563 continue 'supervisor;
1564 }
1565
1566 let graph = graph.expect("non-meta RTDL response must carry a graph");
1567
1568 let calls = plan_call_count(&graph);
1569 let call_signatures = plan_call_signatures(&graph);
1570 let cancel_targets = plan_cancel_targets(&graph);
1571 if mixes_control_inspection_with_action(&graph) {
1572 warn!("[pilot/harness] suppressed mixed control inspection and action tree");
1573 history.push(Message::user(
1574 "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.",
1575 ));
1576 history::trim(history, MAX_HISTORY);
1577 should_plan = true;
1578 continue 'supervisor;
1579 }
1580 if let Some(target) = invalid_cancel_target(&cancel_targets, &forest, &cancel_requested) {
1581 warn!("[pilot/harness] suppressed stale or duplicate cancel for plan {target}");
1582 history.push(Message::user(
1583 "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.",
1584 ));
1585 history::trim(history, MAX_HISTORY);
1586 should_plan = true;
1587 continue 'supervisor;
1588 }
1589 if let Some(duplicate) = duplicate_in_flight_signature(&call_signatures, &forest) {
1590 warn!("[pilot/harness] suppressed duplicate in-flight call: {duplicate}");
1591 history.push(Message::user(
1592 "Pilot harness feedback: that exact capability call is already in flight. Do not dispatch or cancel it again; wait for its result.",
1593 ));
1594 history::trim(history, MAX_HISTORY);
1595 should_plan = false;
1596 continue 'supervisor;
1597 }
1598
1599 if let Some(updated) = task_update {
1603 info!(
1604 "[pilot/rtdl] task_update goal='{}' status='{}'",
1605 updated.goal, updated.status
1606 );
1607 let changed = apply_task_update(standing_task, updated, calls == 0);
1608 if changed && let Some(state) = standing_task.as_ref() {
1609 let _ = tx
1610 .send(Ok(service::pack(
1611 &session_id,
1612 PilotStreamBody::TaskState(TaskStateEvent {
1613 goal: state.goal.clone(),
1614 success_criterion: state.success_criterion.clone(),
1615 status: state.status.clone(),
1616 }),
1617 )))
1618 .await;
1619 }
1620 }
1621
1622 log_plan_start(&graph, &rtdl_description, round, calls);
1623
1624 if !assistant_content.is_empty() {
1628 history.push(Message::assistant(&assistant_content));
1629 last_content = assistant_content.clone();
1630 }
1631
1632 round += 1;
1633 let hit_cap = round as usize >= max_rounds;
1634 let task_done = standing_task
1635 .as_ref()
1636 .map(TaskState::is_done)
1637 .unwrap_or(false);
1638
1639 if calls == 0 {
1640 if forest.is_empty() {
1645 if hit_cap && !(task_done || standing_task.is_none()) {
1646 warn!("[pilot] hit max tool rounds ({max_rounds}), stopping turn");
1647 }
1648 let reply = if assistant_content.trim().is_empty() && !task_done {
1649 "I need more information before I can continue.".to_string()
1650 } else {
1651 assistant_content
1652 };
1653 let _ = tx
1654 .send(Ok(service::pack(
1655 &session_id,
1656 PilotStreamBody::FinalText(reply),
1657 )))
1658 .await;
1659 break;
1660 }
1661 if !assistant_content.trim().is_empty() {
1666 let body = if task_done {
1667 PilotStreamBody::FinalText(assistant_content.clone())
1668 } else {
1669 PilotStreamBody::TextChunk(assistant_content.clone())
1670 };
1671 let _ = tx.send(Ok(service::pack(&session_id, body))).await;
1672 if !task_done {
1673 let _ = tx
1677 .send(Ok(service::pack(
1678 &session_id,
1679 PilotStreamBody::Status(SessionStatusEvent {
1680 session_id: session_id.clone(),
1681 state: SessionState::Active as u32,
1682 message: "Waiting for in-flight work".to_string(),
1683 }),
1684 )))
1685 .await;
1686 }
1687 }
1688 if hit_cap {
1689 warn!("[pilot] hit max tool rounds ({max_rounds}), stopping turn");
1690 break;
1691 }
1692 continue;
1695 }
1696
1697 if !assistant_content.trim().is_empty() {
1698 let _ = tx
1699 .send(Ok(service::pack(
1700 &session_id,
1701 PilotStreamBody::TextChunk(assistant_content.clone()),
1702 )))
1703 .await;
1704 }
1705
1706 let _ = tx
1709 .send(Ok(service::pack(
1710 &session_id,
1711 PilotStreamBody::Plan(graph.clone()),
1712 )))
1713 .await;
1714 cancel_requested.extend(cancel_targets);
1715 forest.insert(
1716 plan_id.clone(),
1717 TreeMeta {
1718 description: rtdl_description,
1719 control_only: is_control_only(&graph),
1720 call_signatures,
1721 steps: plan_steps(&graph),
1722 },
1723 );
1724 tokio::spawn(drive_plan(
1725 graph,
1726 executor.graph.clone(),
1727 forest_tx.clone(),
1728 Arc::clone(&forest_revision),
1729 ));
1730 info!(
1731 "[pilot/forest] plan_id={plan_id} dispatched forest_size={}",
1732 forest.len()
1733 );
1734
1735 if hit_cap {
1736 warn!("[pilot] hit max tool rounds ({max_rounds}), stopping turn");
1737 break;
1738 }
1739 }
1741
1742 let _ = tx
1744 .send(Ok(service::pack(
1745 &session_id,
1746 PilotStreamBody::Status(SessionStatusEvent {
1747 session_id: session_id.clone(),
1748 state: SessionState::Completed as u32,
1749 message: String::new(),
1750 }),
1751 )))
1752 .await;
1753
1754 Ok(())
1755}
1756
1757fn build_display_capabilities(
1758 cap_list: &[(String, atlas_pb::Capability)],
1759) -> Vec<DisplayCapability<'_>> {
1760 cap_list
1761 .iter()
1762 .filter(|(_, cap)| !is_legacy_plan_control_contract(&cap.contract_id))
1763 .map(|(provider_id, cap)| DisplayCapability {
1764 display_name: format!("{}.{}", provider_id, llm_name(&cap.contract_id)),
1765 provider_id: provider_id.as_str(),
1766 cap,
1767 })
1768 .collect()
1769}
1770
1771fn is_legacy_plan_control_contract(contract_id: &str) -> bool {
1772 if !contract_id.starts_with("robonix/system/executor/builtin/") {
1773 return false;
1774 }
1775 matches!(
1776 contract_id.rsplit('/').next().unwrap_or_default(),
1777 "cancel_plan" | "cancel_all_plans" | "stop_plan_at" | "get_all_plans" | "get_plan_status"
1778 )
1779}
1780
1781fn build_capability_target_map(display_caps: &[DisplayCapability<'_>]) -> CapabilityTargetMap {
1782 let mut out = HashMap::new();
1783 for cap in display_caps {
1784 out.insert(
1785 cap.display_name.clone(),
1786 (cap.provider_id.to_string(), cap.cap.contract_id.clone()),
1787 );
1788 }
1789 out
1790}
1791
1792const RTDL_PROTOCOL_REMINDER: &str = "## RTDL output (reminder — same format as your earlier turns)\n\
1797Reply with exactly ONE JSON object, keys EXACTLY these four: \
1798`content`, `rtdl_description`, `rtdl`, `task_update`. No other top-level keys.\n\
1799The whole reply MUST begin with `{` and end with `}` — no prose, narration, or \
1800markdown fences before or after it; put any user-facing text inside `content`, never outside the object. \
1801Every value must be a literal JSON number or string.\n\
1802`rtdl` is a tree; every node carries `op_id` (always write `0`; the system \
1803assigns the real id) and a short `description` of THIS node's intent, plus:\n\
1804- {\"op\":\"sequence\",\"op_id\":0,\"description\":\"...\",\"children\":[ ...nodes... ]} (run children in order)\n\
1805- {\"op\":\"parallel\",\"op_id\":0,\"description\":\"...\",\"children\":[ ...nodes... ]} (run children concurrently)\n\
1806- {\"op\":\"do\",\"op_id\":0,\"description\":\"...\",\"cap\":\"<capability_name>\",\"args\":{ ... }} (one capability call)\n\
1807A capability name goes ONLY in a do node's `cap` — NEVER as an `op`. \
1808Beyond `op_id` and `description`, do NOT add other node fields (no `plan_id`, no `out`, no `id`). \
1809Copy each `cap` EXACTLY from a capability_name in the list below (it is provider-qualified, \
1810e.g. `tiago_camera.camera_snapshot`); never invent or shorten names.\n\
1811`task_update`: null keeps current progress, or {\"goal\",\"success_criterion\",\"status\"}. \
1812The harness owns the instruction history: copy it EXACTLY from \"Current overall task\" and never \
1813rewrite it. Interpret entries chronologically: the newest user instruction overrides conflicting \
1814older instructions, while non-conflicting work remains active. You may refine the default success \
1815criterion once. status is the \
1816string \"in_progress\" or \"done\" (never null), set to \
1817\"done\" only when the success_criterion verifiably holds AND no tree in the \
1818\"In-flight trees\" list is still running (cancelling a tree does not make the task done — wait \
1819for it to leave the list first).\n\
1820Compose multi-step trees; don't drip one node per round. No new capability call this round = \
1821{\"op\":\"sequence\",\"op_id\":0,\"description\":\"wait\",\"children\":[]}.\n\
1822 Plan control is a root meta op, never a capability or nested RTDL node: \
1823 {\"op\":\"cancel_plan\",\"plan_id\":\"<listed id>\",\"wait_ms\":5000}, \
1824 {\"op\":\"cancel_all\",\"wait_ms\":5000}, or \
1825 {\"op\":\"stop_plan_at\",\"plan_id\":\"<listed id>\",\"target_op_id\":\"<listed op id>\",\"when\":\"on_complete\"}. \
1826 A meta op is the entire `rtdl` value for that round and cannot be nested in sequence/parallel/do. \
1827 Never call a skill's cancel capability yourself; Executor propagates cancellation to the active provider.\n\
1828Executor results are scoped to the plan_id/tree named immediately before them. A failed tree \
1829blocks only its dependent steps; do not cancel an unrelated in-flight tree because another \
1830tree failed. Cancel only for an explicit user steer covering that tree or a safety hazard.\n\
1831For a named room or region, current Scene data is authoritative: call Scene list_objects, then \
1832goal_room with its stable ID, then navigation with the returned pose. Never substitute a Memory \
1833coordinate or an object/grasp pose. A navigation SUCCEEDED result proves completion only for the \
1834resolved requested destination; a zero-distance result does not prove that the robot moved.\n\
1835Example: {\"content\":\"listing\",\"rtdl_description\":\"list tmp\",\"rtdl\":{\"op\":\"sequence\",\
1836\"op_id\":0,\"description\":\"list /tmp\",\"children\":[{\"op\":\"do\",\"op_id\":0,\
1837\"description\":\"list the /tmp directory\",\"cap\":\"executor.builtin_list_dir\",\"args\":{\"path\":\"/tmp\"}}]},\
1838\"task_update\":null}\n";
1839
1840fn build_rtdl_prompt(
1844 display_caps: &[DisplayCapability<'_>],
1845 full_protocol: bool,
1846) -> Result<String> {
1847 let mut p = if full_protocol {
1849 String::from(include_str!("../rtdl_protocol.md"))
1850 } else {
1851 String::from(RTDL_PROTOCOL_REMINDER)
1852 };
1853 p.push_str("\n## Available capabilities\n\n");
1854
1855 for cap in display_caps {
1856 let c = cap.cap;
1857 let Some(atlas_pb::transport_params::Kind::Mcp(mcp)) =
1858 c.params.as_ref().and_then(|p| p.kind.as_ref())
1859 else {
1860 continue;
1861 };
1862 let schema: serde_json::Value =
1863 serde_json::from_str(&mcp.input_schema_json).unwrap_or(serde_json::Value::Null);
1864 p.push_str(&format!(
1865 "- capability_name: {}\n - description: {}\n - args_schema: `{}`\n",
1866 cap.display_name,
1867 c.description.trim(),
1868 schema
1869 ));
1870 }
1871
1872 Ok(p)
1874}
1875
1876#[derive(Debug)]
1878struct RtdlEnvelope {
1879 content: String,
1881 rtdl_description: String,
1884 rtdl: serde_json::Value,
1886 task_update: Option<TaskState>,
1889}
1890
1891fn extract_json_object(raw: &str) -> Option<&str> {
1907 let bytes = raw.as_bytes();
1908 let start = bytes.iter().position(|&b| b == b'{')?;
1909 let mut depth = 0usize;
1910 let mut in_str = false;
1911 let mut escaped = false;
1912 for (i, &b) in bytes.iter().enumerate().skip(start) {
1913 if in_str {
1914 if escaped {
1915 escaped = false;
1916 } else if b == b'\\' {
1917 escaped = true;
1918 } else if b == b'"' {
1919 in_str = false;
1920 }
1921 continue;
1922 }
1923 match b {
1924 b'"' => in_str = true,
1925 b'{' => depth += 1,
1926 b'}' => {
1927 depth -= 1;
1928 if depth == 0 {
1929 return Some(&raw[start..=i]);
1930 }
1931 }
1932 _ => {}
1933 }
1934 }
1935 None
1936}
1937
1938fn parse_rtdl_assistant_response(raw: &str) -> Result<RtdlEnvelope> {
1949 let candidate = extract_json_object(raw).unwrap_or(raw);
1950 let v: serde_json::Value = serde_json::from_str(candidate)?;
1951 let obj = v
1952 .as_object()
1953 .ok_or_else(|| anyhow::anyhow!("assistant response must be a JSON object"))?;
1954 const KEYS: [&str; 4] = ["content", "rtdl_description", "rtdl", "task_update"];
1955 if obj.len() != KEYS.len() || !KEYS.iter().all(|k| obj.contains_key(*k)) {
1956 anyhow::bail!(
1957 "assistant response must contain exactly `content`, `rtdl_description`, `rtdl`, and `task_update`"
1958 );
1959 }
1960 let content = obj
1961 .get("content")
1962 .and_then(|x| x.as_str())
1963 .ok_or_else(|| anyhow::anyhow!("assistant `content` must be a string"))?
1964 .to_string();
1965 let rtdl_description = obj
1966 .get("rtdl_description")
1967 .and_then(|x| x.as_str())
1968 .ok_or_else(|| anyhow::anyhow!("assistant `rtdl_description` must be a string"))?
1969 .to_string();
1970 let rtdl = obj
1971 .get("rtdl")
1972 .filter(|x| x.is_object())
1973 .ok_or_else(|| anyhow::anyhow!("assistant `rtdl` must be an object"))?
1974 .clone();
1975 let task_update = match obj.get("task_update") {
1976 None | Some(serde_json::Value::Null) => None,
1977 Some(v) => Some(parse_task_update(v)?),
1978 };
1979 Ok(RtdlEnvelope {
1980 content,
1981 rtdl_description,
1982 rtdl,
1983 task_update,
1984 })
1985}
1986
1987fn parse_task_update(v: &serde_json::Value) -> Result<TaskState> {
1992 let obj = v
1993 .as_object()
1994 .ok_or_else(|| anyhow::anyhow!("`task_update` must be null or an object"))?;
1995 const KEYS: [&str; 3] = ["goal", "success_criterion", "status"];
1996 if obj.len() != KEYS.len() || !KEYS.iter().all(|k| obj.contains_key(*k)) {
1997 anyhow::bail!(
1998 "`task_update` object must contain exactly `goal`, `success_criterion`, and `status`"
1999 );
2000 }
2001 let get = |key: &str| -> Result<String> {
2002 obj.get(key)
2003 .and_then(|x| x.as_str())
2004 .map(str::to_string)
2005 .ok_or_else(|| anyhow::anyhow!("`task_update.{key}` must be a string"))
2006 };
2007 let status = get("status")?;
2008 if status != "in_progress" && status != "done" {
2009 anyhow::bail!("`task_update.status` must be \"in_progress\" or \"done\"");
2010 }
2011 Ok(TaskState {
2012 goal: get("goal")?,
2013 success_criterion: get("success_criterion")?,
2014 status,
2015 })
2016}
2017
2018fn raw_preview(raw: &str) -> String {
2019 if raw.is_empty() {
2020 return "assistant content was empty".to_string();
2021 }
2022 let preview: String = raw.chars().take(240).collect();
2023 let ellipsis = if raw.chars().count() > 240 { "..." } else { "" };
2024 format!("assistant content preview: {preview:?}{ellipsis}")
2025}
2026
2027fn build_rtdl_retry_prompt(
2033 err: &anyhow::Error,
2034 raw_content: &str,
2035 display_caps: &[DisplayCapability<'_>],
2036) -> String {
2037 let mut p = format!(
2038 "Your previous RTDL response could not be parsed or expanded by Pilot.\n\
2039 Error: {err:#}\n\
2040 Previous response preview: {}\n\n\
2041 Fix the RTDL error and retry the same user request exactly once. If the error \
2042 mentions an unknown capability, do not repeat that capability. Return ONLY a JSON object \
2043 with exactly `content`, `rtdl_description`, `rtdl`, and `task_update`. The reply MUST begin \
2044 with `{{` and end with `}}`: no prose, narration, or markdown fences before or after it (put \
2045 any user-facing text inside `content`). Use only \
2046 capability_name values from this list; do not invent provider names, method names, or \
2047 aliases:\n",
2048 raw_preview(raw_content)
2049 );
2050 for cap in display_caps {
2051 p.push_str("- ");
2052 p.push_str(&cap.display_name);
2053 p.push('\n');
2054 }
2055 p.push_str(
2056 "\nIf no further capability call is needed, use \
2057 {\"op\":\"sequence\",\"children\":[]} as `rtdl`. If the user's requested action cannot \
2058 be performed using the listed capabilities, explain the missing capability in `content` \
2059 and return an empty RTDL sequence instead of inventing a capability.\n",
2060 );
2061 p
2062}
2063
2064fn empty_sequence_plan(plan_id: String, session_id: String, round: u32) -> Plan {
2068 Plan {
2069 plan_id,
2070 session_id,
2071 round,
2072 nodes: vec![RtdlNode {
2073 node_kind: RTDL_SEQUENCE,
2074 children: Vec::new(),
2075 call: None,
2076 op_id: "recovery".to_string(),
2077 description: "recovery: no valid plan produced".to_string(),
2078 }],
2079 root_index: 0,
2080 }
2081}
2082
2083fn rtdl_recovery_final_text() -> String {
2085 "I couldn't produce a valid robot plan after retrying once. Please try again or rephrase the request."
2086 .to_string()
2087}
2088
2089static OP_ID_SEQ: AtomicU64 = AtomicU64::new(0);
2096
2097fn next_op_id() -> String {
2099 (OP_ID_SEQ.fetch_add(1, Ordering::Relaxed) + 1).to_string()
2100}
2101
2102fn expand_rtdl_to_plan(
2103 rtdl: &serde_json::Value,
2104 target_map: &CapabilityTargetMap,
2105 plan_id: String,
2106 session_id: String,
2107 round: u32,
2108 root_description: &str,
2109) -> Result<Plan> {
2110 let mut nodes = Vec::new();
2111 let mut next_call = 0usize;
2112 let root_index = expand_rtdl_node(
2113 rtdl,
2114 "$",
2115 target_map,
2116 plan_id.as_str(),
2117 root_description,
2118 &mut next_call,
2119 &mut nodes,
2120 )?;
2121 Ok(Plan {
2122 plan_id,
2123 session_id,
2124 round,
2125 nodes,
2126 root_index,
2127 })
2128}
2129
2130fn pick_description(
2139 obj: &serde_json::Map<String, serde_json::Value>,
2140 path: &str,
2141 root_description: &str,
2142 synthesized: String,
2143) -> String {
2144 if let Some(d) = obj.get("description").and_then(|x| x.as_str()) {
2145 let d = d.trim();
2146 if !d.is_empty() {
2147 return d.to_string();
2148 }
2149 }
2150 if path == "$" && !root_description.is_empty() {
2151 return root_description.to_string();
2152 }
2153 synthesized
2154}
2155
2156fn reject_unknown_node_keys(
2164 obj: &serde_json::Map<String, serde_json::Value>,
2165 path: &str,
2166 op: &str,
2167 required: &[&str],
2168) -> Result<()> {
2169 const OPTIONAL: [&str; 2] = ["op_id", "description"];
2170 for key in obj.keys() {
2171 let known =
2172 key == "op" || required.contains(&key.as_str()) || OPTIONAL.contains(&key.as_str());
2173 if !known {
2174 anyhow::bail!("{path}: {op} node has unexpected field `{key}`");
2175 }
2176 }
2177 for req in required {
2178 if !obj.contains_key(*req) {
2179 anyhow::bail!("{path}: {op} node must contain `{req}`");
2180 }
2181 }
2182 Ok(())
2183}
2184
2185fn expand_rtdl_node(
2186 node: &serde_json::Value,
2187 path: &str,
2188 target_map: &CapabilityTargetMap,
2189 plan_id: &str,
2190 root_description: &str,
2191 next_call: &mut usize,
2192 nodes: &mut Vec<RtdlNode>,
2193) -> Result<u32> {
2194 let obj = node
2195 .as_object()
2196 .ok_or_else(|| anyhow::anyhow!("{path}: RTDL node must be an object"))?;
2197 let op = obj
2198 .get("op")
2199 .and_then(|x| x.as_str())
2200 .ok_or_else(|| anyhow::anyhow!("{path}.op must be a string"))?;
2201
2202 match op {
2203 "sequence" | "parallel" => {
2204 reject_unknown_node_keys(obj, path, op, &["children"])?;
2205 let children = obj
2206 .get("children")
2207 .and_then(|x| x.as_array())
2208 .ok_or_else(|| anyhow::anyhow!("{path}.children must be an array"))?;
2209 let node_index = nodes.len() as u32;
2210 let node_kind = if op == "sequence" {
2211 RTDL_SEQUENCE
2212 } else {
2213 RTDL_PARALLEL
2214 };
2215 let description = pick_description(
2216 obj,
2217 path,
2218 root_description,
2219 format!("{op} of {} step(s)", children.len()),
2220 );
2221 nodes.push(RtdlNode {
2222 node_kind,
2223 children: Vec::new(),
2224 call: None,
2225 op_id: next_op_id(),
2226 description,
2227 });
2228 let mut child_indices = Vec::with_capacity(children.len());
2229 for (idx, child) in children.iter().enumerate() {
2230 let child_index = expand_rtdl_node(
2231 child,
2232 &format!("{path}.children[{idx}]"),
2233 target_map,
2234 plan_id,
2235 root_description,
2236 next_call,
2237 nodes,
2238 )?;
2239 child_indices.push(child_index);
2240 }
2241 nodes[node_index as usize].children = child_indices;
2242 Ok(node_index)
2243 }
2244 "do" => {
2245 reject_unknown_node_keys(obj, path, op, &["cap", "args"])?;
2246 let cap = obj
2247 .get("cap")
2248 .and_then(|x| x.as_str())
2249 .ok_or_else(|| anyhow::anyhow!("{path}.cap must be a string"))?;
2250 let args = obj
2251 .get("args")
2252 .filter(|x| x.is_object())
2253 .ok_or_else(|| anyhow::anyhow!("{path}.args must be an object"))?;
2254 let (provider_id, contract_id) = target_map
2255 .get(cap)
2256 .cloned()
2257 .ok_or_else(|| anyhow::anyhow!("{path}.cap unknown capability `{cap}`"))?;
2258 let call_index = *next_call;
2259 *next_call += 1;
2260 let node_index = nodes.len() as u32;
2261 let description = pick_description(obj, path, root_description, format!("call {cap}"));
2262 nodes.push(RtdlNode {
2263 node_kind: RTDL_DO,
2264 children: Vec::new(),
2265 call: Some(CapabilityCall {
2266 call_id: format!("{plan_id}:{call_index}"),
2267 provider_id,
2268 contract_id,
2269 args_json: serde_json::to_string(args)?,
2270 }),
2271 op_id: next_op_id(),
2272 description,
2273 });
2274 Ok(node_index)
2275 }
2276 other => anyhow::bail!("{path}.op unknown operator `{other}`"),
2277 }
2278}
2279
2280fn plan_call_count(plan: &Plan) -> usize {
2281 plan.nodes
2282 .iter()
2283 .filter(|node| node.node_kind == RTDL_DO && node.call.is_some())
2284 .count()
2285}
2286
2287fn mixes_control_inspection_with_action(plan: &Plan) -> bool {
2288 let leaves: Vec<&str> = plan
2289 .nodes
2290 .iter()
2291 .filter_map(|node| node.call.as_ref())
2292 .filter_map(|call| call.contract_id.rsplit('/').next())
2293 .collect();
2294 let has_inspection = leaves
2295 .iter()
2296 .any(|leaf| matches!(*leaf, "get_plan_status" | "get_all_plans"));
2297 has_inspection
2298 && leaves
2299 .iter()
2300 .any(|leaf| !matches!(*leaf, "get_plan_status" | "get_all_plans"))
2301}
2302
2303fn plan_call_signatures(plan: &Plan) -> HashSet<String> {
2304 plan.nodes
2305 .iter()
2306 .filter_map(|node| node.call.as_ref())
2307 .map(|call| {
2308 let args = serde_json::from_str::<serde_json::Value>(&call.args_json)
2309 .ok()
2310 .and_then(|value| serde_json::to_string(&value).ok())
2311 .unwrap_or_else(|| call.args_json.clone());
2312 format!("{}|{}|{args}", call.provider_id, call.contract_id)
2313 })
2314 .collect()
2315}
2316
2317fn duplicate_in_flight_signature(
2318 signatures: &HashSet<String>,
2319 forest: &HashMap<String, TreeMeta>,
2320) -> Option<String> {
2321 signatures.iter().find_map(|signature| {
2322 forest
2323 .values()
2324 .any(|meta| meta.call_signatures.contains(signature))
2325 .then(|| signature.clone())
2326 })
2327}
2328
2329fn plan_cancel_targets(plan: &Plan) -> Vec<String> {
2330 plan.nodes
2331 .iter()
2332 .filter_map(|node| node.call.as_ref())
2333 .filter(|call| call.contract_id.rsplit('/').next() == Some("cancel_plan"))
2334 .filter_map(|call| serde_json::from_str::<serde_json::Value>(&call.args_json).ok())
2335 .filter_map(|args| {
2336 args.get("plan_id")
2337 .and_then(|value| value.as_str())
2338 .map(str::to_string)
2339 })
2340 .collect()
2341}
2342
2343fn invalid_cancel_target(
2344 targets: &[String],
2345 forest: &HashMap<String, TreeMeta>,
2346 cancel_requested: &HashSet<String>,
2347) -> Option<String> {
2348 targets.iter().find_map(|target| {
2349 let invalid = cancel_requested.contains(target)
2350 || forest.get(target).is_none_or(|meta| meta.control_only);
2351 invalid.then(|| target.clone())
2352 })
2353}
2354
2355fn should_replan_after_plan_done(
2356 canceled: bool,
2357 requested_cancellation: bool,
2358 cancellation_batch_complete: bool,
2359 interaction_active: bool,
2360) -> bool {
2361 interaction_active && (!canceled || (requested_cancellation && cancellation_batch_complete))
2362}
2363
2364fn rtdl_state_name(state: u32) -> String {
2366 match RtdlNodeStateEnum::try_from(state as i32) {
2367 Ok(RtdlNodeStateEnum::Pending) => "Pending".to_string(),
2368 Ok(RtdlNodeStateEnum::Running) => "Running".to_string(),
2369 Ok(RtdlNodeStateEnum::Succeeded) => "Succeeded".to_string(),
2370 Ok(RtdlNodeStateEnum::Failed) => "Failed".to_string(),
2371 Ok(RtdlNodeStateEnum::Canceled) => "Canceled".to_string(),
2372 Ok(RtdlNodeStateEnum::Timeout) => "Timeout".to_string(),
2373 Ok(RtdlNodeStateEnum::Paused) => "Paused".to_string(),
2374 Err(_) => format!("Unknown({state})"),
2375 }
2376}
2377
2378fn rtdl_node_kind_name(kind: u32) -> String {
2380 match kind {
2381 RTDL_SEQUENCE => "sequence".to_string(),
2382 RTDL_PARALLEL => "parallel".to_string(),
2383 RTDL_DO => "do".to_string(),
2384 _ => format!("unknown({kind})"),
2385 }
2386}
2387
2388fn compact_preview(value: &str, max_chars: usize) -> String {
2390 let flattened = value.replace('\n', "\\n");
2391 let mut preview: String = flattened.chars().take(max_chars).collect();
2392 if flattened.chars().count() > max_chars {
2393 preview.push_str("...");
2394 }
2395 preview
2396}
2397
2398fn call_display_name(call: &CapabilityCall) -> String {
2400 format!("{}.{}", call.provider_id, llm_name(&call.contract_id))
2401}
2402
2403fn append_plan_node_summary(plan: &Plan, node_index: usize, depth: usize, out: &mut Vec<String>) {
2405 let Some(node) = plan.nodes.get(node_index) else {
2406 out.push(format!("{}[{node_index}] missing-node", " ".repeat(depth)));
2407 return;
2408 };
2409 let indent = " ".repeat(depth);
2410 let mut line = format!(
2411 "{indent}[{node_index}] {} op_id={} desc='{}'",
2412 rtdl_node_kind_name(node.node_kind),
2413 node.op_id,
2414 compact_preview(&node.description, 160),
2415 );
2416 if let Some(call) = node.call.as_ref() {
2417 line.push_str(&format!(
2418 " cap={} args={}",
2419 call_display_name(call),
2420 compact_preview(&call.args_json, 240)
2421 ));
2422 }
2423 out.push(line);
2424 for child in &node.children {
2425 append_plan_node_summary(plan, *child as usize, depth + 1, out);
2426 }
2427}
2428
2429fn format_plan_summary(plan: &Plan) -> Vec<String> {
2431 let mut lines = Vec::new();
2432 append_plan_node_summary(plan, plan.root_index as usize, 0, &mut lines);
2433 lines
2434}
2435
2436fn log_plan_start(plan: &Plan, description: &str, round: u32, calls: usize) {
2438 info!(
2439 "[pilot/rtdl] -- plan start plan_id={} round={} calls={} --",
2440 plan.plan_id, round, calls
2441 );
2442 info!(
2443 "[pilot/rtdl] rtdl_plan_description='{}'",
2444 compact_preview(description, 240)
2445 );
2446 info!("[pilot/rtdl] rtdl_plan:");
2447 for line in format_plan_summary(plan) {
2448 info!("[pilot/rtdl] {line}");
2449 }
2450}
2451
2452fn terminal_node_detail(ns: &RtdlNodeState) -> String {
2454 if !is_terminal_executor_state(ns.state) || ns.state == RtdlNodeStateEnum::Succeeded as u32 {
2455 return String::new();
2456 }
2457 let Some(result) = ns.leaf_result.as_ref() else {
2458 return String::new();
2459 };
2460 if !result.error.trim().is_empty() {
2461 return format!(" error='{}'", compact_preview(&result.error, 180));
2462 }
2463 if !result.output.trim().is_empty() {
2464 return format!(" output='{}'", compact_preview(&result.output, 180));
2465 }
2466 String::new()
2467}
2468
2469fn log_node_state(plan_id: &str, ns: &RtdlNodeState) {
2471 let mut line = format!(
2472 "[pilot/forest] plan_id={} node={} op_id={} state={} desc='{}'",
2473 plan_id,
2474 ns.node_index,
2475 ns.op_id,
2476 rtdl_state_name(ns.state),
2477 compact_preview(&ns.description, 160),
2478 );
2479 if !ns.operator_detail.trim().is_empty() {
2480 line.push_str(&format!(
2481 " detail='{}'",
2482 compact_preview(&ns.operator_detail, 180)
2483 ));
2484 }
2485 line.push_str(&terminal_node_detail(ns));
2486 debug!("{line}");
2487}
2488
2489fn plan_completion_state(results: &[RtdlNodeState], any_failed: bool) -> String {
2491 if !any_failed {
2492 return "Succeeded".to_string();
2493 }
2494 for preferred in [
2495 RtdlNodeStateEnum::Failed as u32,
2496 RtdlNodeStateEnum::Timeout as u32,
2497 RtdlNodeStateEnum::Canceled as u32,
2498 ] {
2499 if results.iter().any(|ns| ns.state == preferred) {
2500 return rtdl_state_name(preferred);
2501 }
2502 }
2503 results
2504 .iter()
2505 .find(|ns| ns.state != RtdlNodeStateEnum::Succeeded as u32)
2506 .map(|ns| rtdl_state_name(ns.state))
2507 .unwrap_or_else(|| "Failed".to_string())
2508}
2509
2510fn log_plan_complete(plan_id: &str, results: &[RtdlNodeState], any_failed: bool) {
2512 let state = plan_completion_state(results, any_failed);
2513 let mut line = format!(
2514 "[pilot/forest] plan_id={} complete state={} terminal_nodes={}",
2515 plan_id,
2516 state,
2517 results.len()
2518 );
2519 let non_success: Vec<String> = results
2520 .iter()
2521 .filter(|ns| ns.state != RtdlNodeStateEnum::Succeeded as u32)
2522 .map(|ns| format!("node={} state={}", ns.node_index, rtdl_state_name(ns.state)))
2523 .collect();
2524 if !non_success.is_empty() {
2525 line.push_str(" non_success=[");
2526 line.push_str(&non_success.join(", "));
2527 line.push(']');
2528 }
2529 line.push_str("; replanning");
2530 info!("{line}");
2531}
2532
2533fn is_terminal_executor_state(state: u32) -> bool {
2534 matches!(
2535 RtdlNodeStateEnum::try_from(state as i32),
2536 Ok(RtdlNodeStateEnum::Succeeded
2537 | RtdlNodeStateEnum::Failed
2538 | RtdlNodeStateEnum::Canceled
2539 | RtdlNodeStateEnum::Timeout)
2540 )
2541}
2542
2543fn rtdl_result_to_messages(r: &CapabilityCallResult) -> history::ToolResultHistory {
2544 let mapped = if r.success {
2545 history::tool_result_to_messages(&r.call_id, &r.output)
2546 } else {
2547 history::ToolResultHistory {
2548 tool_messages: vec![Message::user(&r.output)],
2549 followup_messages: vec![],
2550 }
2551 };
2552
2553 let tool_messages = mapped
2554 .tool_messages
2555 .into_iter()
2556 .map(|msg| {
2557 let output = msg.content.unwrap_or_default();
2558 let feedback = serde_json::json!({
2559 "leaf_result": {
2560 "call_id": r.call_id,
2561 "contract_id": r.contract_id,
2562 "success": r.success,
2563 "output": output,
2564 "error": r.error,
2565 }
2566 });
2567 Message::user(&format!(
2568 "Executor feedback for the current RTDL leaf (not a new user request): {}",
2569 feedback
2570 ))
2571 })
2572 .collect();
2573
2574 history::ToolResultHistory {
2575 tool_messages,
2576 followup_messages: mapped.followup_messages,
2577 }
2578}
2579
2580fn load_agent_soul() -> Option<String> {
2587 if let Ok(p) = std::env::var("ROBONIX_PILOT_SOUL") {
2588 let p = p.trim();
2589 if !p.is_empty() {
2590 return std::fs::read_to_string(p).ok();
2591 }
2592 }
2593 let home = std::env::var_os("HOME").map(PathBuf::from)?;
2594 let soul = home.join(".robonix").join("SOUL.md");
2595 if soul.is_file() {
2596 return std::fs::read_to_string(soul).ok();
2597 }
2598 None
2599}
2600
2601fn build_system_prompt(soul: Option<&str>) -> String {
2602 let mut p = String::new();
2603 if let Some(s) = soul {
2604 let t = s.trim();
2605 if !t.is_empty() {
2606 p.push_str("## Agent SOUL\n\n");
2607 p.push_str(t);
2608 p.push_str("\n\n---\n\n");
2609 }
2610 }
2611 p.push_str(
2612 "\
2613You are the Robonix Pilot — the reasoning and planning component of a robot system.
2614You receive requests from a user or higher-level system and translate them into actions
2615by planning capability calls available to you.
2616
2617## Operating principles
2618- ACT immediately using available capabilities. Do not ask the user to run things themselves.
2619- Each capability call you plan is dispatched to the Executor runtime, which handles the
2620 actual robot hardware or service call.
2621- COMPOSE multi-step RTDL trees. When you already know several steps that don't
2622 depend on each other's results, put them ALL in one `sequence` (ordered) or
2623 `parallel` (independent) tree in a single round — that is the entire point of
2624 RTDL. Emitting one single-node tree per round (ReAct-style drip) is wrong
2625 UNLESS the next step genuinely needs to see the previous step's result.
2626- Do NOT claim missing capabilities unless verified from the current capability list/results.
2627 - If `memory_search` / `memory_save` / `memory_compact` capabilities are available,
2628 treat long-term memory as available via those capabilities.
2629- Prefer structured output; report capability results concisely.
2630- Scope every result to the `plan_id` and independent RTDL tree named in its
2631 Executor feedback. If a capability fails, times out, returns success=false,
2632 or gives an unsafe/unexpected result, stop only steps that depend on that
2633 result. Report that branch failure, but let unrelated in-flight trees continue.
2634 Never cancel a different in-flight tree merely because this tree failed.
2635- Cancel a running tree only when the latest user steer explicitly asks to stop
2636 work covered by that tree, or when continuing that same tree is unsafe. A
2637 failure in an independent monitoring, greeting, observation, or query branch
2638 is not permission to cancel navigation or another physical task.
2639- For any boundary stop, select the explicitly requested step from the ordered
2640 in-flight RTDL step list and call `builtin_stop_plan_at` once. The target may
2641 be any step in the plan; never assume it means the currently running step.
2642 Use `on_complete` for 'after step X' and `on_enter` for 'before step X'.
2643 Bind X itself; never substitute X's predecessor or successor.
2644- Do not execute a later physical step unless its required earlier steps have succeeded.
2645- For semantic navigation, resolve names through Scene before calling navigation:
2646 - call Scene `list_objects` first to discover the stable ID for a named room or
2647 physical object; pass that exact full ID to the goal tool, never its label,
2648 room number, or a guessed ID; use `get_scene_graph` only when object
2649 relationships are needed;
2650 - named rooms or regions MUST use Scene `goal_room`; never use `goal_near`,
2651 Memory coordinates, or guessed coordinates for a room destination;
2652 - physical objects MUST use Scene `goal_near` to obtain an approach pose;
2653 - call navigation only when Scene returns `reachable=true`.
2654- Long-term Memory is historical context, not live spatial state. It may help
2655 recall what the user called a place, but it never replaces current Scene
2656 resolution for a named destination and never turns a task-specific grasp or
2657 observation pose into a room goal.
2658- After navigation, relate the result to the resolved requested destination.
2659 A transport/action status of `SUCCEEDED` does not by itself prove that the
2660 requested movement occurred: if the submitted pose was already the current
2661 pose, state that the robot was already there instead of claiming it moved.
2662- Some later messages may be labelled `Executor feedback for the current task`.
2663 Treat those as results of capability calls you already planned, not as new
2664 user requests.
2665- If executor feedback already contains enough information to answer the
2666 user's request, answer in `content`, set `task_update.status` to `done`, and
2667 output an empty RTDL sequence. Do not repeat the same observation capability
2668 just to confirm unchanged data.
2669
2670## Interaction and execution lifetime
2671The harness owns the latest instruction shown in \"Current user interaction\".
2672Copy it exactly into a non-null `task_update.goal`; `task_update` reports
2673progress and never replaces user intent. Older conversation remains in message
2674history. Independently running work appears only in \"In-flight RTDL trees\"
2675and may outlive this interaction. Preserve unrelated trees; if the latest
2676instruction conflicts with one, target that specific plan with cancel/stop
2677before dispatching its replacement.
2678
2679Mark the current interaction `done` once its own requested outcome is verified,
2680even when an unrelated long-running tree remains active. An empty RTDL sequence
2681alone does not prove completion; it may also mean waiting for an in-flight tree.
2682Concretely:
2683
2684- Set a concrete `task_update.success_criterion` as soon as you understand the
2685 goal (e.g. for 'turn around': yaw delta ≈ 180° from the starting pose; for
2686 'find the door': a door is visible in a camera observation).
2687- For pure observation or visual question-answering tasks, one successful
2688 observation is usually enough. After answering from that observation, mark
2689 `status: \"done\"` with an empty RTDL sequence.
2690- For tasks that change robot or world state, batch the steps you can already
2691 foresee into one tree, then verify at meaningful checkpoints — not after
2692 literally every action. Re-observe and re-plan when the NEXT step depends on
2693 what you'd see (e.g. you must confirm an object moved before grasping it), not
2694 as a reflex after each call.
2695- A single short chassis movement burst typically rotates ~0.4–0.8 rad
2696 (≈ 25–45°) or translates ~0.1–0.2 m. To turn 180° you need MULTIPLE
2697 bursts; do not assume one call finishes the rotation.
2698- Only mark `status: \"done\"` once the criterion is met OR you've exhausted
2699 reasonable attempts and need to report a blocker. 'Done.' with no
2700 verification is wrong — verify first.
2701- On the very rare case where the user explicitly cancels, you may stop
2702 early; otherwise keep going.
2703- For every action-producing RTDL response, put one concise user-facing progress
2704 update in `content` that says what is happening now. Do not repeat an unchanged
2705 update. When the task completes or needs clarification, use `content` for the
2706 concise final result or question.
2707",
2708 );
2709 p
2710}
2711
2712#[cfg(test)]
2713mod tests {
2714 use super::{
2715 CapabilityTargetMap, DEFAULT_SUCCESS_CRITERION, MetaPlanOp, RTDL_DO, RTDL_PARALLEL,
2716 RTDL_SEQUENCE, TaskState, TreeMeta, TreeStep, append_steer, apply_task_update,
2717 build_executor_active_block, build_forest_block, configured_vlm_idle_timeout,
2718 duplicate_in_flight_signature, expand_rtdl_to_plan, extract_json_object,
2719 feed_results_into_history, format_plan_summary, invalid_cancel_target, is_control_only,
2720 is_legacy_plan_control_contract, mixes_control_inspection_with_action, parse_meta_plan_op,
2721 parse_rtdl_assistant_response, parse_task_update, plan_call_signatures,
2722 rtdl_node_kind_name, rtdl_recovery_final_text, rtdl_state_name,
2723 should_replan_after_plan_done, skip_memory_prefetch, start_or_resume_task,
2724 task_is_session_end,
2725 };
2726 use crate::pb::pilot::{CapabilityCall, CapabilityCallResult, Plan, RtdlNode, Task};
2727 use serde_json::json;
2728 use std::collections::{HashMap, HashSet};
2729 use std::time::Duration;
2730
2731 #[test]
2732 fn vlm_idle_timeout_is_bounded_and_has_a_responsive_default() {
2733 assert_eq!(configured_vlm_idle_timeout(None), Duration::from_secs(30));
2734 assert_eq!(
2735 configured_vlm_idle_timeout(Some("1")),
2736 Duration::from_secs(5)
2737 );
2738 assert_eq!(
2739 configured_vlm_idle_timeout(Some("600")),
2740 Duration::from_secs(300)
2741 );
2742 assert_eq!(
2743 configured_vlm_idle_timeout(Some("bad")),
2744 Duration::from_secs(30)
2745 );
2746 }
2747
2748 #[test]
2749 fn executor_snapshot_is_authoritative_across_turns() {
2750 let block = build_executor_active_block(Some(
2751 r#"{"count":99,"plans":[{"plan_id":"8","description":"greet","ops":[]}]}"#,
2752 ));
2753 assert!(block.contains("\"count\":1"));
2754 assert!(block.contains("\"plan_id\":\"8\""));
2755 assert!(block.contains("long-running skills started by earlier interactions"));
2756 }
2757
2758 #[test]
2759 fn unavailable_executor_snapshot_forbids_guessing_zero() {
2760 let block = build_executor_active_block(None);
2761 assert!(block.contains("status: unavailable"));
2762 assert!(block.contains("Never guess a task count"));
2763 }
2764
2765 #[test]
2766 fn only_requested_cancellation_replans_for_final_confirmation() {
2767 assert!(should_replan_after_plan_done(false, false, true, true));
2768 assert!(should_replan_after_plan_done(true, true, true, true));
2769 assert!(!should_replan_after_plan_done(true, true, false, true));
2770 assert!(!should_replan_after_plan_done(true, false, true, true));
2771 assert!(!should_replan_after_plan_done(true, true, true, false));
2772 }
2773
2774 #[test]
2775 fn root_meta_ops_parse_without_becoming_rtdl_nodes() {
2776 assert_eq!(
2777 parse_meta_plan_op(&json!({"op":"cancel_plan","plan_id":"7"})).unwrap(),
2778 Some(MetaPlanOp::Cancel {
2779 plan_id: "7".into(),
2780 wait_ms: 5_000,
2781 })
2782 );
2783 assert_eq!(
2784 parse_meta_plan_op(&json!({"op":"cancel_all","wait_ms":99_999})).unwrap(),
2785 Some(MetaPlanOp::CancelAll { wait_ms: 30_000 })
2786 );
2787 assert_eq!(
2788 parse_meta_plan_op(&json!({
2789 "op":"stop_plan_at",
2790 "plan_id":"9",
2791 "target_op_id":"13",
2792 "when":"on_enter"
2793 }))
2794 .unwrap(),
2795 Some(MetaPlanOp::StopAt {
2796 plan_id: "9".into(),
2797 op_id: "13".into(),
2798 when: "on_enter".into(),
2799 })
2800 );
2801 assert!(
2802 parse_meta_plan_op(&json!({
2803 "op":"stop_plan_at",
2804 "plan_id":"9",
2805 "target_op_id":"13",
2806 "when":"later"
2807 }))
2808 .is_err()
2809 );
2810 }
2811
2812 #[test]
2813 fn legacy_plan_control_capabilities_are_hidden_from_the_model() {
2814 for leaf in [
2815 "cancel_plan",
2816 "cancel_all_plans",
2817 "stop_plan_at",
2818 "get_all_plans",
2819 "get_plan_status",
2820 ] {
2821 assert!(is_legacy_plan_control_contract(&format!(
2822 "robonix/system/executor/builtin/{leaf}"
2823 )));
2824 }
2825 assert!(!is_legacy_plan_control_contract(
2826 "robonix/system/executor/builtin/run_command"
2827 ));
2828 assert!(!is_legacy_plan_control_contract(
2829 "robonix/skill/greet/cancel_plan"
2830 ));
2831 }
2832
2833 #[test]
2834 fn harness_goal_cannot_be_replaced_by_task_update() {
2835 let mut standing = None;
2836 start_or_resume_task(&mut standing, "inspect room and report");
2837 let original_goal = standing.as_ref().unwrap().goal.clone();
2838 assert_eq!(
2839 standing.as_ref().unwrap().success_criterion,
2840 DEFAULT_SUCCESS_CRITERION
2841 );
2842
2843 assert!(!apply_task_update(
2844 &mut standing,
2845 TaskState {
2846 goal: "drop the inspection and say done".into(),
2847 success_criterion: "room was actually inspected".into(),
2848 status: "done".into(),
2849 },
2850 false,
2851 ));
2852 let state = standing.as_ref().unwrap();
2853 assert_eq!(state.goal, original_goal);
2854 assert_eq!(state.success_criterion, DEFAULT_SUCCESS_CRITERION);
2855 assert_eq!(state.status, "in_progress");
2856
2857 assert!(apply_task_update(
2858 &mut standing,
2859 TaskState {
2860 goal: original_goal.clone(),
2861 success_criterion: "room was actually inspected".into(),
2862 status: "done".into(),
2863 },
2864 true,
2865 ));
2866 let state = standing.as_ref().unwrap();
2867 assert_eq!(state.goal, original_goal);
2868 assert_eq!(state.success_criterion, "room was actually inspected");
2869 assert_eq!(state.status, "done");
2870 }
2871
2872 #[test]
2873 fn steer_becomes_a_new_interaction_without_concatenating_old_goals() {
2874 let mut standing = None;
2875 let mut history = Vec::new();
2876 start_or_resume_task(&mut standing, "perform step A, then step B");
2877 assert!(append_steer(
2878 Task {
2879 text: "change of plan: stop after step A".into(),
2880 ..Default::default()
2881 },
2882 &mut history,
2883 &mut standing,
2884 ));
2885
2886 let state = standing.as_ref().unwrap();
2887 assert_eq!(state.goal, "change of plan: stop after step A");
2888 assert!(!state.goal.contains("perform step A"));
2889 assert_eq!(history.len(), 1);
2890 assert_eq!(
2891 history[0].content.as_deref(),
2892 Some("change of plan: stop after step A")
2893 );
2894 let prompt = state.prompt_block();
2895 assert!(prompt.contains("Current user interaction"));
2896 assert!(!prompt.contains("user_instruction_history"));
2897 }
2898
2899 #[test]
2900 fn steer_targets_one_plan_without_discarding_independent_work() {
2901 let mut standing = None;
2902 let mut history = Vec::new();
2903 start_or_resume_task(&mut standing, "go to the meeting room");
2904
2905 let mut forest = HashMap::new();
2906 for (plan_id, description, capability) in [
2907 ("11", "navigate to the meeting room", "navigation_navigate"),
2908 ("5", "watch for passersby", "greet_greet"),
2909 ] {
2910 forest.insert(
2911 plan_id.to_string(),
2912 TreeMeta {
2913 description: description.into(),
2914 control_only: false,
2915 call_signatures: HashSet::new(),
2916 steps: vec![TreeStep {
2917 op_id: format!("op-{plan_id}"),
2918 description: description.into(),
2919 capability: capability.into(),
2920 }],
2921 },
2922 );
2923 }
2924
2925 assert!(append_steer(
2926 Task {
2927 text: "cancel the meeting-room trip and return to room 315".into(),
2928 ..Default::default()
2929 },
2930 &mut history,
2931 &mut standing,
2932 ));
2933 assert_eq!(
2934 standing.as_ref().unwrap().goal,
2935 "cancel the meeting-room trip and return to room 315"
2936 );
2937
2938 let prompt = build_forest_block(&forest, &HashSet::new());
2939 assert!(prompt.contains("plan_id=11"));
2940 assert!(prompt.contains("plan_id=5"));
2941 assert!(prompt.contains("navigate to the meeting room"));
2942 assert!(prompt.contains("watch for passersby"));
2943 assert_eq!(
2944 invalid_cancel_target(&["11".into()], &forest, &HashSet::new()),
2945 None
2946 );
2947 assert_eq!(
2948 invalid_cancel_target(&["5".into()], &forest, &HashSet::new()),
2949 None
2950 );
2951
2952 let requested = HashSet::from(["11".to_string()]);
2953 assert_eq!(
2954 invalid_cancel_target(&["11".into()], &forest, &requested),
2955 Some("11".to_string())
2956 );
2957 assert_eq!(
2958 invalid_cancel_target(&["5".into()], &forest, &requested),
2959 None
2960 );
2961 }
2962
2963 #[test]
2964 fn forest_prompt_distinguishes_immediate_cancel_from_boundary_stop() {
2965 let mut forest = HashMap::new();
2966 forest.insert(
2967 "4".to_string(),
2968 TreeMeta {
2969 description: "ordered multi-step task".into(),
2970 control_only: false,
2971 call_signatures: HashSet::new(),
2972 steps: vec![
2973 TreeStep {
2974 op_id: "op-restaurant".into(),
2975 description: "move to restaurant".into(),
2976 capability: "navigate".into(),
2977 },
2978 TreeStep {
2979 op_id: "op-meeting".into(),
2980 description: "move to meeting room".into(),
2981 capability: "navigate".into(),
2982 },
2983 ],
2984 },
2985 );
2986 let prompt = build_forest_block(&forest, &HashSet::new());
2987 assert!(prompt.contains("op_id=op-restaurant"));
2988 assert!(prompt.contains("move to meeting room"));
2989 assert!(prompt.contains("target is the currently running step"));
2990 assert!(prompt.contains("do not query status"));
2991 assert!(prompt.contains("on_complete"));
2992 assert!(prompt.contains("on_enter"));
2993 }
2994
2995 #[test]
2996 fn executor_feedback_is_scoped_to_its_independent_tree() {
2997 let mut history = Vec::new();
2998 feed_results_into_history(
2999 &mut history,
3000 "9",
3001 "start greet watch",
3002 &[CapabilityCallResult {
3003 call_id: "9:0".into(),
3004 contract_id: "robonix/skill/greet/greet".into(),
3005 success: false,
3006 error: "activation failed".into(),
3007 ..Default::default()
3008 }],
3009 );
3010 let scope = history[0].content.as_deref().unwrap_or_default();
3011 assert!(scope.contains("plan_id=9"));
3012 assert!(scope.contains("start greet watch"));
3013 assert!(scope.contains("does not cancel or invalidate other in-flight trees"));
3014 }
3015
3016 #[test]
3017 fn duplicate_in_flight_calls_are_detected_by_canonical_signature() {
3018 let plan = Plan {
3019 plan_id: "2".into(),
3020 nodes: vec![RtdlNode {
3021 node_kind: RTDL_DO,
3022 call: Some(CapabilityCall {
3023 provider_id: "executor".into(),
3024 contract_id: "test/run".into(),
3025 args_json: r#"{"b":2,"a":1}"#.into(),
3026 ..Default::default()
3027 }),
3028 ..Default::default()
3029 }],
3030 ..Default::default()
3031 };
3032 let signatures = plan_call_signatures(&plan);
3033 let mut forest = HashMap::new();
3034 forest.insert(
3035 "1".to_string(),
3036 TreeMeta {
3037 description: "same call".into(),
3038 control_only: false,
3039 call_signatures: signatures.clone(),
3040 steps: Vec::new(),
3041 },
3042 );
3043 assert!(duplicate_in_flight_signature(&signatures, &forest).is_some());
3044 }
3045
3046 #[test]
3047 fn inspection_result_must_arrive_before_new_action_is_admitted() {
3048 let plan = Plan {
3049 nodes: vec![
3050 RtdlNode {
3051 node_kind: RTDL_DO,
3052 call: Some(CapabilityCall {
3053 contract_id: "robonix/system/executor/builtin/get_plan_status".into(),
3054 ..Default::default()
3055 }),
3056 ..Default::default()
3057 },
3058 RtdlNode {
3059 node_kind: RTDL_DO,
3060 call: Some(CapabilityCall {
3061 contract_id: "robonix/system/executor/builtin/run_command".into(),
3062 ..Default::default()
3063 }),
3064 ..Default::default()
3065 },
3066 ],
3067 ..Default::default()
3068 };
3069 assert!(mixes_control_inspection_with_action(&plan));
3070
3071 let inspection_only = Plan {
3072 nodes: vec![plan.nodes[0].clone()],
3073 ..Default::default()
3074 };
3075 assert!(!mixes_control_inspection_with_action(&inspection_only));
3076 }
3077
3078 #[test]
3079 fn cancel_target_must_be_live_and_not_already_requested() {
3080 let mut forest = HashMap::new();
3081 forest.insert(
3082 "7".to_string(),
3083 TreeMeta {
3084 description: "drive".into(),
3085 control_only: false,
3086 call_signatures: HashSet::new(),
3087 steps: Vec::new(),
3088 },
3089 );
3090 let targets = vec!["7".to_string()];
3091 assert!(invalid_cancel_target(&targets, &forest, &HashSet::new()).is_none());
3092 assert_eq!(
3093 invalid_cancel_target(&targets, &forest, &HashSet::from(["7".to_string()])),
3094 Some("7".to_string())
3095 );
3096 assert_eq!(
3097 invalid_cancel_target(&["8".to_string()], &forest, &HashSet::new()),
3098 Some("8".to_string())
3099 );
3100 }
3101
3102 #[test]
3103 fn rtdl_recovery_final_text_hides_internal_error() {
3104 let text = rtdl_recovery_final_text();
3105 assert!(text.contains("valid robot plan"));
3106 assert!(!text.contains("expand RTDL"));
3107 assert!(!text.contains("capability call"));
3108 assert!(!text.contains("assistant content preview"));
3109 }
3110
3111 fn task(ctx: &str) -> Task {
3112 Task {
3113 task_id: "t".into(),
3114 session_id: "s".into(),
3115 source: 0,
3116 text: String::new(),
3117 audio_data: Vec::new(),
3118 context_json: ctx.into(),
3119 timestamp_ms: 0,
3120 }
3121 }
3122
3123 #[test]
3124 fn session_end_explicit() {
3125 assert!(task_is_session_end(&task(r#"{"session_end":true}"#)));
3126 }
3127
3128 #[test]
3129 fn session_end_legacy_alias() {
3130 assert!(task_is_session_end(&task(
3131 r#"{"robonix_session_end":true}"#
3132 )));
3133 }
3134
3135 #[test]
3136 fn session_end_false_or_absent() {
3137 assert!(!task_is_session_end(&task("")));
3138 assert!(!task_is_session_end(&task(r#"{"foo":1}"#)));
3139 assert!(!task_is_session_end(&task(r#"{"session_end":false}"#)));
3140 }
3141
3142 #[test]
3143 fn skip_prefetch_chitchat() {
3144 assert!(skip_memory_prefetch("hi"));
3145 assert!(skip_memory_prefetch("Hello"));
3146 }
3147
3148 #[test]
3149 fn no_skip_prefetch_real_query() {
3150 assert!(!skip_memory_prefetch("open the door"));
3151 assert!(!skip_memory_prefetch("find me a red cup"));
3152 }
3153
3154 fn single_do_plan(contract_leaf: &str) -> Plan {
3155 Plan {
3156 plan_id: "p".into(),
3157 session_id: "s".into(),
3158 round: 0,
3159 root_index: 0,
3160 nodes: vec![RtdlNode {
3161 node_kind: RTDL_DO,
3162 children: vec![],
3163 call: Some(CapabilityCall {
3164 call_id: "p:0".into(),
3165 provider_id: "executor".into(),
3166 contract_id: format!("robonix/system/executor/builtin/{contract_leaf}"),
3167 args_json: "{}".into(),
3168 }),
3169 op_id: "op_1".into(),
3170 description: "control action".into(),
3171 }],
3172 }
3173 }
3174
3175 #[test]
3176 fn plan_control_builtins_are_control_only() {
3177 for leaf in [
3178 "cancel_plan",
3179 "cancel_all_plans",
3180 "get_all_plans",
3181 "get_plan_status",
3182 "stop_plan_at",
3183 ] {
3184 assert!(is_control_only(&single_do_plan(leaf)), "{leaf}");
3185 }
3186 assert!(!is_control_only(&single_do_plan("list_dir")));
3187 }
3188
3189 #[test]
3190 fn rtdl_state_names_are_human_readable() {
3191 assert_eq!(rtdl_state_name(0), "Pending");
3192 assert_eq!(rtdl_state_name(2), "Succeeded");
3193 assert_eq!(rtdl_state_name(3), "Failed");
3194 assert_eq!(rtdl_state_name(4), "Canceled");
3195 assert_eq!(rtdl_state_name(5), "Timeout");
3196 assert_eq!(rtdl_state_name(999), "Unknown(999)");
3197 }
3198
3199 #[test]
3200 fn rtdl_node_kind_names_are_human_readable() {
3201 assert_eq!(rtdl_node_kind_name(RTDL_SEQUENCE), "sequence");
3202 assert_eq!(rtdl_node_kind_name(RTDL_PARALLEL), "parallel");
3203 assert_eq!(rtdl_node_kind_name(RTDL_DO), "do");
3204 assert_eq!(rtdl_node_kind_name(99), "unknown(99)");
3205 }
3206
3207 #[test]
3208 fn rtdl_response_requires_exact_top_level_keys() {
3209 let err = parse_rtdl_assistant_response(
3211 r#"{"content":"x","rtdl":{"op":"sequence","children":[]}}"#,
3212 )
3213 .unwrap_err();
3214 assert!(
3215 err.to_string()
3216 .contains("exactly `content`, `rtdl_description`, `rtdl`, and `task_update`")
3217 );
3218 }
3219
3220 #[test]
3221 fn rtdl_response_parses_full_envelope() {
3222 let env = parse_rtdl_assistant_response(
3223 r#"{
3224 "content":"on it",
3225 "rtdl_description":"fetch water",
3226 "rtdl":{"op":"sequence","children":[]},
3227 "task_update":{"goal":"bring water","success_criterion":"cup by user","status":"in_progress"}
3228 }"#,
3229 )
3230 .unwrap();
3231 assert_eq!(env.content, "on it");
3232 assert_eq!(env.rtdl_description, "fetch water");
3233 assert!(env.rtdl.is_object());
3234 assert_eq!(
3235 env.task_update,
3236 Some(TaskState {
3237 goal: "bring water".into(),
3238 success_criterion: "cup by user".into(),
3239 status: "in_progress".into(),
3240 })
3241 );
3242 }
3243
3244 #[test]
3245 fn rtdl_response_tolerates_prose_preamble() {
3246 let env = parse_rtdl_assistant_response(
3249 "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}",
3250 )
3251 .unwrap();
3252 assert_eq!(env.content, "on it");
3253 assert!(env.task_update.is_none());
3254 }
3255
3256 #[test]
3257 fn extract_json_object_skips_prose_and_braces_in_strings() {
3258 let got = extract_json_object("hi: {\"a\":\"x}y\",\"b\":1} trailing");
3260 assert_eq!(got, Some("{\"a\":\"x}y\",\"b\":1}"));
3261 assert_eq!(extract_json_object("no json here"), None);
3263 }
3264
3265 #[test]
3266 fn rtdl_response_task_update_null_is_none() {
3267 let env = parse_rtdl_assistant_response(
3268 r#"{"content":"x","rtdl_description":"","rtdl":{"op":"sequence","children":[]},"task_update":null}"#,
3269 )
3270 .unwrap();
3271 assert!(env.task_update.is_none());
3272 }
3273
3274 #[test]
3275 fn task_update_rejects_unknown_status() {
3276 let err = parse_task_update(&json!({
3277 "goal":"g","success_criterion":"c","status":"paused"
3278 }))
3279 .unwrap_err();
3280 assert!(err.to_string().contains("status"));
3281 }
3282
3283 #[test]
3284 fn task_update_rejects_missing_field() {
3285 let err = parse_task_update(&json!({ "goal":"g","status":"done" })).unwrap_err();
3286 assert!(err.to_string().contains("exactly"));
3287 }
3288
3289 #[test]
3290 fn rtdl_expands_sequence_to_plan_calls() {
3291 let mut targets = CapabilityTargetMap::new();
3292 targets.insert(
3293 "camera_snapshot".to_string(),
3294 (
3295 "cap-camera".to_string(),
3296 "robonix/primitive/camera/snapshot".to_string(),
3297 ),
3298 );
3299 targets.insert(
3300 "chassis_move".to_string(),
3301 (
3302 "cap-chassis".to_string(),
3303 "robonix/primitive/chassis/move".to_string(),
3304 ),
3305 );
3306
3307 let rtdl = json!({
3308 "op": "sequence",
3309 "children": [
3310 { "op": "do", "cap": "camera_snapshot", "args": {} },
3311 { "op": "do", "cap": "chassis_move", "args": { "linear": 0.1 } }
3312 ]
3313 });
3314 let plan = expand_rtdl_to_plan(&rtdl, &targets, "p".into(), "s".into(), 7, "").unwrap();
3315
3316 assert_eq!(plan.plan_id, "p");
3317 assert_eq!(plan.session_id, "s");
3318 assert_eq!(plan.round, 7);
3319 assert_eq!(plan.nodes.len(), 3);
3320 assert_eq!(plan.root_index, 0);
3321 assert_eq!(plan.nodes[0].node_kind, RTDL_SEQUENCE);
3322 assert_eq!(plan.nodes[0].children, vec![1, 2]);
3323 let first = plan.nodes[1].call.as_ref().unwrap();
3324 let second = plan.nodes[2].call.as_ref().unwrap();
3325 assert_eq!(plan.nodes[1].node_kind, RTDL_DO);
3326 assert_eq!(first.call_id, "p:0");
3327 assert_eq!(first.provider_id, "cap-camera");
3328 assert_eq!(first.contract_id, "robonix/primitive/camera/snapshot");
3329 assert_eq!(first.args_json, "{}");
3330 assert_eq!(second.call_id, "p:1");
3331 assert_eq!(second.args_json, r#"{"linear":0.1}"#);
3332 }
3333
3334 #[test]
3335 fn rtdl_expands_parallel_root() {
3336 let mut targets = CapabilityTargetMap::new();
3337 targets.insert(
3338 "camera_snapshot".to_string(),
3339 (
3340 "cap-camera".to_string(),
3341 "robonix/primitive/camera/snapshot".to_string(),
3342 ),
3343 );
3344 targets.insert(
3345 "read_temp".to_string(),
3346 (
3347 "cap-temp".to_string(),
3348 "robonix/primitive/sensor/temp".to_string(),
3349 ),
3350 );
3351
3352 let rtdl = json!({
3353 "op": "parallel",
3354 "children": [
3355 { "op": "do", "cap": "camera_snapshot", "args": {} },
3356 { "op": "do", "cap": "read_temp", "args": { "unit": "c" } }
3357 ]
3358 });
3359 let plan = expand_rtdl_to_plan(&rtdl, &targets, "p".into(), "s".into(), 1, "").unwrap();
3360
3361 assert_eq!(plan.root_index, 0);
3362 assert_eq!(plan.nodes[0].node_kind, RTDL_PARALLEL);
3363 assert_eq!(plan.nodes[0].children, vec![1, 2]);
3364 assert_eq!(plan.nodes[1].call.as_ref().unwrap().call_id, "p:0");
3365 assert_eq!(plan.nodes[2].call.as_ref().unwrap().call_id, "p:1");
3366 }
3367
3368 #[test]
3369 fn format_plan_summary_uses_tree_shape_and_compact_cap_names() {
3370 let mut targets = CapabilityTargetMap::new();
3371 targets.insert(
3372 "nav2.navigation_status".to_string(),
3373 (
3374 "nav2".to_string(),
3375 "robonix/service/navigation/status".to_string(),
3376 ),
3377 );
3378 let rtdl = json!({
3379 "op": "sequence",
3380 "description": "poll navigation status",
3381 "children": [
3382 {
3383 "op": "do",
3384 "description": "check current navigation goal",
3385 "cap": "nav2.navigation_status",
3386 "args": { "goal_id": "" }
3387 }
3388 ]
3389 });
3390 let plan = expand_rtdl_to_plan(&rtdl, &targets, "p".into(), "s".into(), 1, "").unwrap();
3391 let summary = format_plan_summary(&plan).join("\n");
3392
3393 assert!(summary.contains("[0] sequence"));
3394 assert!(summary.contains("[1] do"));
3395 assert!(summary.contains("cap=nav2.navigation_status"));
3396 assert!(summary.contains(r#"args={"goal_id":""}"#));
3397 assert!(summary.contains(" [1] do"));
3398 assert!(!summary.contains("kind="));
3399 assert!(!summary.contains("state="));
3400 assert!(!summary.contains("children"));
3401 assert!(!summary.contains("robonix/service/navigation/status"));
3402 assert!(!summary.contains("call_id"));
3403 }
3404
3405 #[test]
3406 fn rtdl_nested_call_ids_follow_json_traversal_order() {
3407 let mut targets = CapabilityTargetMap::new();
3408 for name in ["a", "b", "c"] {
3409 targets.insert(
3410 name.to_string(),
3411 (format!("provider-{name}"), format!("robonix/test/{name}")),
3412 );
3413 }
3414
3415 let rtdl = json!({
3416 "op": "sequence",
3417 "children": [
3418 { "op": "do", "cap": "a", "args": {} },
3419 {
3420 "op": "parallel",
3421 "children": [
3422 { "op": "do", "cap": "b", "args": {} },
3423 { "op": "do", "cap": "c", "args": {} }
3424 ]
3425 }
3426 ]
3427 });
3428 let plan = expand_rtdl_to_plan(&rtdl, &targets, "p".into(), "s".into(), 1, "").unwrap();
3429 let calls: Vec<_> = plan
3430 .nodes
3431 .iter()
3432 .filter_map(|node| node.call.as_ref())
3433 .map(|call| call.call_id.as_str())
3434 .collect();
3435 assert_eq!(calls, vec!["p:0", "p:1", "p:2"]);
3436 }
3437
3438 #[test]
3439 fn rtdl_empty_sequence_generates_root_node() {
3440 let targets = CapabilityTargetMap::new();
3441 let rtdl = json!({ "op": "sequence", "children": [] });
3442 let plan = expand_rtdl_to_plan(&rtdl, &targets, "p".into(), "s".into(), 0, "").unwrap();
3443
3444 assert_eq!(plan.root_index, 0);
3445 assert_eq!(plan.nodes.len(), 1);
3446 assert_eq!(plan.nodes[0].node_kind, RTDL_SEQUENCE);
3447 assert!(plan.nodes[0].children.is_empty());
3448 }
3449
3450 #[test]
3451 fn rtdl_rejects_out_field() {
3452 let mut targets = CapabilityTargetMap::new();
3453 targets.insert(
3454 "camera_snapshot".to_string(),
3455 (
3456 "cap-camera".to_string(),
3457 "robonix/primitive/camera/snapshot".to_string(),
3458 ),
3459 );
3460 let rtdl = json!({
3461 "op": "do",
3462 "cap": "camera_snapshot",
3463 "args": {},
3464 "out": { "image": "img" }
3465 });
3466 let err = expand_rtdl_to_plan(&rtdl, &targets, "p".into(), "s".into(), 0, "").unwrap_err();
3467 assert!(err.to_string().contains("unexpected field `out`"));
3468 }
3469
3470 #[test]
3471 fn rtdl_uses_model_node_description_over_synthesized() {
3472 let mut targets = CapabilityTargetMap::new();
3473 targets.insert(
3474 "camera_snapshot".to_string(),
3475 (
3476 "cap-camera".to_string(),
3477 "robonix/primitive/camera/snapshot".to_string(),
3478 ),
3479 );
3480 let rtdl = json!({
3483 "op": "sequence",
3484 "op_id": 0,
3485 "description": "inspect the doorway",
3486 "children": [
3487 {
3488 "op": "do",
3489 "op_id": 0,
3490 "description": "take a camera snapshot of the door",
3491 "cap": "camera_snapshot",
3492 "args": {}
3493 }
3494 ]
3495 });
3496 let plan = expand_rtdl_to_plan(&rtdl, &targets, "p".into(), "s".into(), 0, "").unwrap();
3497 assert_eq!(plan.nodes[0].description, "inspect the doorway");
3498 assert_eq!(
3499 plan.nodes[1].description,
3500 "take a camera snapshot of the door"
3501 );
3502 assert!(!plan.nodes[0].op_id.is_empty());
3504 assert_ne!(plan.nodes[0].op_id, plan.nodes[1].op_id);
3505 }
3506
3507 #[test]
3508 fn rtdl_rejects_parallel_non_array_children() {
3509 let targets = CapabilityTargetMap::new();
3510 let rtdl = json!({ "op": "parallel", "children": {} });
3511 let err = expand_rtdl_to_plan(&rtdl, &targets, "p".into(), "s".into(), 0, "").unwrap_err();
3512 assert!(err.to_string().contains("children must be an array"));
3513 }
3514
3515 #[test]
3516 fn rtdl_rejects_do_non_object_args() {
3517 let mut targets = CapabilityTargetMap::new();
3518 targets.insert(
3519 "camera_snapshot".to_string(),
3520 (
3521 "cap-camera".to_string(),
3522 "robonix/primitive/camera/snapshot".to_string(),
3523 ),
3524 );
3525 let rtdl = json!({ "op": "do", "cap": "camera_snapshot", "args": [] });
3526 let err = expand_rtdl_to_plan(&rtdl, &targets, "p".into(), "s".into(), 0, "").unwrap_err();
3527 assert!(err.to_string().contains("args must be an object"));
3528 }
3529
3530 #[test]
3531 fn rtdl_rejects_unknown_op() {
3532 let targets = CapabilityTargetMap::new();
3533 let rtdl = json!({ "op": "race", "children": [] });
3534 let err = expand_rtdl_to_plan(&rtdl, &targets, "p".into(), "s".into(), 0, "").unwrap_err();
3535 assert!(err.to_string().contains("unknown operator"));
3536 }
3537}