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