Skip to main content

robonix_pilot/
planner.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2// Author: wheatfox <wheatfox17@icloud.com>
3//
4use 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
34/// gRPC client for executor's plan-dispatch contract. Pilot only ever calls
35/// `Execute(Plan)` — discovery happens directly against atlas now.
36pub 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/// Harness-owned state for the latest user interaction. Long-running work is
81/// represented independently by the RTDL forest; it must not keep older user
82/// text welded into the current goal forever.
83#[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    /// Whether the LLM has declared the overall task complete. This is the
95    /// authoritative completion signal — an empty RTDL tree alone does not end
96    /// the turn.
97    fn is_done(&self) -> bool {
98        self.status == "done"
99    }
100
101    /// Render the latest interaction separately from the in-flight forest.
102    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
111/// `context_json`: `{"session_end": true}` (or `robonix_session_end`) — run memory compaction only, no VLM turn.
112fn 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
127/// `context_json.modality` — set by liaison to "text" / "voice" / "api".
128/// `None` when the field is missing or context_json is empty/malformed.
129fn 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
143/// Skip vector memory prefetch for trivial chit-chat (saves latency and noise).
144fn 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
150/// Metadata for one in-flight RTDL tree in the forest. Trees are keyed by
151/// pilot-assigned `plan_id`; this carries what the supervisor and the LLM need
152/// to reason about a running tree (and, later, what the chat UI renders).
153struct TreeMeta {
154    /// LLM-supplied `rtdl_description` (sub-task label).
155    description: String,
156    /// True when this tree is purely control ops (only `cancel_plan` /
157    /// `cancel_all_plans`). Such trees are NOT advertised to the LLM as
158    /// cancellable in-flight work — a cancel is not itself a task tree, and
159    /// listing it makes the model cancel its own cancels in a loop.
160    control_only: bool,
161    /// Canonical provider/contract/args signatures for calls in this tree.
162    /// The harness rejects a second tree containing an identical call while
163    /// the first is still in flight; execution latency must not duplicate a
164    /// physical or external command.
165    call_signatures: HashSet<String>,
166    /// Ordered executable leaves from the original RTDL graph. Keeping these
167    /// visible lets the model target any semantic boundary in one control call
168    /// instead of querying live state first or guessing what "current" means.
169    steps: Vec<TreeStep>,
170}
171
172struct TreeStep {
173    op_id: String,
174    description: String,
175    capability: String,
176}
177
178/// Events fed from per-tree driver tasks back to the supervisor loop. One
179/// `drive_plan` task runs per dispatched tree and streams these.
180enum ForestEvent {
181    /// A node changed state — forwarded for live visualisation. Carries the
182    /// originating tree's `plan_id`. The state is boxed because it is much
183    /// larger than the other variant's payload.
184    NodeState {
185        plan_id: String,
186        node_state: Box<RtdlNodeState>,
187    },
188    /// A tree finished (or its Execute stream ended/errored). Carries one
189    /// full `RtdlNodeState` record for every node that reached a terminal
190    /// state (leaf and non-leaf), collected from the tree.
191    PlanDone {
192        plan_id: String,
193        results: Vec<RtdlNodeState>,
194        any_failed: bool,
195        /// True when this tree ended because it was canceled (a node reached
196        /// CANCELED), as opposed to running to natural success/failure. A
197        /// cancellation fulfils a prior decision and carries no new info, so the
198        /// supervisor must NOT trigger a fresh planning round for it — otherwise
199        /// "cancel old plan → PlanDone → replan → model re-cancels" becomes a
200        /// self-sustaining storm with monotonically growing plan ids.
201        canceled: bool,
202    },
203}
204
205/// Drive one dispatched plan's Execute stream to completion, forwarding node
206/// states for visualisation and collecting terminal results. Sends exactly one
207/// `PlanDone` when the stream ends. Runs as its own task so the supervisor loop
208/// never blocks on a single tree — concurrent trees form the forest.
209async 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                    // Forward every node state for live viz.
249                    let _ = events_tx
250                        .send(ForestEvent::NodeState {
251                            plan_id: plan_id.clone(),
252                            node_state: Box::new(ns.clone()),
253                        })
254                        .await;
255                    // Collect the full RtdlNodeState for every node that reaches
256                    // a terminal state (leaf and non-leaf). A non-success
257                    // terminal state marks the round as failed.
258                    if is_terminal_executor_state(ns.state) {
259                        forest_revision.fetch_add(1, Ordering::Release);
260                        if ns.state == RtdlNodeStateEnum::Canceled as u32 {
261                            // Cancellation is not a failure to recover from — it
262                            // is the model's own stop request taking effect. Flag
263                            // it so the supervisor suppresses the post-cancel
264                            // replan that would otherwise feed a cancel storm.
265                            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
293/// Cancel every real task tree owned by this turn before reporting the Pilot
294/// session interrupted. Dropping the Execute stream alone only detaches Pilot;
295/// Executor continues the plan (and synchronous tools such as run_command)
296/// unless its PlanRuntime receives an explicit cancel_plan request.
297async 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
459/// Render the in-flight forest as a system-prompt block so the LLM can see what
460/// is still running and reference a `plan_id` to cancel it. Empty when no tree
461/// is running. Trees are ordered by numeric plan id for stable output.
462/// True when every `do` node is a plan-control builtin and there is at least
463/// one. Plan-control trees are not themselves cancellable task work; advertising
464/// them makes the model inspect or cancel its own control actions.
465fn 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    // Only real task trees are cancellable in-flight work; hide pure control
516    // (cancel-only) trees so the model never tries to cancel its own cancels.
517    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
631/// Pull every queued mid-task steer into the LLM history as fresh user input.
632///
633/// A steer is just a `Task` the user submitted while the turn was already
634/// running. Draining is non-blocking; returns whether anything was pulled so
635/// the caller knows to re-plan. The model decides for itself whether the steer
636/// requires a root plan-control meta op.
637fn 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
683/// Apply only progress fields from the model. The user-owned goal is immutable
684/// within the standing task; steering is appended by the harness above. The
685/// model may refine the default success criterion once, but cannot erase or
686/// replace an established criterion. Completion is accepted only at a harness
687/// safe point with no new or in-flight execution.
688fn 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        // The response was sampled for an older interaction. Applying even
703        // its status or success criterion can falsely complete and discard a
704        // newer steer, so reject the entire stale update.
705        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
720/// Approx history size (in chars; ~4 chars/token) past which we compact. Tuned
721/// to keep the working window small without compacting on every short turn.
722const HISTORY_COMPACT_TRIGGER_CHARS: usize = 24_000;
723/// Most recent messages always kept verbatim through a compaction.
724const HISTORY_KEEP_RECENT: usize = 12;
725
726/// Claude-Code-style rolling compaction. When the running history grows past
727/// `HISTORY_COMPACT_TRIGGER_CHARS`, summarize everything except the most recent
728/// `HISTORY_KEEP_RECENT` messages into a single summary note (preserving goal,
729/// decisions, observations, and current state) and keep the recent turns
730/// verbatim. This shrinks the per-round prompt for the rest of the turn instead
731/// of re-shipping the full transcript every round.
732///
733/// Best-effort: any VLM error leaves history untouched (the `MAX_HISTORY` trim
734/// still bounds it). Self-limiting: after compaction the total drops below the
735/// trigger, so it won't fire again until history regrows.
736async 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
775/// Run one non-streaming VLM completion and return the full text (drains the
776/// stream). Returns `None` on any stream error.
777async 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
788/// Feed one finished tree's terminal results into the LLM history, mirroring
789/// the per-round feedback the blocking loop used to produce.
790fn 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    // 1. Build system prompt (once per turn)
862    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    // Pilot's capability catalog comes straight from atlas (filtered to
868    // MCP transport — only those are LLM-callable). McpParams ride along
869    // in Capability.params, no Connect needed.
870    let _ = consumer_id; // currently unused; kept on the signature for future channel-tracked discovery
871    let initial_caps = discovery::discover(atlas)
872        .await
873        .map_err(|e| anyhow::anyhow!("atlas capability discovery failed: {e}"))?;
874    // Pilot binds to the canonical contract_id, not the LLM-facing tool
875    // name: the latter is just the contract_id leaf and a provider could
876    // rename it freely. contract_id is the stable identity.
877    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    // 1b. Pre-fetch long-term memory
883    // Silently dispatches search_memory before the first VLM call so that
884    // relevant past context is available from the start of the turn.
885    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    // 1c. Append the per-capability docs index. Each provider that registered
902    // a `capability_md_path` shows up here as a one-liner pointing at its
903    // CAPABILITY.md; the LLM is instructed to lazy-load those via the
904    // `read_file` builtin when it actually needs that provider. This keeps the
905    // system prompt tiny while still giving the LLM full per-provider context
906    // when relevant. Errors here are non-fatal — providers that didn't register
907    // a path simply don't appear in the block.
908    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            // `provider_id` is the only token the LLM needs (it passes it to
926            // `read_capability_doc`); the one-line package description from the
927            // CAPABILITY.md frontmatter lets it judge relevance without reading
928            // the full manual. The internal `namespace` is deliberately omitted —
929            // it is routing detail the model never uses.
930            block.push_str(&format!(
931                "- `{}`{}: {}\n",
932                d.provider_id, tag, d.description
933            ));
934        }
935        system_prompt.push_str(&block);
936    }
937
938    // Voice-mode brevity hint. Liaison stamps `context_json.modality =
939    // "voice"` for every voice-path Task; in that case we ask the VLM
940    // for a short reply because the user is going to *hear* it via TTS,
941    // not read a Markdown wall. Threshold is intentionally tight (~30
942    // Chinese chars / ~50 English words) — barge-in matters more than
943    // exhaustive coverage and the user can always ask follow-ups.
944    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    // 2. Add user message to history
958    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    // Pilot-assigned plan ids come from one process-global atomic counter.
978    // They are reserved only for normal RTDL trees, never for meta operations,
979    // and are unique even when different sessions dispatch concurrently.
980
981    // 3. Forest supervisor loop.
982    //
983    // Each dispatched RTDL tree runs in its own `drive_plan` task; the loop
984    // never blocks on a single tree, so trees dispatched across rounds run
985    // concurrently — the forest. The loop wakes when a planning round is due
986    // (`should_plan`), a running tree emits an event, or a cancel arrives. It
987    // re-plans when a tree finishes; it ends only when the overall task is
988    // `done` (or was never set, i.e. chit-chat) AND no tree is still running.
989    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    // Last user-facing narration; surfaced as FinalText when the turn ends.
995    let mut last_content = String::new();
996
997    'supervisor: loop {
998        // Check for hard interrupt at the top of every iteration.
999        if *cancel_rx.borrow() {
1000            return_interrupted!(&forest);
1001        }
1002
1003        if !should_plan {
1004            // No planning due. Either wait for a running tree, or end the turn.
1005            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                // An in-progress task with no running tree and no planning event
1020                // is deliberately waiting for operator input. Replanning here
1021                // turns an empty "wait for instructions" response (or a completed
1022                // cancel-only tree) into an unbounded VLM/reply/cancel loop.
1023                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            // A tree is still running: block until it emits an event, a steer
1043            // arrives, or a cancel.
1044            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                        // Re-plan now so the model can react (and decide
1055                        // whether to cancel any in-flight tree).
1056                        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                            // Carry the originating tree's id (the executor sets
1064                            // this too, but be explicit so the live view always
1065                            // correlates with the Plan already sent).
1066                            ns.plan_id = plan_id.clone();
1067                            // Feed every node's result into context the moment it
1068                            // reaches a terminal state, using names rather than
1069                            // numeric RTDL state codes in logs. Successful nodes wait
1070                            // for PlanDone before replanning; non-success terminal
1071                            // nodes replan immediately below. The tree-level feed in
1072                            // PlanDone is dropped to avoid double-feeding — every
1073                            // leaf result already arrives here.
1074                            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                            // Any non-success terminal outcome escalates to the VLM
1090                            // immediately rather than waiting for the whole tree to
1091                            // finish (PlanDone): the result is already in context
1092                            // above, so re-plan now and let the model recover or
1093                            // abort without blocking on still-running sibling
1094                            // branches. Successes still batch at tree completion,
1095                            // which avoids the per-node re-plan storms that plain
1096                            // "re-plan on every node" caused.
1097                            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                            // Forward to the chat UI for the live forest highlight.
1106                            // Moving `ns` last avoids cloning its (possibly large)
1107                            // leaf_result on every node tick.
1108                            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                            // Leaf results were already fed per-node (see above);
1129                            // only surface the batch to the chat UI here.
1130                            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                            // Re-plan after natural completion or exactly once
1145                            // when a cancellation explicitly requested by this
1146                            // supervisor is fulfilled. Unsolicited canceled
1147                            // events stay quiet, preventing the old self-feeding
1148                            // cancel storm across unrelated sibling trees.
1149                            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                            // run_turn still holds forest_tx, so a closed channel
1160                            // means no producers — fall back to planning if idle.
1161                            should_plan = forest.is_empty();
1162                        }
1163                    }
1164                }
1165            }
1166            continue;
1167        }
1168
1169        // ── Planning round ────────────────────────────────────────────────────
1170        should_plan = false;
1171
1172        // Pull any steers that landed while we were busy (e.g. during the
1173        // previous VLM stream) so this round plans with the latest user input.
1174        drain_steers(&mut steer_rx, history, standing_task);
1175
1176        // Roll up old history into a summary once it gets large, so the rest of
1177        // the turn plans against a compact window instead of the full transcript.
1178        compact_history(history, vlm).await;
1179
1180        // Re-discover capabilities from atlas every round so providers that
1181        // registered mid-turn are visible in the next call.
1182        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        // Plan with a single corrective retry (merged from dev #88): if the
1211        // VLM's RTDL fails to parse or expand, feed the error back and let it
1212        // fix the reply once; a second failure ends the turn gracefully (empty
1213        // recovery plan) instead of crashing the whole turn. The loop yields a
1214        // valid (narration, tree label, plan, id) tuple for the forest dispatch.
1215        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 takes priority — checked before every new VLM token.
1255                        _ = 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                            // The response being sampled was built without this
1266                            // input. Drop it before parsing or dispatching any
1267                            // call, then sample again from the updated history.
1268                            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                // Executor state changed while the model was thinking. Never
1319                // dispatch a plan based on the stale in-flight snapshot. Return
1320                // to the event arm, consume the queued state, then re-plan.
1321                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            // Reserve an id atomically only for normal RTDL. Concurrent sessions
1409            // cannot observe or dispatch the same id. A failed expansion may
1410            // leave a harmless gap, but an id is never reused.
1411            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                // Carry `task_update` out so it is applied ONLY after a tree
1423                // expands — never on a recovery path, where it could falsely
1424                // mark the turn done for a plan that never ran.
1425                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        // RTDL recovery gave up after a retry: surface the user-facing message
1460        // once and END the turn. Without this the empty recovery plan would fall
1461        // through to "no new tree this round" and re-plan forever.
1462        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                    // PlanDone is the durable boundary. Replan only after every
1548                    // target in this control batch has left the forest.
1549                    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        // Apply progress only after the harness knows whether this response can
1600        // safely finish. A model cannot mark a task done while it is also
1601        // dispatching work or while an older tree remains in flight.
1602        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        // Retain model narration for later planning. Action-producing RTDL
1625        // rounds are also streamed below so the current user sees and hears
1626        // progress instead of receiving only a final burst after a long task.
1627        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            // With no tree left, this is either a final answer or a deliberate
1641            // request for more user input. End this transport turn exactly once;
1642            // an in-progress standing task remains persisted by the service and
1643            // resumes on the next user message.
1644            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            // A completed interaction may close while unrelated long-running
1662            // work remains. If this interaction is still in progress, keep its
1663            // stream open: surface the model text as progress and wait for the
1664            // relevant plan result before producing FinalText.
1665            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                    // Status is the narration boundary consumed by Liaison:
1674                    // display/TTS the complete progress text now without
1675                    // closing the SubmitTask stream.
1676                    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            // should_plan stays false: wait for a forest event, or for a steer
1693            // when an in-progress task has intentionally produced no new tree.
1694            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        // Non-empty tree: hand the structure to the client and dispatch it to
1707        // the forest after its user-facing narration above.
1708        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        // should_plan stays false: wait for this tree (and any others) to report.
1740    }
1741
1742    // ── 8. Mark turn complete ─────────────────────────────────────────────────
1743    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
1792/// One-line protocol reminder sent on every round after the first. The full
1793/// ~9KB `rtdl_protocol.md` is sent only on round 0 (and the model's own prior
1794/// envelopes stay in history), so later rounds re-anchor cheaply instead of
1795/// re-shipping the whole spec — a large latency/token win on multi-round turns.
1796const 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
1840/// Build the per-round protocol + capability catalog. `full_protocol` ships the
1841/// complete spec (round 0); otherwise a one-line reminder. The capability
1842/// catalog is always included because providers can change mid-turn.
1843fn build_rtdl_prompt(
1844    display_caps: &[DisplayCapability<'_>],
1845    full_protocol: bool,
1846) -> Result<String> {
1847    // Compile-time embedded; edit `rtdl_protocol.md` in this crate root.
1848    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    // debug!("[pilot/rtdl] rtdl prompt:\n{}", p);
1873    Ok(p)
1874}
1875
1876/// One parsed RTDL envelope from the VLM.
1877#[derive(Debug)]
1878struct RtdlEnvelope {
1879    /// User-facing narration.
1880    content: String,
1881    /// Short label for the dispatched tree (sub-task name); may be empty only
1882    /// when `rtdl` is an empty sequence.
1883    rtdl_description: String,
1884    /// The declarative ops tree to dispatch this round.
1885    rtdl: serde_json::Value,
1886    /// Overall-task update. `None` means "keep the current task unchanged"
1887    /// (envelope `task_update: null`).
1888    task_update: Option<TaskState>,
1889}
1890
1891/// Parses one VLM reply in RTDL envelope form.
1892///
1893/// The model must emit a JSON **object** whose only keys are exactly
1894/// `content`, `rtdl_description`, `rtdl`, and `task_update`. See
1895/// `rtdl_protocol.md` for the field contract.
1896///
1897/// Extract the first balanced top-level JSON object from `raw`, ignoring any
1898/// prose before or after it (e.g. a narration line the model emitted before the
1899/// JSON, or a trailing comment). Returns the `{...}` slice, or `None` if there
1900/// is no `{` or no matching close brace.
1901///
1902/// Brace depth is counted only outside JSON string literals, so braces inside
1903/// strings don't affect it. Scanning by bytes is UTF-8-safe here because `{`,
1904/// `}`, `"`, and `\` are all ASCII and never collide with multibyte
1905/// continuation bytes (which are all >= 0x80).
1906fn 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
1938/// Tolerates a prose preamble or trailing commentary around the JSON object
1939/// (a common model habit, e.g. a narration line then the JSON on the next
1940/// line) by extracting the first balanced `{...}` before parsing; the raw
1941/// string is used unchanged when no object is found, so a genuinely
1942/// JSON-less reply still surfaces the original parse error.
1943///
1944/// Fails if `raw` is not valid JSON, the root is not an object, the key set is
1945/// not exactly those four, `content` / `rtdl_description` are not strings,
1946/// `rtdl` is not an object, or `task_update` is neither `null` nor a valid task
1947/// object.
1948fn 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
1987/// Parse a non-null `task_update` object into a [`TaskState`].
1988///
1989/// Requires exactly `goal`, `success_criterion`, and `status` (all strings),
1990/// with `status` constrained to `"in_progress"` or `"done"`.
1991fn 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
2027// ── RTDL recovery (merged from dev #88) ────────────────────────────────────────
2028// When the VLM emits an RTDL that fails to parse or expand, feed the error back
2029// once and let it self-correct; a second failure ends the turn gracefully.
2030
2031/// Corrective prompt appended to the next VLM round after a parse/expand failure.
2032fn 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
2064/// A single empty-sequence root plan, used as the no-op plan when a turn ends in
2065/// RTDL recovery. Carries non-empty `op_id`/`description` so executor's
2066/// `validate_plan` accepts it.
2067fn 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
2083/// User-facing message when RTDL recovery gives up — never leaks the internal error.
2084fn 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
2089/// Process-wide monotonic source of node `op_id`s. The LLM-emitted RTDL does
2090/// not carry a usable op_id (it defaults to 0), so pilot assigns one itself
2091/// while parsing: a globally-unique, auto-incrementing id starting at 1.
2092/// "Global" = across every plan/round in this pilot process, so each node in
2093/// the live task-graph forest is uniquely addressable for steering and result
2094/// correlation — not merely unique within one plan.
2095static OP_ID_SEQ: AtomicU64 = AtomicU64::new(0);
2096
2097/// Allocate the next global op_id (1, 2, 3, …) as a decimal string.
2098fn 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
2130/// Pick a node's `description`, in priority order:
2131/// 1. the LLM's own per-node `description` field when present and non-empty;
2132/// 2. the LLM's tree label (`rtdl_description`) for an otherwise-unlabelled root;
2133/// 3. a synthesized fallback (e.g. `call camera_snapshot`).
2134///
2135/// The model is asked to author a node-level `description` for every node (see
2136/// `rtdl_protocol.md`); the fallbacks keep a sloppy or older reply from failing
2137/// the turn, since executor's `validate_plan` requires a non-empty description.
2138fn 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
2156/// Reject node fields outside the allowed set and require the structural ones.
2157///
2158/// `required` lists the keys an operator must carry beyond `op` (e.g.
2159/// `children` for sequence/parallel; `cap` + `args` for do). `op_id` and
2160/// `description` are always optional — the model emits them (op_id defaults to
2161/// 0, which pilot ignores and reassigns), but a reply that omits them still
2162/// parses. Any other key (`out`, `id`, `plan_id`, …) is an error.
2163fn 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
2364/// Render an RTDL node state as a stable human-readable name for logs.
2365fn 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
2378/// Render an RTDL node kind as the tree operator name used in plan logs.
2379fn 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
2388/// Shorten free-form payloads so one log event stays readable on one line.
2389fn 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
2398/// Recover the LLM-facing capability name from an expanded capability call.
2399fn call_display_name(call: &CapabilityCall) -> String {
2400    format!("{}.{}", call.provider_id, llm_name(&call.contract_id))
2401}
2402
2403/// Append one node and its descendants to the human-readable plan summary.
2404fn 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
2429/// Format a plan as an indented tree instead of exposing arena child arrays.
2430fn 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
2436/// Emit the compact plan-start log block for one expanded RTDL plan.
2437fn 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
2452/// Build compact extra detail for non-success terminal node states.
2453fn 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
2469/// Emit one readable node-state event without numeric state or kind codes.
2470fn 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
2489/// Pick the plan-level completion state shown in the forest completion log.
2490fn 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
2510/// Emit the readable plan completion line, including non-success terminal nodes.
2511fn 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
2580// ── System prompt + SOUL ──────────────────────────────────────────────────────
2581// Optional `SOUL.md` (agent personality) is read from `$ROBONIX_PILOT_SOUL`,
2582// then `~/.robonix/SOUL.md`. There is no skill index — skill providers surface as
2583// regular tools through `executor.list_tools`, with descriptions sourced from
2584// each provider's CAPABILITY.md.
2585
2586fn 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        // Old two-key envelope is now rejected.
3210        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        // Observed real failure: the model narrates a line, then emits the JSON
3247        // on the next line. The leading prose must be stripped, not rejected.
3248        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        // Leading prose dropped; a `}` inside a string value does not end it.
3259        let got = extract_json_object("hi: {\"a\":\"x}y\",\"b\":1} trailing");
3260        assert_eq!(got, Some("{\"a\":\"x}y\",\"b\":1}"));
3261        // No object at all → None, so the caller still hits the real parse error.
3262        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        // Every node carries op_id (always 0 — pilot reassigns) plus a
3481        // model-authored node-level description.
3482        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        // The model's op_id=0 is ignored; pilot assigns non-empty unique ids.
3503        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}