Skip to main content

luft_planner/
lib.rs

1//! # luft-planner
2//!
3//! **Natural-language → Lua orchestration script planner.**
4//!
5//! Instead of classifying the task with keywords and filling fixed templates,
6//! the planner asks an LLM *agent* to generate a Lua orchestration script for
7//! the task. The model acts as a compiler (NL → DSL); the runtime then executes
8//! the script deterministically.
9//!
10//! ## Flow
11//!
12//! ```text
13//!  User NL task
14//!       │
15//!       ▼
16//!  ┌─────────────────┐     ┌──────────────────┐
17//!  │ LUA_DSL_REFERENCE│ ──► │  Planner Agent   │
18//!  │ (system prompt)  │     │  (backend.run)   │
19//!  └─────────────────┘     └────────┬─────────┘
20//!                                    │
21//!                            ┌───────▼────────┐
22//!                            │ validate_script │ ── fail ──► retry (up to max_retries)
23//!                            └───────┬────────┘
24//!                                    │ pass
25//!                            ┌───────▼────────┐
26//!                            │ PlannedWorkflow │
27//!                            │  (script+meta)  │
28//!                            └────────────────┘
29//! ```
30//!
31//! The generated script orchestrates only — it never touches the filesystem or
32//! shell (the Lua sandbox forbids `io`/`os`). All real work — reading files,
33//! grepping, editing, web search — happens inside the `agent()` prompts the
34//! script spawns at runtime.
35
36use luft_core::contract::backend::{AgentBackend, AgentTask, RunContext};
37use luft_core::contract::event::AgentEvent;
38use luft_runtime::{validate_script, validate_workflow};
39use std::path::PathBuf;
40use std::sync::Arc;
41
42pub mod meta;
43pub use meta::{extract_meta, validate_meta as validate_meta_extracted, MetaPhase, MetaValidation, PlanMeta};
44
45/// Planning configuration.
46#[derive(Debug, Clone)]
47pub struct PlannerConfig {
48    /// Model used by the planner agent (`None` = backend default).
49    pub planner_model: Option<String>,
50    /// Max attempts to (re)generate a valid script before giving up.
51    pub max_retries: usize,
52    /// When true, the planner also generates a `.mock.json` companion block.
53    pub generate_mock: bool,
54}
55
56impl Default for PlannerConfig {
57    fn default() -> Self {
58        Self {
59            planner_model: None,
60            max_retries: 3,
61            generate_mock: false,
62        }
63    }
64}
65
66/// A planned workflow: the generated Lua script + its extracted metadata.
67#[derive(Debug, Clone)]
68pub struct PlannedWorkflow {
69    /// The generated Lua script (validated, fence-stripped).
70    pub script: String,
71    /// Mock data for `--with-mock` runs (`None` when mock generation is off).
72    pub mock_data: Option<serde_json::Value>,
73    /// Extracted phase structure (`meta = {...}`), if the script declared one.
74    pub meta: Option<PlanMeta>,
75}
76
77/// Planner errors.
78#[derive(thiserror::Error, Debug)]
79pub enum PlannerError {
80    /// The backend agent call itself failed.
81    #[error("planner backend error: {0}")]
82    Backend(String),
83    /// Ran out of attempts without producing a valid script.
84    #[error("planner exhausted {attempts} attempt(s); last error: {last_error}")]
85    ExhaustedRetries { attempts: usize, last_error: String },
86}
87
88/// Generate a Lua orchestration script for `task` by asking the backend agent.
89///
90/// Retries up to `cfg.max_retries` times, feeding the validation error back to
91/// the agent so it can self-correct.
92pub async fn plan_workflow(
93    task: &str,
94    backend: Arc<dyn AgentBackend>,
95    cfg: &PlannerConfig,
96) -> Result<PlannedWorkflow, PlannerError> {
97    let attempts = cfg.max_retries.max(1);
98    let mut last_error = String::new();
99
100    for attempt in 0..attempts {
101        if attempt > 0 {
102            tracing::warn!(
103                attempt,
104                total = attempts,
105                "retrying script generation after validation failure"
106            );
107        }
108
109        let prompt = build_prompt(
110            task,
111            (attempt > 0).then_some(last_error.as_str()),
112            cfg.generate_mock,
113        );
114
115        let output = run_planner_agent(&*backend, &prompt, cfg.planner_model.clone())
116            .await
117            .map_err(PlannerError::Backend)?;
118
119        let script = match extract_script(&output) {
120            Some(s) => s,
121            None => {
122                tracing::warn!(attempt, "agent output contained no lua code block");
123                last_error = "no ```lua code block (or text) found in agent output".to_string();
124                continue;
125            }
126        };
127
128        match validate_generated(&script) {
129            Ok(()) => {
130                let mock_data = if cfg.generate_mock {
131                    let mock = extract_mock_block(&output);
132                    if mock.is_none() {
133                        tracing::warn!(attempt, "--with-mock requested but no mock block found");
134                    }
135                    mock
136                } else {
137                    None
138                };
139                let meta = match meta::extract_meta(&script) {
140                    Ok(Some(m)) => {
141                        let v = meta::validate_meta(&m, &script);
142                        for w in &v.warnings { tracing::warn!(warning = %w, "planner meta validation warning"); }
143                        if !v.is_valid() {
144                            last_error = format!("meta validation failed: {}", v.errors.join("; "));
145                            continue;
146                        }
147                        Some(m)
148                    }
149                    Ok(None) => None,
150                    Err(e) => { tracing::warn!(error = %e, "meta extraction failed; ignoring"); None }
151                };
152                return Ok(PlannedWorkflow { script, mock_data, meta });
153            }
154            Err(e) => {
155                tracing::warn!(attempt, error = %e, "generated script failed validation");
156                last_error = e;
157            }
158        }
159    }
160
161    Err(PlannerError::ExhaustedRetries {
162        attempts,
163        last_error,
164    })
165}
166
167/// Run a single planning agent through the backend, returning its raw output.
168async fn run_planner_agent(
169    backend: &dyn AgentBackend,
170    prompt: &str,
171    model: Option<String>,
172) -> Result<serde_json::Value, String> {
173    // The planner needs a one-shot RunContext; events are discarded.
174    let (events, _rx) = tokio::sync::broadcast::channel::<AgentEvent>(16);
175    let ctx = RunContext {
176        run_id: uuid::Uuid::now_v7(),
177        cancel: tokio_util::sync::CancellationToken::new(),
178        events,
179    };
180    let task = AgentTask {
181        agent_id: uuid::Uuid::now_v7(),
182        phase_id: 0,
183        prompt: prompt.to_string(),
184        model,
185        description: None,
186        role: None,
187        name: None,
188        agent_seq: 0,
189        allowlist: None,
190        workdir: PathBuf::from("."),
191        mcp_endpoint: None,
192        timeout: None,
193        output_schema: None,
194    };
195    backend
196        .run(task, ctx)
197        .await
198        .map(|r| r.output)
199        .map_err(|e| e.to_string())
200}
201
202/// Validate a generated script: syntax + structure + heuristic checks.
203fn validate_generated(script: &str) -> Result<(), String> {
204    let result = validate_workflow(script).map_err(|e| format!("lua validation error: {}", e))?;
205    if result.errors.is_empty() {
206        Ok(())
207    } else {
208        Err(result.errors.join("; "))
209    }
210}
211
212/// Extract the ```json block from planner output (for mock data).
213fn extract_mock_block(output: &serde_json::Value) -> Option<serde_json::Value> {
214    let text = output_to_text(output)?;
215    let json_str = find_fenced_block_by_lang(&text, "json")?;
216    let trimmed = json_str.trim();
217    if trimmed.is_empty() {
218        return None;
219    }
220    serde_json::from_str(trimmed).ok()
221}
222
223/// Find a fenced code block by exact language tag.
224fn find_fenced_block_by_lang(text: &str, lang: &str) -> Option<String> {
225    let marker = format!("```{}", lang);
226    let mut from = 0;
227    while let Some(rel) = text[from..].find(&marker) {
228        let fence = from + rel;
229        let after = &text[fence + marker.len()..];
230        let line_end = match after.find('\n') {
231            Some(n) => n,
232            None => {
233                from = fence + marker.len();
234                continue;
235            }
236        };
237        let body_start = fence + marker.len() + line_end + 1;
238        let rest = &text[body_start..];
239        let close = match rest.find("```") {
240            Some(c) => c,
241            None => {
242                from = body_start;
243                continue;
244            }
245        };
246        return Some(rest[..close].trim_end().to_string());
247    }
248    None
249}
250
251/// Coerce the agent output into text and pull out the Lua script.
252fn extract_script(output: &serde_json::Value) -> Option<String> {
253    let text = output_to_text(output)?;
254    let block = extract_lua_block(&text);
255    if block.trim().is_empty() {
256        None
257    } else {
258        Some(block)
259    }
260}
261
262/// Best-effort extraction of assistant text from a backend's structured output.
263///
264/// The exact shape depends on the backend (real ACP backends land in P0-A); this
265/// handles the common cases and falls back to a JSON stringification.
266fn output_to_text(output: &serde_json::Value) -> Option<String> {
267    match output {
268        serde_json::Value::String(s) => Some(s.clone()),
269        serde_json::Value::Object(map) => {
270            for key in ["script", "message", "text", "content", "output"] {
271                if let Some(serde_json::Value::String(s)) = map.get(key) {
272                    return Some(s.clone());
273                }
274            }
275            Some(output.to_string())
276        }
277        serde_json::Value::Null => None,
278        other => Some(other.to_string()),
279    }
280}
281
282/// Return the first ```lua (or unlabelled) fenced block; otherwise try to
283/// strip surrounding prose from the trimmed input.
284fn extract_lua_block(text: &str) -> String {
285    if let Some(block) = find_fenced_block(text) {
286        return block;
287    }
288    let trimmed = text.trim();
289    if validate_script(trimmed).is_ok() {
290        return trimmed.to_string();
291    }
292    strip_prose(trimmed)
293}
294
295/// Heuristically strip leading/trailing prose lines that aren't valid Lua.
296/// Keeps lines from the first Lua-looking line to the last.
297fn strip_prose(text: &str) -> String {
298    let lines: Vec<&str> = text.lines().collect();
299    let lua_start = lines.iter().position(|l| looks_like_lua(l)).unwrap_or(0);
300    let lua_end = lines
301        .iter()
302        .rposition(|l| looks_like_lua(l))
303        .map(|i| i + 1)
304        .unwrap_or(lines.len());
305    lines[lua_start.min(lua_end.max(1))..lua_end.max(lines.len().min(lua_start + 1))]
306        .join("\n")
307        .trim()
308        .to_string()
309}
310
311/// Check if a line looks like it could be Lua code (not prose).
312fn looks_like_lua(line: &str) -> bool {
313    let t = line.trim();
314    if t.is_empty() {
315        return false;
316    }
317    if t.starts_with("--") {
318        return true;
319    }
320    const KEYWORDS: &[&str] = &[
321        "local", "phase", "report", "agent", "parallel", "pipeline", "for", "if", "while",
322        "function", "return", "log", "budget", "workflow", "json", "do", "end", "else", "elseif",
323        "then", "and", "or", "not", "true", "false", "nil", "args", "ctx",
324    ];
325    let first_word = t
326        .split(|c: char| !c.is_alphanumeric() && c != '_')
327        .next()
328        .unwrap_or("");
329    KEYWORDS.contains(&first_word)
330        || t.starts_with('{')
331        || t.starts_with('}')
332        || t.starts_with('(')
333        || t.starts_with(')')
334        || t.starts_with('"')
335        || t.starts_with('\'')
336        || t.starts_with('[')
337        || t.starts_with(']')
338        || t.starts_with('=')
339        || t.starts_with('.')
340        || t.starts_with(':')
341        || t.starts_with('#')
342}
343
344fn find_fenced_block(text: &str) -> Option<String> {
345    let mut from = 0;
346    while let Some(rel) = text[from..].find("```") {
347        let fence = from + rel;
348        let after = &text[fence + 3..];
349        let line_end = match after.find('\n') {
350            Some(n) => n,
351            None => {
352                from = fence + 3;
353                continue;
354            }
355        };
356        let lang = after[..line_end].trim();
357        let body_start = fence + 3 + line_end + 1;
358        let rest = &text[body_start..];
359        let close = match rest.find("```") {
360            Some(c) => c,
361            None => {
362                from = body_start;
363                continue;
364            }
365        };
366        if lang.eq_ignore_ascii_case("lua") || lang.is_empty() {
367            return Some(rest[..close].trim_end().to_string());
368        }
369        from = body_start + close + 3;
370    }
371    None
372}
373
374/// Build the planner prompt: DSL reference + task (+ optional fix-up error).
375fn build_prompt(task: &str, fix_error: Option<&str>, generate_mock: bool) -> String {
376    let mut p = String::with_capacity(LUA_DSL_REFERENCE.len() + task.len() + 1024);
377    p.push_str(LUA_DSL_REFERENCE);
378    p.push_str("\n\n# Task\n\n");
379    p.push_str(task);
380    p.push('\n');
381    if let Some(err) = fix_error {
382        p.push_str("\n# Your previous attempt was rejected\n\n");
383        p.push_str(err);
384        p.push_str("\n\nFix the script and output a corrected version.\n");
385    }
386    if generate_mock {
387        p.push_str(MOCK_GENERATION_INSTRUCTIONS);
388    }
389    p.push_str("\nOutput ONLY one ```lua code block");
390    if generate_mock {
391        p.push_str(" followed by one ```json code block");
392    }
393    p.push_str(" — no prose before or after.\n");
394    p
395}
396
397const MOCK_GENERATION_INSTRUCTIONS: &str = r#"
398
399# Mock Data Generation
400
401In ADDITION to the ```lua block, output a second ```json block with mock
402responses for EVERY named agent call. Format:
403
404```json
405{
406  "responses": {
407    "<agent_name>": {
408      "output": <representative JSON matching the agent's expected output>,
409      "tokens": {"input": 100, "output": 50},
410      "status": "ok"
411    }
412  },
413  "default": {
414    "output": {"text": "mock response"},
415    "tokens": {"input": 0, "output": 0}
416  }
417}
418```
419
420Rules:
4211. EVERY agent() call in the Lua script MUST include a unique `name=` field.
4222. For parallel() fan-out, all items share ONE name — the mock response is reused.
4233. Mock output MUST match the agent's schema structure if a schema is defined.
4244. Keep mock output concise but structurally valid.
4255. Output BOTH blocks: ```lua first, then ```json.
426"#;
427
428/// The orchestration DSL spec handed to the planner agent.
429///
430/// ## Role
431///
432/// This constant is the **system prompt** for the planner LLM. When a user runs
433/// `luft run --nl "<task>"`, the planner sends this text + the user's task
434/// description to the LLM, and expects a single ```lua code block back. The
435/// returned script is then validated (syntax + `report()` presence + span pairing)
436/// and executed by the sandboxed [`crate::runtime`].
437///
438/// ## Architecture: NL → Lua compilation
439///
440/// ```text
441///   User NL task ──► planner LLM ──► Lua script ──► sandbox execute
442///        │              ▲                              │
443///        │              │                              ▼
444///        └──── LUA_DSL_REFERENCE (this const)     agent() calls ──► scheduler
445///                                                     │
446///                                                     ▼
447///                                                   report()
448/// ```
449///
450/// The LLM acts as a "compiler" from natural language to the Luft Lua DSL.
451/// The script is pure orchestration — no I/O, no filesystem, no shell. All real
452/// work (file reads, grep, edit, web search) happens inside the `agent()` prompts.
453///
454/// ## Keeping in sync
455///
456/// Every primitive documented here MUST be registered in [`luft_runtime::sandbox`]
457/// via `register_sdk()`. If you add a new Lua global, document it here and register
458/// it there. If you remove one, remove it from both places.
459///
460/// ## Token cost
461///
462/// This reference is sent on every planner call (~4K tokens). Keep examples
463/// concise but illustrative. Rules are authoritative — primitives are reference.
464const LUA_DSL_REFERENCE: &str = include_str!("lua_dsl_reference.md");
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469    use luft_core::{FailKind, MockBackend, MockBehavior, TokenUsage};
470    use std::time::Duration;
471
472    fn mock_returning(output: serde_json::Value) -> Arc<dyn AgentBackend> {
473        Arc::new(MockBackend::new(
474            "mock",
475            vec![MockBehavior::Success {
476                output,
477                tokens: TokenUsage::default(),
478                delay: Duration::ZERO,
479            }],
480        ))
481    }
482
483    #[tokio::test]
484    async fn test_plan_extracts_and_validates_script() {
485        let script = "```lua\nmeta = { reasoning = \"test\", phases = {{ label = \"work\" }} }\nfunction main()\n  local r = agent({prompt='hi'})\n  report({ok=true})\nend\n```";
486        let backend = mock_returning(serde_json::json!(script));
487        let planned = plan_workflow("do something", backend, &PlannerConfig::default())
488            .await
489            .unwrap();
490        assert!(planned.script.contains("report("));
491        assert!(!planned.script.contains("```"));
492        assert!(validate_script(&planned.script).is_ok());
493    }
494
495    #[tokio::test]
496    async fn test_plan_retries_on_invalid_then_fails() {
497        // Unbalanced parenthesis → syntax error, repeated for every retry.
498        let bad = "```lua\nlocal x = (\nreport({})\n```";
499        let backend = mock_returning(serde_json::json!(bad));
500        let cfg = PlannerConfig {
501            planner_model: None,
502            max_retries: 2,
503            ..Default::default()
504        };
505        match plan_workflow("x", backend, &cfg).await.unwrap_err() {
506            PlannerError::ExhaustedRetries { attempts, .. } => assert_eq!(attempts, 2),
507            other => panic!("expected ExhaustedRetries, got {:?}", other),
508        }
509    }
510
511    #[tokio::test]
512    async fn test_plan_rejects_missing_report() {
513        // Valid Lua but no report() call → rejected, retries exhausted.
514        let no_report = "```lua\nlocal r = agent({prompt='hi'})\n```";
515        let backend = mock_returning(serde_json::json!(no_report));
516        let cfg = PlannerConfig {
517            planner_model: None,
518            max_retries: 1,
519            ..Default::default()
520        };
521        assert!(matches!(
522            plan_workflow("x", backend, &cfg).await,
523            Err(PlannerError::ExhaustedRetries { .. })
524        ));
525    }
526
527    #[test]
528    fn test_extract_script_fenced_bare_and_object() {
529        // Fenced lua block with surrounding prose.
530        let v = serde_json::json!("prefix\n```lua\nreport({})\n```\nsuffix");
531        assert_eq!(extract_script(&v).unwrap().trim(), "report({})");
532
533        // Bare text (no fence) → trimmed.
534        let v = serde_json::json!("  report({})  ");
535        assert_eq!(extract_script(&v).unwrap(), "report({})");
536
537        // Object with a `message` field.
538        let v = serde_json::json!({ "message": "```lua\nreport({})\n```" });
539        assert_eq!(extract_script(&v).unwrap().trim(), "report({})");
540
541        // Null → None.
542        assert!(extract_script(&serde_json::Value::Null).is_none());
543    }
544
545    #[test]
546    fn test_extract_skips_non_lua_block() {
547        let v = serde_json::json!("```text\nnot code\n```\n```lua\nreport({})\n```");
548        assert_eq!(extract_script(&v).unwrap().trim(), "report({})");
549    }
550
551    // ── Additional coverage: backend error path ──────────────────────────
552
553    #[tokio::test]
554    async fn test_plan_backend_error() {
555        let backend: Arc<dyn AgentBackend> = Arc::new(MockBackend::new(
556            "mock",
557            vec![MockBehavior::fail(FailKind::Protocol)],
558        ));
559        let err = plan_workflow("x", backend, &PlannerConfig::default())
560            .await
561            .unwrap_err();
562        assert!(matches!(err, PlannerError::Backend(_)));
563    }
564
565    // ── Coverage: agent output with no lua block (extract_script → None) ─
566
567    #[tokio::test]
568    async fn test_plan_no_lua_block_retries() {
569        let backend = mock_returning(serde_json::Value::Null);
570        let cfg = PlannerConfig {
571            max_retries: 2,
572            ..Default::default()
573        };
574        let err = plan_workflow("x", backend, &cfg).await.unwrap_err();
575        assert!(matches!(err, PlannerError::ExhaustedRetries { .. }));
576    }
577
578    // ── Coverage: validation failure followed by successful retry ────────
579
580    #[tokio::test]
581    async fn test_plan_retry_then_succeeds() {
582        let valid = "```lua\nmeta = { reasoning = \"test\", phases = {{ label = \"work\" }} }\nfunction main()\n  local r = agent({prompt='hi'})\n  report({ok=true})\nend\n```";
583        let backend: Arc<dyn AgentBackend> = Arc::new(MockBackend::new(
584            "mock",
585            vec![
586                MockBehavior::Success {
587                    output: serde_json::json!("this is pure garbage"),
588                    tokens: TokenUsage::default(),
589                    delay: Duration::ZERO,
590                },
591                MockBehavior::Success {
592                    output: serde_json::json!(valid),
593                    tokens: TokenUsage::default(),
594                    delay: Duration::ZERO,
595                },
596            ],
597        ));
598        let planned = plan_workflow("x", backend, &PlannerConfig::default())
599            .await
600            .unwrap();
601        assert!(planned.script.contains("report({ok=true})"));
602    }
603
604    // ── Coverage: strip_prose edge cases (lines 191, 195, 201) ──────────
605
606    #[test]
607    fn test_strip_prose_edge_cases() {
608        // No lines look like Lua → lua_start = 0, lua_end = lines.len()
609        let s = strip_prose("hello world\nsome prose");
610        assert_eq!(s, "hello world\nsome prose");
611
612        // Lua in middle, prose before/after → slices correctly
613        let s = strip_prose("intro\nlocal x = 1\noutro");
614        assert_eq!(s, "local x = 1");
615
616        // Only Lua lines
617        let s = strip_prose("local x = 1\nreport({})");
618        assert_eq!(s, "local x = 1\nreport({})");
619
620        // Lines with leading/trailing whitespace → trim exercised
621        let s = strip_prose("  local x = 1  ");
622        assert_eq!(s, "local x = 1");
623    }
624
625    // ── Coverage: looks_like_lua branches ────────────────────────────────
626
627    #[test]
628    fn test_looks_like_lua_variants() {
629        assert!(!looks_like_lua(""));
630        assert!(!looks_like_lua("   "));
631        assert!(looks_like_lua("-- comment"));
632        assert!(looks_like_lua("local x = 1"));
633        assert!(looks_like_lua("{hello}"));
634        assert!(looks_like_lua("}"));
635        assert!(looks_like_lua("(arg)"));
636        assert!(looks_like_lua(")"));
637        assert!(looks_like_lua("\"string\""));
638        assert!(looks_like_lua("'string'"));
639        assert!(looks_like_lua("[1, 2]"));
640        assert!(looks_like_lua("]"));
641        assert!(looks_like_lua("= value"));
642        assert!(looks_like_lua(".method"));
643        assert!(looks_like_lua(":method"));
644        assert!(looks_like_lua("#list"));
645        assert!(!looks_like_lua("plain prose text"));
646        assert!(!looks_like_lua("1234 number line"));
647    }
648
649    // ── Coverage: find_fenced_block edge cases ──────────────────────────
650
651    #[test]
652    fn test_find_fenced_block_edge_cases() {
653        // Non-lua fence skipped, lua fence found
654        let s = find_fenced_block("```text\nstuff\n```\n```lua\nreport({})\n```");
655        assert_eq!(s.unwrap().trim(), "report({})");
656
657        // Missing closing fence → None
658        assert!(find_fenced_block("```lua\nreport({})").is_none());
659
660        // No fence at all
661        assert!(find_fenced_block("just text").is_none());
662    }
663
664    // ── Coverage: output_to_text remaining match arms ───────────────────
665
666    #[test]
667    fn test_output_to_text_variants() {
668        // Object with no recognized key → fallback to_string
669        let obj = serde_json::json!({"unknown": "value"});
670        assert!(output_to_text(&obj).is_some());
671
672        // Array → `other` arm
673        assert_eq!(
674            output_to_text(&serde_json::json!([1, 2, 3])).unwrap(),
675            "[1,2,3]"
676        );
677
678        // Number → `other` arm
679        assert_eq!(output_to_text(&serde_json::json!(42)).unwrap(), "42");
680    }
681
682    // ── Coverage: build_prompt fix_error branch ─────────────────────────
683
684    #[test]
685    fn test_build_prompt_with_fix_error() {
686        let p = build_prompt("do task", Some("script had syntax error"), false);
687        assert!(p.contains("do task"));
688        assert!(p.contains("previous attempt was rejected"));
689        assert!(p.contains("script had syntax error"));
690        assert!(p.contains("Output ONLY"));
691    }
692
693    #[test]
694    fn test_build_prompt_without_fix_error() {
695        let p = build_prompt("do task", None, false);
696        assert!(p.contains("do task"));
697        assert!(!p.contains("previous attempt was rejected"));
698        assert!(p.contains("Output ONLY"));
699    }
700
701    // ── Span pairing validation ─────────────────────────────────────────
702
703    #[test]
704    fn test_validate_span_unpaired() {
705        let script = "meta = { reasoning = \"test\", phases = {} }\nfunction main()\nlocal m = phase_begin(\"x\")\nreport({})\nend";
706        assert!(validate_generated(script).is_err());
707    }
708
709    #[test]
710    fn test_validate_span_paired() {
711        let script = "meta = { reasoning = \"test\", phases = {} }\nfunction main()\nlocal m = phase_begin(\"x\")\nphase_end(m)\nreport({})\nend";
712        assert!(validate_generated(script).is_ok());
713    }
714
715    #[test]
716    fn test_validate_no_span_ok() {
717        let script =
718            "meta = { reasoning = \"test\", phases = {} }\nfunction main() report({ok=true}) end";
719        assert!(validate_generated(script).is_ok());
720    }
721
722    #[test]
723    fn test_build_prompt_contains_decomposition_section() {
724        let p = build_prompt("refactor everything", None, false);
725        assert!(p.contains("Task Decomposition"));
726        assert!(p.contains("meta.phases") || p.contains("phases"));
727    }
728}