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::{
44 extract_meta, validate_meta as validate_meta_extracted, MetaPhase, MetaValidation, PlanMeta,
45};
46
47#[derive(Debug, Clone)]
49pub struct PlannerConfig {
50 pub planner_model: Option<String>,
52 pub max_retries: usize,
54 pub generate_mock: bool,
56}
57
58impl Default for PlannerConfig {
59 fn default() -> Self {
60 Self {
61 planner_model: None,
62 max_retries: 3,
63 generate_mock: false,
64 }
65 }
66}
67
68#[derive(Debug, Clone)]
70pub struct PlannedWorkflow {
71 pub script: String,
73 pub mock_data: Option<serde_json::Value>,
75 pub meta: Option<PlanMeta>,
77}
78
79#[derive(thiserror::Error, Debug)]
81pub enum PlannerError {
82 #[error("planner backend error: {0}")]
84 Backend(String),
85 #[error("planner exhausted {attempts} attempt(s); last error: {last_error}")]
87 ExhaustedRetries { attempts: usize, last_error: String },
88}
89
90pub async fn plan_workflow(
95 task: &str,
96 backend: Arc<dyn AgentBackend>,
97 cfg: &PlannerConfig,
98) -> Result<PlannedWorkflow, PlannerError> {
99 let attempts = cfg.max_retries.max(1);
100 let mut last_error = String::new();
101
102 for attempt in 0..attempts {
103 if attempt > 0 {
104 tracing::warn!(
105 attempt,
106 total = attempts,
107 "retrying script generation after validation failure"
108 );
109 }
110
111 let prompt = build_prompt(
112 task,
113 (attempt > 0).then_some(last_error.as_str()),
114 cfg.generate_mock,
115 );
116
117 let output = run_planner_agent(&*backend, &prompt, cfg.planner_model.clone())
118 .await
119 .map_err(PlannerError::Backend)?;
120
121 let script = match extract_script(&output) {
122 Some(s) => s,
123 None => {
124 tracing::warn!(attempt, "agent output contained no lua code block");
125 last_error = "no ```lua code block (or text) found in agent output".to_string();
126 continue;
127 }
128 };
129
130 match validate_generated(&script) {
131 Ok(()) => {
132 let mock_data = if cfg.generate_mock {
133 let mock = extract_mock_block(&output);
134 if mock.is_none() {
135 tracing::warn!(attempt, "--with-mock requested but no mock block found");
136 }
137 mock
138 } else {
139 None
140 };
141 let meta = match meta::extract_meta(&script) {
142 Ok(Some(m)) => {
143 let v = meta::validate_meta(&m, &script);
144 for w in &v.warnings {
145 tracing::warn!(warning = %w, "planner meta validation warning");
146 }
147 if !v.is_valid() {
148 last_error = format!("meta validation failed: {}", v.errors.join("; "));
149 continue;
150 }
151 Some(m)
152 }
153 Ok(None) => None,
154 Err(e) => {
155 tracing::warn!(error = %e, "meta extraction failed; ignoring");
156 None
157 }
158 };
159 return Ok(PlannedWorkflow {
160 script,
161 mock_data,
162 meta,
163 });
164 }
165 Err(e) => {
166 tracing::warn!(attempt, error = %e, "generated script failed validation");
167 last_error = e;
168 }
169 }
170 }
171
172 Err(PlannerError::ExhaustedRetries {
173 attempts,
174 last_error,
175 })
176}
177
178async fn run_planner_agent(
180 backend: &dyn AgentBackend,
181 prompt: &str,
182 model: Option<String>,
183) -> Result<serde_json::Value, String> {
184 let (events, _rx) = tokio::sync::broadcast::channel::<AgentEvent>(16);
186 let ctx = RunContext {
187 run_id: uuid::Uuid::now_v7(),
188 cancel: tokio_util::sync::CancellationToken::new(),
189 events,
190 };
191 let task = AgentTask {
192 agent_id: uuid::Uuid::now_v7(),
193 phase_id: 0,
194 prompt: prompt.to_string(),
195 model,
196 description: None,
197 role: None,
198 name: None,
199 agent_seq: 0,
200 allowlist: None,
201 workdir: PathBuf::from("."),
202 mcp_endpoint: None,
203 timeout: None,
204 output_schema: None,
205 workdir_override: None,
206 };
207 backend
208 .run(task, ctx)
209 .await
210 .map(|r| r.output)
211 .map_err(|e| e.to_string())
212}
213
214fn validate_generated(script: &str) -> Result<(), String> {
216 let result = validate_workflow(script).map_err(|e| format!("lua validation error: {}", e))?;
217 if result.errors.is_empty() {
218 Ok(())
219 } else {
220 Err(result.errors.join("; "))
221 }
222}
223
224fn extract_mock_block(output: &serde_json::Value) -> Option<serde_json::Value> {
226 let text = output_to_text(output)?;
227 let json_str = find_fenced_block_by_lang(&text, "json")?;
228 let trimmed = json_str.trim();
229 if trimmed.is_empty() {
230 return None;
231 }
232 serde_json::from_str(trimmed).ok()
233}
234
235fn find_fenced_block_by_lang(text: &str, lang: &str) -> Option<String> {
237 let marker = format!("```{}", lang);
238 let mut from = 0;
239 while let Some(rel) = text[from..].find(&marker) {
240 let fence = from + rel;
241 let after = &text[fence + marker.len()..];
242 let line_end = match after.find('\n') {
243 Some(n) => n,
244 None => {
245 from = fence + marker.len();
246 continue;
247 }
248 };
249 let body_start = fence + marker.len() + line_end + 1;
250 let rest = &text[body_start..];
251 let close = match rest.find("```") {
252 Some(c) => c,
253 None => {
254 from = body_start;
255 continue;
256 }
257 };
258 return Some(rest[..close].trim_end().to_string());
259 }
260 None
261}
262
263fn extract_script(output: &serde_json::Value) -> Option<String> {
265 let text = output_to_text(output)?;
266 let block = extract_lua_block(&text);
267 if block.trim().is_empty() {
268 None
269 } else {
270 Some(block)
271 }
272}
273
274fn output_to_text(output: &serde_json::Value) -> Option<String> {
279 match output {
280 serde_json::Value::String(s) => Some(s.clone()),
281 serde_json::Value::Object(map) => {
282 for key in ["script", "message", "text", "content", "output"] {
283 if let Some(serde_json::Value::String(s)) = map.get(key) {
284 return Some(s.clone());
285 }
286 }
287 Some(output.to_string())
288 }
289 serde_json::Value::Null => None,
290 other => Some(other.to_string()),
291 }
292}
293
294fn extract_lua_block(text: &str) -> String {
297 if let Some(block) = find_fenced_block(text) {
298 return block;
299 }
300 let trimmed = text.trim();
301 if validate_script(trimmed).is_ok() {
302 return trimmed.to_string();
303 }
304 strip_prose(trimmed)
305}
306
307fn strip_prose(text: &str) -> String {
310 let lines: Vec<&str> = text.lines().collect();
311 let lua_start = lines.iter().position(|l| looks_like_lua(l)).unwrap_or(0);
312 let lua_end = lines
313 .iter()
314 .rposition(|l| looks_like_lua(l))
315 .map(|i| i + 1)
316 .unwrap_or(lines.len());
317 lines[lua_start.min(lua_end.max(1))..lua_end.max(lines.len().min(lua_start + 1))]
318 .join("\n")
319 .trim()
320 .to_string()
321}
322
323fn looks_like_lua(line: &str) -> bool {
325 let t = line.trim();
326 if t.is_empty() {
327 return false;
328 }
329 if t.starts_with("--") {
330 return true;
331 }
332 const KEYWORDS: &[&str] = &[
333 "local", "phase", "report", "agent", "parallel", "pipeline", "for", "if", "while",
334 "function", "return", "log", "budget", "workflow", "json", "do", "end", "else", "elseif",
335 "then", "and", "or", "not", "true", "false", "nil", "args", "ctx",
336 ];
337 let first_word = t
338 .split(|c: char| !c.is_alphanumeric() && c != '_')
339 .next()
340 .unwrap_or("");
341 KEYWORDS.contains(&first_word)
342 || t.starts_with('{')
343 || t.starts_with('}')
344 || t.starts_with('(')
345 || t.starts_with(')')
346 || t.starts_with('"')
347 || t.starts_with('\'')
348 || t.starts_with('[')
349 || t.starts_with(']')
350 || t.starts_with('=')
351 || t.starts_with('.')
352 || t.starts_with(':')
353 || t.starts_with('#')
354}
355
356fn find_fenced_block(text: &str) -> Option<String> {
357 let mut from = 0;
358 while let Some(rel) = text[from..].find("```") {
359 let fence = from + rel;
360 let after = &text[fence + 3..];
361 let line_end = match after.find('\n') {
362 Some(n) => n,
363 None => {
364 from = fence + 3;
365 continue;
366 }
367 };
368 let lang = after[..line_end].trim();
369 let body_start = fence + 3 + line_end + 1;
370 let rest = &text[body_start..];
371 let close = match rest.find("```") {
372 Some(c) => c,
373 None => {
374 from = body_start;
375 continue;
376 }
377 };
378 if lang.eq_ignore_ascii_case("lua") || lang.is_empty() {
379 return Some(rest[..close].trim_end().to_string());
380 }
381 from = body_start + close + 3;
382 }
383 None
384}
385
386fn build_prompt(task: &str, fix_error: Option<&str>, generate_mock: bool) -> String {
388 let mut p = String::with_capacity(LUA_DSL_REFERENCE.len() + task.len() + 1024);
389 p.push_str(LUA_DSL_REFERENCE);
390 p.push_str("\n\n# Task\n\n");
391 p.push_str(task);
392 p.push('\n');
393 if let Some(err) = fix_error {
394 p.push_str("\n# Your previous attempt was rejected\n\n");
395 p.push_str(err);
396 p.push_str("\n\nFix the script and output a corrected version.\n");
397 }
398 if generate_mock {
399 p.push_str(MOCK_GENERATION_INSTRUCTIONS);
400 }
401 p.push_str("\nOutput ONLY one ```lua code block");
402 if generate_mock {
403 p.push_str(" followed by one ```json code block");
404 }
405 p.push_str(" — no prose before or after.\n");
406 p
407}
408
409const MOCK_GENERATION_INSTRUCTIONS: &str = r#"
410
411# Mock Data Generation
412
413In ADDITION to the ```lua block, output a second ```json block with mock
414responses for EVERY named agent call. Format:
415
416```json
417{
418 "responses": {
419 "<agent_name>": {
420 "output": <representative JSON matching the agent's expected output>,
421 "tokens": {"input": 100, "output": 50},
422 "status": "ok"
423 }
424 },
425 "default": {
426 "output": {"text": "mock response"},
427 "tokens": {"input": 0, "output": 0}
428 }
429}
430```
431
432Rules:
4331. EVERY agent() call in the Lua script MUST include a unique `name=` field.
4342. For parallel() fan-out, all items share ONE name — the mock response is reused.
4353. Mock output MUST match the agent's schema structure if a schema is defined.
4364. Keep mock output concise but structurally valid.
4375. Output BOTH blocks: ```lua first, then ```json.
438"#;
439
440const LUA_DSL_REFERENCE: &str = include_str!("lua_dsl_reference.md");
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481 use luft_core::{FailKind, MockBackend, MockBehavior, TokenUsage};
482 use std::time::Duration;
483
484 fn mock_returning(output: serde_json::Value) -> Arc<dyn AgentBackend> {
485 Arc::new(MockBackend::new(
486 "mock",
487 vec![MockBehavior::Success {
488 output,
489 tokens: TokenUsage::default(),
490 delay: Duration::ZERO,
491 }],
492 ))
493 }
494
495 #[tokio::test]
496 async fn test_plan_extracts_and_validates_script() {
497 let script = "```lua\nmeta = { reasoning = \"test\", phases = {{ label = \"work\" }} }\nfunction main()\n local r = agent({prompt='hi'})\n report({ok=true})\nend\n```";
498 let backend = mock_returning(serde_json::json!(script));
499 let planned = plan_workflow("do something", backend, &PlannerConfig::default())
500 .await
501 .unwrap();
502 assert!(planned.script.contains("report("));
503 assert!(!planned.script.contains("```"));
504 assert!(validate_script(&planned.script).is_ok());
505 }
506
507 #[tokio::test]
508 async fn test_plan_retries_on_invalid_then_fails() {
509 let bad = "```lua\nlocal x = (\nreport({})\n```";
511 let backend = mock_returning(serde_json::json!(bad));
512 let cfg = PlannerConfig {
513 planner_model: None,
514 max_retries: 2,
515 ..Default::default()
516 };
517 match plan_workflow("x", backend, &cfg).await.unwrap_err() {
518 PlannerError::ExhaustedRetries { attempts, .. } => assert_eq!(attempts, 2),
519 other => panic!("expected ExhaustedRetries, got {:?}", other),
520 }
521 }
522
523 #[tokio::test]
524 async fn test_plan_rejects_missing_report() {
525 let no_report = "```lua\nlocal r = agent({prompt='hi'})\n```";
527 let backend = mock_returning(serde_json::json!(no_report));
528 let cfg = PlannerConfig {
529 planner_model: None,
530 max_retries: 1,
531 ..Default::default()
532 };
533 assert!(matches!(
534 plan_workflow("x", backend, &cfg).await,
535 Err(PlannerError::ExhaustedRetries { .. })
536 ));
537 }
538
539 #[test]
540 fn test_extract_script_fenced_bare_and_object() {
541 let v = serde_json::json!("prefix\n```lua\nreport({})\n```\nsuffix");
543 assert_eq!(extract_script(&v).unwrap().trim(), "report({})");
544
545 let v = serde_json::json!(" report({}) ");
547 assert_eq!(extract_script(&v).unwrap(), "report({})");
548
549 let v = serde_json::json!({ "message": "```lua\nreport({})\n```" });
551 assert_eq!(extract_script(&v).unwrap().trim(), "report({})");
552
553 assert!(extract_script(&serde_json::Value::Null).is_none());
555 }
556
557 #[test]
558 fn test_extract_skips_non_lua_block() {
559 let v = serde_json::json!("```text\nnot code\n```\n```lua\nreport({})\n```");
560 assert_eq!(extract_script(&v).unwrap().trim(), "report({})");
561 }
562
563 #[tokio::test]
566 async fn test_plan_backend_error() {
567 let backend: Arc<dyn AgentBackend> = Arc::new(MockBackend::new(
568 "mock",
569 vec![MockBehavior::fail(FailKind::Protocol)],
570 ));
571 let err = plan_workflow("x", backend, &PlannerConfig::default())
572 .await
573 .unwrap_err();
574 assert!(matches!(err, PlannerError::Backend(_)));
575 }
576
577 #[tokio::test]
580 async fn test_plan_no_lua_block_retries() {
581 let backend = mock_returning(serde_json::Value::Null);
582 let cfg = PlannerConfig {
583 max_retries: 2,
584 ..Default::default()
585 };
586 let err = plan_workflow("x", backend, &cfg).await.unwrap_err();
587 assert!(matches!(err, PlannerError::ExhaustedRetries { .. }));
588 }
589
590 #[tokio::test]
593 async fn test_plan_retry_then_succeeds() {
594 let valid = "```lua\nmeta = { reasoning = \"test\", phases = {{ label = \"work\" }} }\nfunction main()\n local r = agent({prompt='hi'})\n report({ok=true})\nend\n```";
595 let backend: Arc<dyn AgentBackend> = Arc::new(MockBackend::new(
596 "mock",
597 vec![
598 MockBehavior::Success {
599 output: serde_json::json!("this is pure garbage"),
600 tokens: TokenUsage::default(),
601 delay: Duration::ZERO,
602 },
603 MockBehavior::Success {
604 output: serde_json::json!(valid),
605 tokens: TokenUsage::default(),
606 delay: Duration::ZERO,
607 },
608 ],
609 ));
610 let planned = plan_workflow("x", backend, &PlannerConfig::default())
611 .await
612 .unwrap();
613 assert!(planned.script.contains("report({ok=true})"));
614 }
615
616 #[test]
619 fn test_strip_prose_edge_cases() {
620 let s = strip_prose("hello world\nsome prose");
622 assert_eq!(s, "hello world\nsome prose");
623
624 let s = strip_prose("intro\nlocal x = 1\noutro");
626 assert_eq!(s, "local x = 1");
627
628 let s = strip_prose("local x = 1\nreport({})");
630 assert_eq!(s, "local x = 1\nreport({})");
631
632 let s = strip_prose(" local x = 1 ");
634 assert_eq!(s, "local x = 1");
635 }
636
637 #[test]
640 fn test_looks_like_lua_variants() {
641 assert!(!looks_like_lua(""));
642 assert!(!looks_like_lua(" "));
643 assert!(looks_like_lua("-- comment"));
644 assert!(looks_like_lua("local x = 1"));
645 assert!(looks_like_lua("{hello}"));
646 assert!(looks_like_lua("}"));
647 assert!(looks_like_lua("(arg)"));
648 assert!(looks_like_lua(")"));
649 assert!(looks_like_lua("\"string\""));
650 assert!(looks_like_lua("'string'"));
651 assert!(looks_like_lua("[1, 2]"));
652 assert!(looks_like_lua("]"));
653 assert!(looks_like_lua("= value"));
654 assert!(looks_like_lua(".method"));
655 assert!(looks_like_lua(":method"));
656 assert!(looks_like_lua("#list"));
657 assert!(!looks_like_lua("plain prose text"));
658 assert!(!looks_like_lua("1234 number line"));
659 }
660
661 #[test]
664 fn test_find_fenced_block_edge_cases() {
665 let s = find_fenced_block("```text\nstuff\n```\n```lua\nreport({})\n```");
667 assert_eq!(s.unwrap().trim(), "report({})");
668
669 assert!(find_fenced_block("```lua\nreport({})").is_none());
671
672 assert!(find_fenced_block("just text").is_none());
674 }
675
676 #[test]
679 fn test_output_to_text_variants() {
680 let obj = serde_json::json!({"unknown": "value"});
682 assert!(output_to_text(&obj).is_some());
683
684 assert_eq!(
686 output_to_text(&serde_json::json!([1, 2, 3])).unwrap(),
687 "[1,2,3]"
688 );
689
690 assert_eq!(output_to_text(&serde_json::json!(42)).unwrap(), "42");
692 }
693
694 #[test]
697 fn test_build_prompt_with_fix_error() {
698 let p = build_prompt("do task", Some("script had syntax error"), false);
699 assert!(p.contains("do task"));
700 assert!(p.contains("previous attempt was rejected"));
701 assert!(p.contains("script had syntax error"));
702 assert!(p.contains("Output ONLY"));
703 }
704
705 #[test]
706 fn test_build_prompt_without_fix_error() {
707 let p = build_prompt("do task", None, false);
708 assert!(p.contains("do task"));
709 assert!(!p.contains("previous attempt was rejected"));
710 assert!(p.contains("Output ONLY"));
711 }
712
713 #[test]
716 fn test_validate_span_unpaired() {
717 let script = "meta = { reasoning = \"test\", phases = {} }\nfunction main()\nlocal m = phase_begin(\"x\")\nreport({})\nend";
718 assert!(validate_generated(script).is_err());
719 }
720
721 #[test]
722 fn test_validate_span_paired() {
723 let script = "meta = { reasoning = \"test\", phases = {} }\nfunction main()\nlocal m = phase_begin(\"x\")\nphase_end(m)\nreport({})\nend";
724 assert!(validate_generated(script).is_ok());
725 }
726
727 #[test]
728 fn test_validate_no_span_ok() {
729 let script =
730 "meta = { reasoning = \"test\", phases = {} }\nfunction main() report({ok=true}) end";
731 assert!(validate_generated(script).is_ok());
732 }
733
734 #[test]
735 fn test_build_prompt_contains_decomposition_section() {
736 let p = build_prompt("refactor everything", None, false);
737 assert!(p.contains("Task Decomposition"));
738 assert!(p.contains("meta.phases") || p.contains("phases"));
739 }
740}