1use 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#[derive(Debug, Clone)]
47pub struct PlannerConfig {
48 pub planner_model: Option<String>,
50 pub max_retries: usize,
52 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#[derive(Debug, Clone)]
68pub struct PlannedWorkflow {
69 pub script: String,
71 pub mock_data: Option<serde_json::Value>,
73 pub meta: Option<PlanMeta>,
75}
76
77#[derive(thiserror::Error, Debug)]
79pub enum PlannerError {
80 #[error("planner backend error: {0}")]
82 Backend(String),
83 #[error("planner exhausted {attempts} attempt(s); last error: {last_error}")]
85 ExhaustedRetries { attempts: usize, last_error: String },
86}
87
88pub 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
167async fn run_planner_agent(
169 backend: &dyn AgentBackend,
170 prompt: &str,
171 model: Option<String>,
172) -> Result<serde_json::Value, String> {
173 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
202fn 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
212fn 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
223fn 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
251fn 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
262fn 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
282fn 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
295fn 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
311fn 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
374fn 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
428const 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 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 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 let v = serde_json::json!("prefix\n```lua\nreport({})\n```\nsuffix");
531 assert_eq!(extract_script(&v).unwrap().trim(), "report({})");
532
533 let v = serde_json::json!(" report({}) ");
535 assert_eq!(extract_script(&v).unwrap(), "report({})");
536
537 let v = serde_json::json!({ "message": "```lua\nreport({})\n```" });
539 assert_eq!(extract_script(&v).unwrap().trim(), "report({})");
540
541 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 #[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 #[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 #[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 #[test]
607 fn test_strip_prose_edge_cases() {
608 let s = strip_prose("hello world\nsome prose");
610 assert_eq!(s, "hello world\nsome prose");
611
612 let s = strip_prose("intro\nlocal x = 1\noutro");
614 assert_eq!(s, "local x = 1");
615
616 let s = strip_prose("local x = 1\nreport({})");
618 assert_eq!(s, "local x = 1\nreport({})");
619
620 let s = strip_prose(" local x = 1 ");
622 assert_eq!(s, "local x = 1");
623 }
624
625 #[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 #[test]
652 fn test_find_fenced_block_edge_cases() {
653 let s = find_fenced_block("```text\nstuff\n```\n```lua\nreport({})\n```");
655 assert_eq!(s.unwrap().trim(), "report({})");
656
657 assert!(find_fenced_block("```lua\nreport({})").is_none());
659
660 assert!(find_fenced_block("just text").is_none());
662 }
663
664 #[test]
667 fn test_output_to_text_variants() {
668 let obj = serde_json::json!({"unknown": "value"});
670 assert!(output_to_text(&obj).is_some());
671
672 assert_eq!(
674 output_to_text(&serde_json::json!([1, 2, 3])).unwrap(),
675 "[1,2,3]"
676 );
677
678 assert_eq!(output_to_text(&serde_json::json!(42)).unwrap(), "42");
680 }
681
682 #[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 #[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}