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 thread_id: None,
207 };
208 backend
209 .run(task, ctx)
210 .await
211 .map(|r| r.output)
212 .map_err(|e| e.to_string())
213}
214
215fn validate_generated(script: &str) -> Result<(), String> {
217 let result = validate_workflow(script).map_err(|e| format!("lua validation error: {}", e))?;
218 if result.errors.is_empty() {
219 Ok(())
220 } else {
221 Err(result.errors.join("; "))
222 }
223}
224
225fn extract_mock_block(output: &serde_json::Value) -> Option<serde_json::Value> {
227 let text = output_to_text(output)?;
228 let json_str = find_fenced_block_by_lang(&text, "json")?;
229 let trimmed = json_str.trim();
230 if trimmed.is_empty() {
231 return None;
232 }
233 serde_json::from_str(trimmed).ok()
234}
235
236fn find_fenced_block_by_lang(text: &str, lang: &str) -> Option<String> {
238 let marker = format!("```{}", lang);
239 let mut from = 0;
240 while let Some(rel) = text[from..].find(&marker) {
241 let fence = from + rel;
242 let after = &text[fence + marker.len()..];
243 let line_end = match after.find('\n') {
244 Some(n) => n,
245 None => {
246 from = fence + marker.len();
247 continue;
248 }
249 };
250 let body_start = fence + marker.len() + line_end + 1;
251 let rest = &text[body_start..];
252 let close = match rest.find("```") {
253 Some(c) => c,
254 None => {
255 from = body_start;
256 continue;
257 }
258 };
259 return Some(rest[..close].trim_end().to_string());
260 }
261 None
262}
263
264fn extract_script(output: &serde_json::Value) -> Option<String> {
266 let text = output_to_text(output)?;
267 let block = extract_lua_block(&text);
268 if block.trim().is_empty() {
269 None
270 } else {
271 Some(block)
272 }
273}
274
275fn output_to_text(output: &serde_json::Value) -> Option<String> {
280 match output {
281 serde_json::Value::String(s) => Some(s.clone()),
282 serde_json::Value::Object(map) => {
283 for key in ["script", "message", "text", "content", "output"] {
284 if let Some(serde_json::Value::String(s)) = map.get(key) {
285 return Some(s.clone());
286 }
287 }
288 Some(output.to_string())
289 }
290 serde_json::Value::Null => None,
291 other => Some(other.to_string()),
292 }
293}
294
295fn extract_lua_block(text: &str) -> String {
298 if let Some(block) = find_fenced_block(text) {
299 return block;
300 }
301 let trimmed = text.trim();
302 if validate_script(trimmed).is_ok() {
303 return trimmed.to_string();
304 }
305 strip_prose(trimmed)
306}
307
308fn strip_prose(text: &str) -> String {
311 let lines: Vec<&str> = text.lines().collect();
312 let lua_start = lines.iter().position(|l| looks_like_lua(l)).unwrap_or(0);
313 let lua_end = lines
314 .iter()
315 .rposition(|l| looks_like_lua(l))
316 .map(|i| i + 1)
317 .unwrap_or(lines.len());
318 lines[lua_start.min(lua_end.max(1))..lua_end.max(lines.len().min(lua_start + 1))]
319 .join("\n")
320 .trim()
321 .to_string()
322}
323
324fn looks_like_lua(line: &str) -> bool {
326 let t = line.trim();
327 if t.is_empty() {
328 return false;
329 }
330 if t.starts_with("--") {
331 return true;
332 }
333 const KEYWORDS: &[&str] = &[
334 "local", "phase", "report", "agent", "parallel", "pipeline", "for", "if", "while",
335 "function", "return", "log", "budget", "workflow", "json", "do", "end", "else", "elseif",
336 "then", "and", "or", "not", "true", "false", "nil", "args", "ctx",
337 ];
338 let first_word = t
339 .split(|c: char| !c.is_alphanumeric() && c != '_')
340 .next()
341 .unwrap_or("");
342 KEYWORDS.contains(&first_word)
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 || t.starts_with('#')
355}
356
357fn find_fenced_block(text: &str) -> Option<String> {
358 let mut from = 0;
359 while let Some(rel) = text[from..].find("```") {
360 let fence = from + rel;
361 let after = &text[fence + 3..];
362 let line_end = match after.find('\n') {
363 Some(n) => n,
364 None => {
365 from = fence + 3;
366 continue;
367 }
368 };
369 let lang = after[..line_end].trim();
370 let body_start = fence + 3 + line_end + 1;
371 let rest = &text[body_start..];
372 let close = match rest.find("```") {
373 Some(c) => c,
374 None => {
375 from = body_start;
376 continue;
377 }
378 };
379 if lang.eq_ignore_ascii_case("lua") || lang.is_empty() {
380 return Some(rest[..close].trim_end().to_string());
381 }
382 from = body_start + close + 3;
383 }
384 None
385}
386
387fn build_prompt(task: &str, fix_error: Option<&str>, generate_mock: bool) -> String {
389 let mut p = String::with_capacity(LUA_DSL_REFERENCE.len() + task.len() + 1024);
390 p.push_str(LUA_DSL_REFERENCE);
391 p.push_str("\n\n# Task\n\n");
392 p.push_str(task);
393 p.push('\n');
394 if let Some(err) = fix_error {
395 p.push_str("\n# Your previous attempt was rejected\n\n");
396 p.push_str(err);
397 p.push_str("\n\nFix the script and output a corrected version.\n");
398 }
399 if generate_mock {
400 p.push_str(MOCK_GENERATION_INSTRUCTIONS);
401 }
402 p.push_str("\nOutput ONLY one ```lua code block");
403 if generate_mock {
404 p.push_str(" followed by one ```json code block");
405 }
406 p.push_str(" — no prose before or after.\n");
407 p
408}
409
410const MOCK_GENERATION_INSTRUCTIONS: &str = r#"
411
412# Mock Data Generation
413
414In ADDITION to the ```lua block, output a second ```json block with mock
415responses for EVERY named agent call. Format:
416
417```json
418{
419 "responses": {
420 "<agent_name>": {
421 "output": <representative JSON matching the agent's expected output>,
422 "tokens": {"input": 100, "output": 50},
423 "status": "ok"
424 }
425 },
426 "default": {
427 "output": {"text": "mock response"},
428 "tokens": {"input": 0, "output": 0}
429 }
430}
431```
432
433Rules:
4341. EVERY agent() call in the Lua script MUST include a unique `name=` field.
4352. For parallel() fan-out, all items share ONE name — the mock response is reused.
4363. Mock output MUST match the agent's schema structure if a schema is defined.
4374. Keep mock output concise but structurally valid.
4385. Output BOTH blocks: ```lua first, then ```json.
439"#;
440
441const LUA_DSL_REFERENCE: &str = include_str!("lua_dsl_reference.md");
478
479#[cfg(test)]
480mod tests {
481 use super::*;
482 use luft_core::{FailKind, MockBackend, MockBehavior, TokenUsage};
483 use std::time::Duration;
484
485 fn mock_returning(output: serde_json::Value) -> Arc<dyn AgentBackend> {
486 Arc::new(MockBackend::new(
487 "mock",
488 vec![MockBehavior::Success {
489 output,
490 tokens: TokenUsage::default(),
491 delay: Duration::ZERO,
492 }],
493 ))
494 }
495
496 #[tokio::test]
497 async fn test_plan_extracts_and_validates_script() {
498 let script = "```lua\nmeta = { reasoning = \"test\", phases = {{ label = \"work\" }} }\nfunction main()\n local r = agent({prompt='hi'})\n report({ok=true})\nend\n```";
499 let backend = mock_returning(serde_json::json!(script));
500 let planned = plan_workflow("do something", backend, &PlannerConfig::default())
501 .await
502 .unwrap();
503 assert!(planned.script.contains("report("));
504 assert!(!planned.script.contains("```"));
505 assert!(validate_script(&planned.script).is_ok());
506 }
507
508 #[tokio::test]
509 async fn test_plan_retries_on_invalid_then_fails() {
510 let bad = "```lua\nlocal x = (\nreport({})\n```";
512 let backend = mock_returning(serde_json::json!(bad));
513 let cfg = PlannerConfig {
514 planner_model: None,
515 max_retries: 2,
516 ..Default::default()
517 };
518 match plan_workflow("x", backend, &cfg).await.unwrap_err() {
519 PlannerError::ExhaustedRetries { attempts, .. } => assert_eq!(attempts, 2),
520 other => panic!("expected ExhaustedRetries, got {:?}", other),
521 }
522 }
523
524 #[tokio::test]
525 async fn test_plan_rejects_missing_report() {
526 let no_report = "```lua\nlocal r = agent({prompt='hi'})\n```";
528 let backend = mock_returning(serde_json::json!(no_report));
529 let cfg = PlannerConfig {
530 planner_model: None,
531 max_retries: 1,
532 ..Default::default()
533 };
534 assert!(matches!(
535 plan_workflow("x", backend, &cfg).await,
536 Err(PlannerError::ExhaustedRetries { .. })
537 ));
538 }
539
540 #[test]
541 fn test_extract_script_fenced_bare_and_object() {
542 let v = serde_json::json!("prefix\n```lua\nreport({})\n```\nsuffix");
544 assert_eq!(extract_script(&v).unwrap().trim(), "report({})");
545
546 let v = serde_json::json!(" report({}) ");
548 assert_eq!(extract_script(&v).unwrap(), "report({})");
549
550 let v = serde_json::json!({ "message": "```lua\nreport({})\n```" });
552 assert_eq!(extract_script(&v).unwrap().trim(), "report({})");
553
554 assert!(extract_script(&serde_json::Value::Null).is_none());
556 }
557
558 #[test]
559 fn test_extract_skips_non_lua_block() {
560 let v = serde_json::json!("```text\nnot code\n```\n```lua\nreport({})\n```");
561 assert_eq!(extract_script(&v).unwrap().trim(), "report({})");
562 }
563
564 #[tokio::test]
567 async fn test_plan_backend_error() {
568 let backend: Arc<dyn AgentBackend> = Arc::new(MockBackend::new(
569 "mock",
570 vec![MockBehavior::fail(FailKind::Protocol)],
571 ));
572 let err = plan_workflow("x", backend, &PlannerConfig::default())
573 .await
574 .unwrap_err();
575 assert!(matches!(err, PlannerError::Backend(_)));
576 }
577
578 #[tokio::test]
581 async fn test_plan_no_lua_block_retries() {
582 let backend = mock_returning(serde_json::Value::Null);
583 let cfg = PlannerConfig {
584 max_retries: 2,
585 ..Default::default()
586 };
587 let err = plan_workflow("x", backend, &cfg).await.unwrap_err();
588 assert!(matches!(err, PlannerError::ExhaustedRetries { .. }));
589 }
590
591 #[tokio::test]
594 async fn test_plan_retry_then_succeeds() {
595 let valid = "```lua\nmeta = { reasoning = \"test\", phases = {{ label = \"work\" }} }\nfunction main()\n local r = agent({prompt='hi'})\n report({ok=true})\nend\n```";
596 let backend: Arc<dyn AgentBackend> = Arc::new(MockBackend::new(
597 "mock",
598 vec![
599 MockBehavior::Success {
600 output: serde_json::json!("this is pure garbage"),
601 tokens: TokenUsage::default(),
602 delay: Duration::ZERO,
603 },
604 MockBehavior::Success {
605 output: serde_json::json!(valid),
606 tokens: TokenUsage::default(),
607 delay: Duration::ZERO,
608 },
609 ],
610 ));
611 let planned = plan_workflow("x", backend, &PlannerConfig::default())
612 .await
613 .unwrap();
614 assert!(planned.script.contains("report({ok=true})"));
615 }
616
617 #[test]
620 fn test_strip_prose_edge_cases() {
621 let s = strip_prose("hello world\nsome prose");
623 assert_eq!(s, "hello world\nsome prose");
624
625 let s = strip_prose("intro\nlocal x = 1\noutro");
627 assert_eq!(s, "local x = 1");
628
629 let s = strip_prose("local x = 1\nreport({})");
631 assert_eq!(s, "local x = 1\nreport({})");
632
633 let s = strip_prose(" local x = 1 ");
635 assert_eq!(s, "local x = 1");
636 }
637
638 #[test]
641 fn test_looks_like_lua_variants() {
642 assert!(!looks_like_lua(""));
643 assert!(!looks_like_lua(" "));
644 assert!(looks_like_lua("-- comment"));
645 assert!(looks_like_lua("local x = 1"));
646 assert!(looks_like_lua("{hello}"));
647 assert!(looks_like_lua("}"));
648 assert!(looks_like_lua("(arg)"));
649 assert!(looks_like_lua(")"));
650 assert!(looks_like_lua("\"string\""));
651 assert!(looks_like_lua("'string'"));
652 assert!(looks_like_lua("[1, 2]"));
653 assert!(looks_like_lua("]"));
654 assert!(looks_like_lua("= value"));
655 assert!(looks_like_lua(".method"));
656 assert!(looks_like_lua(":method"));
657 assert!(looks_like_lua("#list"));
658 assert!(!looks_like_lua("plain prose text"));
659 assert!(!looks_like_lua("1234 number line"));
660 }
661
662 #[test]
665 fn test_find_fenced_block_edge_cases() {
666 let s = find_fenced_block("```text\nstuff\n```\n```lua\nreport({})\n```");
668 assert_eq!(s.unwrap().trim(), "report({})");
669
670 assert!(find_fenced_block("```lua\nreport({})").is_none());
672
673 assert!(find_fenced_block("just text").is_none());
675 }
676
677 #[test]
680 fn test_output_to_text_variants() {
681 let obj = serde_json::json!({"unknown": "value"});
683 assert!(output_to_text(&obj).is_some());
684
685 assert_eq!(
687 output_to_text(&serde_json::json!([1, 2, 3])).unwrap(),
688 "[1,2,3]"
689 );
690
691 assert_eq!(output_to_text(&serde_json::json!(42)).unwrap(), "42");
693 }
694
695 #[test]
698 fn test_build_prompt_with_fix_error() {
699 let p = build_prompt("do task", Some("script had syntax error"), false);
700 assert!(p.contains("do task"));
701 assert!(p.contains("previous attempt was rejected"));
702 assert!(p.contains("script had syntax error"));
703 assert!(p.contains("Output ONLY"));
704 }
705
706 #[test]
707 fn test_build_prompt_without_fix_error() {
708 let p = build_prompt("do task", None, false);
709 assert!(p.contains("do task"));
710 assert!(!p.contains("previous attempt was rejected"));
711 assert!(p.contains("Output ONLY"));
712 }
713
714 #[test]
717 fn test_validate_span_unpaired() {
718 let script = "meta = { reasoning = \"test\", phases = {} }\nfunction main()\nlocal m = phase_begin(\"x\")\nreport({})\nend";
719 assert!(validate_generated(script).is_err());
720 }
721
722 #[test]
723 fn test_validate_span_paired() {
724 let script = "meta = { reasoning = \"test\", phases = {} }\nfunction main()\nlocal m = phase_begin(\"x\")\nphase_end(m)\nreport({})\nend";
725 assert!(validate_generated(script).is_ok());
726 }
727
728 #[test]
729 fn test_validate_no_span_ok() {
730 let script =
731 "meta = { reasoning = \"test\", phases = {} }\nfunction main() report({ok=true}) end";
732 assert!(validate_generated(script).is_ok());
733 }
734
735 #[test]
736 fn test_build_prompt_contains_decomposition_section() {
737 let p = build_prompt("refactor everything", None, false);
738 assert!(p.contains("Task Decomposition"));
739 assert!(p.contains("meta.phases") || p.contains("phases"));
740 }
741}