car-builder 0.24.1

Natural-language → validated car-workflow manifest builder for Common Agent Runtime
//! Prompt construction: turn a goal + catalog (+ optional existing workflow,
//! revision feedback, and prior validation issues) into the instruction the
//! model answers with a single workflow JSON object.

use serde::{Deserialize, Serialize};

/// An agent the builder may wire into a workflow stage.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentInfo {
    pub id: String,
    #[serde(default)]
    pub name: String,
    #[serde(default)]
    pub description: String,
}

/// A tool a proposal/agent stage may call.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolInfo {
    pub name: String,
    #[serde(default)]
    pub description: String,
}

/// What the builder knows is available to compose into a workflow.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ToolCatalog {
    #[serde(default)]
    pub agents: Vec<AgentInfo>,
    #[serde(default)]
    pub tools: Vec<ToolInfo>,
    #[serde(default)]
    pub models: Vec<String>,
}

/// The car-workflow schema, taught to the model so it emits a parseable,
/// verifiable manifest. Kept compact but complete — these are the only stage
/// types and the exact field names the runtime accepts.
const SCHEMA_CHEATSHEET: &str = r#"A workflow is a JSON object:
{
  "id": "kebab-id", "name": "Human Name", "start": "<entry stage id>",
  "goal": "the overall objective (optional; re-anchored into every step to prevent drift)",
  "max_iterations": 100,
  "stages": [ { "id": "s1", "name": "Stage 1", "step": <STEP> }, ... ],
  "edges": [ { "from": "s1", "to": "s2", "conditions": [<COND>...], "label": "on success" }, ... ]
}

STEP is one of (tagged by "type"):
- {"type":"pattern","pattern":"<KIND>","task":"...","agents":[<AGENT_SPEC>...],"config":{}}
    KIND: swarm_parallel | swarm_sequential | swarm_debate | pipeline | supervisor | delegator | map_reduce | vote | fleet | adversarial_review | tournament
    adversarial_review: fresh reviewer checks prior work; agents[0]=reviewer, config={"criteria":["..."],"review_key":"stage.<id>.answer"}. Branch on the typed bool stage.<id>.review_passed.
    tournament: rank candidates by pairwise judging; last agent (or config.judge_index) is the judge, the rest compete.
- {"type":"proposal","proposal":{"id":"p1","source":"builder","actions":[<ACTION>...],"context":{}}}
- {"type":"approval","prompt":"shown to the human","fields":[{"name":"decision","label":"Decision","field_type":"text","options":[],"required":true}],"output_key":"approval"}
- {"type":"sub_workflow","workflow":{<a nested workflow>}}
- {"type":"loop_until","body":<STEP>,"until":[<COND>...],"max_iterations":5}  (repeat body until conditions hold or cap; body may not be approval)
- {"type":"for_each","items_from":"<state key with a JSON array>","body":<STEP>,"max_concurrent":4}  ({{item}}/{{index}} substituted into the body)

AGENT_SPEC: {"name":"researcher","system_prompt":"...","tools":["search"],"max_turns":10,"metadata":{}}
ACTION: {"id":"a1","type":"tool_call","tool":"<tool name>","parameters":{},"preconditions":[],"expected_effects":{},"state_dependencies":[],"idempotent":false,"max_retries":3,"failure_behavior":"abort","metadata":{}}
COND: {"key":"stage.<id>.succeeded","operator":"eq","value":true}  (operators: eq, neq, exists, not_exists, gt, lt, gte, lte, contains)

RULES:
- Every edge `from`/`to` and the `start` must reference an existing stage id.
- Every reachable path must terminate (a stage with no outgoing edge ends the run).
- Approval stages MUST set a non-empty "output_key" and may NOT be used as a compensation handler.
- After an approval gate, branch on the human's answer with edge conditions like {"key":"approval.decision","operator":"eq","value":"approve"}.
- Prefer reusing the listed agents/tools; do not invent tool names that aren't listed unless the goal clearly needs a new one.
"#;

/// Build the full instruction for one generation attempt.
pub fn build_prompt(req: &crate::BuildRequest, prior_issues: &[String]) -> String {
    let mut p = String::new();

    p.push_str(
        "You design deterministic multi-stage agent workflows for the Common Agent Runtime. \
         Output EXACTLY ONE JSON object for the workflow described below — no prose, no markdown fences.\n\n",
    );

    p.push_str("# Goal\n");
    p.push_str(req.goal.trim());
    p.push_str("\n\n");

    // Catalog
    if !req.catalog.agents.is_empty() {
        p.push_str("# Available agents (reuse by id)\n");
        for a in &req.catalog.agents {
            p.push_str(&format!("- {} ({}): {}\n", a.id, a.name, a.description));
        }
        p.push('\n');
    }
    if !req.catalog.tools.is_empty() {
        p.push_str("# Available tools\n");
        for t in &req.catalog.tools {
            p.push_str(&format!("- {}: {}\n", t.name, t.description));
        }
        p.push('\n');
    }
    if !req.catalog.models.is_empty() {
        p.push_str("# Available models\n");
        p.push_str(&req.catalog.models.join(", "));
        p.push_str("\n\n");
    }

    // Update path: show the existing definition and ask for targeted edits.
    if let Some(existing) = &req.existing {
        p.push_str(
            "# Existing workflow to UPDATE\nApply the goal as targeted edits. \
             Preserve existing stage ids where the stage is kept.\n",
        );
        if let Ok(json) = serde_json::to_string_pretty(existing) {
            p.push_str(&json);
            p.push_str("\n\n");
        }
    }

    // Human revision feedback (from an approve/revise loop).
    if let Some(fb) = &req.feedback {
        if !fb.trim().is_empty() {
            p.push_str("# Revision requested by the user\n");
            p.push_str(fb.trim());
            p.push_str("\n\n");
        }
    }

    // Repair feedback: prior attempt's validation errors.
    if !prior_issues.is_empty() {
        p.push_str(
            "# Your previous attempt FAILED validation. Fix exactly these issues and return the corrected workflow:\n",
        );
        for issue in prior_issues {
            p.push_str(&format!("- {issue}\n"));
        }
        p.push('\n');
    }

    p.push_str("# Workflow schema\n");
    p.push_str(SCHEMA_CHEATSHEET);
    p.push_str("\nReturn ONLY the workflow JSON object now.");
    p
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::BuildRequest;

    fn req() -> BuildRequest {
        BuildRequest {
            goal: "research a stock then have a human approve a summary".into(),
            catalog: ToolCatalog {
                agents: vec![AgentInfo {
                    id: "agent_researcher".into(),
                    name: "Researcher".into(),
                    description: "Finds information".into(),
                }],
                tools: vec![ToolInfo {
                    name: "web_search".into(),
                    description: "search the web".into(),
                }],
                models: vec!["qwen3".into()],
            },
            existing: None,
            feedback: None,
            max_attempts: 3,
        }
    }

    #[test]
    fn prompt_includes_goal_catalog_and_schema() {
        let p = build_prompt(&req(), &[]);
        assert!(p.contains("research a stock"));
        assert!(p.contains("agent_researcher"));
        assert!(p.contains("web_search"));
        assert!(p.contains("qwen3"));
        assert!(p.contains("\"type\":\"approval\""));
        assert!(p.contains("output_key"));
    }

    #[test]
    fn prior_issues_are_fed_back_for_repair() {
        let p = build_prompt(&req(), &["s2: edge to 'ghost' references unknown stage".into()]);
        assert!(p.contains("FAILED validation"));
        assert!(p.contains("ghost"));
    }

    #[test]
    fn feedback_and_existing_render_update_path() {
        let mut r = req();
        r.feedback = Some("add a verification stage before approval".into());
        let p = build_prompt(&r, &[]);
        assert!(p.contains("Revision requested"));
        assert!(p.contains("verification stage"));
    }
}