mod parse;
mod prompt;
pub use parse::parse_workflow;
pub use prompt::{build_prompt, AgentInfo, ToolCatalog, ToolInfo};
use car_workflow::{verify_workflow, Workflow};
use std::future::Future;
pub struct BuildRequest {
pub goal: String,
pub catalog: ToolCatalog,
pub existing: Option<Workflow>,
pub feedback: Option<String>,
pub max_attempts: u32,
}
pub struct BuildResult {
pub workflow: Option<Workflow>,
pub valid: bool,
pub issues: Vec<String>,
pub warnings: Vec<String>,
pub attempts: u32,
pub raw: Option<String>,
}
pub async fn build_workflow<F, Fut>(generate: F, req: &BuildRequest) -> BuildResult
where
F: Fn(String) -> Fut + Send + Sync,
Fut: Future<Output = Result<String, String>> + Send,
{
let max = req.max_attempts.max(1);
let mut prior_issues: Vec<String> = Vec::new();
let mut last_raw: Option<String> = None;
let mut last_invalid: Option<Workflow> = None;
let mut last_issues: Vec<String> = Vec::new();
let mut last_warnings: Vec<String> = Vec::new();
for attempt in 1..=max {
let prompt = build_prompt(req, &prior_issues);
let text = match generate(prompt).await {
Ok(t) => t,
Err(e) => {
last_issues = vec![format!("generation failed: {e}")];
continue;
}
};
last_raw = Some(text.clone());
let workflow = match parse_workflow(&text) {
Ok(wf) => wf,
Err(e) => {
prior_issues =
vec![format!("Your output did not parse as a workflow JSON object: {e}. Return ONLY the JSON object.")];
last_issues = prior_issues.clone();
continue;
}
};
let mut errors: Vec<String> = verify_workflow(&workflow)
.issues
.iter()
.filter(|i| i.severity == "error")
.map(|i| match &i.stage_id {
Some(s) => format!("{s}: {}", i.message),
None => i.message.clone(),
})
.collect();
errors.extend(catalog_issues(&workflow, &req.catalog));
let warnings = car_workflow::semantic_issues(&workflow);
if errors.is_empty() {
return BuildResult {
workflow: Some(workflow),
valid: true,
issues: Vec::new(),
warnings,
attempts: attempt,
raw: last_raw,
};
}
let mut feedback = errors.clone();
feedback.extend(warnings.iter().cloned());
prior_issues = feedback;
last_issues = errors;
last_warnings = warnings;
last_invalid = Some(workflow);
}
BuildResult {
workflow: last_invalid,
valid: false,
issues: last_issues,
warnings: last_warnings,
attempts: max,
raw: last_raw,
}
}
fn catalog_issues(workflow: &Workflow, catalog: &ToolCatalog) -> Vec<String> {
if catalog.tools.is_empty() {
return Vec::new();
}
let known: std::collections::HashSet<&str> =
catalog.tools.iter().map(|t| t.name.as_str()).collect();
let mut issues = Vec::new();
for stage in &workflow.stages {
match &stage.step {
car_workflow::StageStep::Proposal(ps) => {
for action in &ps.proposal.actions {
if let Some(tool) = &action.tool {
if !known.contains(tool.as_str()) {
issues.push(format!(
"{}: action uses unknown tool '{}'",
stage.id, tool
));
}
}
}
}
car_workflow::StageStep::Pattern(p) => {
for agent in &p.agents {
for tool in &agent.tools {
if !known.contains(tool.as_str()) {
issues.push(format!(
"{}: agent '{}' uses unknown tool '{}'",
stage.id, agent.name, tool
));
}
}
}
}
_ => {}
}
}
issues
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
fn req(goal: &str) -> BuildRequest {
BuildRequest {
goal: goal.into(),
catalog: ToolCatalog::default(),
existing: None,
feedback: None,
max_attempts: 3,
}
}
const VALID_WF: &str = r#"{
"id": "wf", "name": "WF", "start": "gate",
"stages": [
{"id": "gate", "name": "Approve", "step": {"type":"approval","prompt":"ok?","fields":[],"output_key":"approval"}}
],
"edges": []
}"#;
const INVALID_WF: &str = r#"{
"id": "wf", "name": "WF", "start": "missing",
"stages": [
{"id": "gate", "name": "Approve", "step": {"type":"approval","prompt":"ok?","fields":[],"output_key":"approval"}}
],
"edges": []
}"#;
#[tokio::test]
async fn valid_on_first_attempt() {
let result = build_workflow(|_p| async { Ok::<_, String>(VALID_WF.to_string()) }, &req("x")).await;
assert!(result.valid);
assert_eq!(result.attempts, 1);
assert_eq!(result.workflow.unwrap().id, "wf");
}
#[tokio::test]
async fn repairs_then_succeeds() {
let calls = AtomicUsize::new(0);
let result = build_workflow(
|prompt: String| {
let n = calls.fetch_add(1, Ordering::SeqCst) + 1;
async move {
if n == 1 {
Ok::<_, String>(INVALID_WF.to_string())
} else {
assert!(prompt.contains("FAILED validation"));
assert!(prompt.contains("missing"));
Ok(VALID_WF.to_string())
}
}
},
&req("x"),
)
.await;
assert!(result.valid);
assert_eq!(result.attempts, 2);
}
#[tokio::test]
async fn gives_up_after_max_attempts_with_issues() {
let result = build_workflow(
|_p| async { Ok::<_, String>(INVALID_WF.to_string()) },
&req("x"),
)
.await;
assert!(!result.valid);
assert_eq!(result.attempts, 3);
assert!(!result.issues.is_empty());
assert!(result.workflow.is_some());
assert!(result.issues.iter().any(|i| i.contains("missing")));
}
#[tokio::test]
async fn unparseable_output_is_repaired_as_a_parse_issue() {
let calls = AtomicUsize::new(0);
let result = build_workflow(
|prompt: String| {
let n = calls.fetch_add(1, Ordering::SeqCst) + 1;
async move {
if n == 1 {
Ok::<_, String>("I'm sorry, I can't do that".to_string())
} else {
assert!(prompt.contains("did not parse"));
Ok(VALID_WF.to_string())
}
}
},
&req("x"),
)
.await;
assert!(result.valid);
assert_eq!(result.attempts, 2);
}
#[tokio::test]
async fn generation_error_then_recovers() {
let calls = AtomicUsize::new(0);
let result = build_workflow(
|_prompt: String| {
let n = calls.fetch_add(1, Ordering::SeqCst) + 1;
async move {
if n == 1 {
Err::<String, String>("transport boom".into())
} else {
Ok(VALID_WF.to_string())
}
}
},
&req("x"),
)
.await;
assert!(result.valid);
assert_eq!(result.attempts, 2);
}
#[tokio::test]
async fn all_generation_errors_yield_no_workflow_with_issues() {
let result = build_workflow(
|_p| async { Err::<String, String>("boom".into()) },
&req("x"),
)
.await;
assert!(!result.valid);
assert!(result.workflow.is_none());
assert!(result.issues.iter().any(|i| i.contains("generation failed")));
}
#[tokio::test]
async fn catalog_cross_check_rejects_unknown_tool() {
const PROPOSAL_WF: &str = r#"{
"id":"wf","name":"WF","start":"do",
"stages":[{"id":"do","name":"Do","step":{"type":"proposal","proposal":{
"id":"p","source":"builder","actions":[
{"id":"a","type":"tool_call","tool":"made_up_tool","parameters":{}}
],"context":{}}}}],
"edges":[]
}"#;
let mut r = req("x");
r.catalog = ToolCatalog {
tools: vec![ToolInfo { name: "real_tool".into(), description: String::new() }],
..Default::default()
};
r.max_attempts = 1;
let result =
build_workflow(|_p| async { Ok::<_, String>(PROPOSAL_WF.to_string()) }, &r).await;
assert!(!result.valid, "unknown tool must fail the catalog cross-check");
assert!(result.issues.iter().any(|i| i.contains("made_up_tool")));
}
#[tokio::test]
async fn empty_catalog_imposes_no_tool_constraint() {
const PROPOSAL_WF: &str = r#"{
"id":"wf","name":"WF","start":"do",
"stages":[{"id":"do","name":"Do","step":{"type":"proposal","proposal":{
"id":"p","source":"builder","actions":[
{"id":"a","type":"tool_call","tool":"anything","parameters":{}}
],"context":{}}}}],
"edges":[]
}"#;
let result =
build_workflow(|_p| async { Ok::<_, String>(PROPOSAL_WF.to_string()) }, &req("x")).await;
assert!(result.valid, "empty catalog should not constrain tool names");
}
const SEMANTIC_WARN_WF: &str = r#"{
"id":"wf","name":"WF","start":"gate",
"stages":[
{"id":"gate","name":"Gate","step":{"type":"approval","prompt":"ok?","fields":[],"output_key":"approval"}},
{"id":"done","name":"Done","step":{"type":"proposal","proposal":{"id":"p","source":"b","actions":[],"context":{}}}}
],
"edges":[{"from":"gate","to":"done","conditions":[{"key":"approval.decision","operator":"eq","value":"approve"}],"label":""}]
}"#;
#[tokio::test]
async fn valid_with_semantic_warnings_does_not_block() {
let result = build_workflow(
|_p| async { Ok::<_, String>(SEMANTIC_WARN_WF.to_string()) },
&req("x"),
)
.await;
assert!(result.valid);
assert_eq!(result.attempts, 1);
assert!(result
.warnings
.iter()
.any(|w| w.contains("approval.decision")));
}
#[tokio::test]
async fn max_attempts_is_clamped_to_at_least_one() {
let mut r = req("x");
r.max_attempts = 0;
let result = build_workflow(|_p| async { Ok::<_, String>(VALID_WF.to_string()) }, &r).await;
assert!(result.valid);
assert_eq!(result.attempts, 1);
}
}