llman 0.0.30

A tool for managing LLM application rules(prompts) ...
Documentation
use anyhow::{Context, Result, anyhow};
use llm_json::{RepairOptions, loads};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashSet;

#[derive(Debug, Clone)]
pub struct AgentPresetBuildRequest {
    pub agent_id: String,
    pub available_skill_ids: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AgentPresetBuildOutput {
    pub description: String,
    pub includes: Vec<String>,
    pub system_prompt_md: String,
}

pub fn builder_output_schema() -> Value {
    serde_json::json!({
        "type": "object",
        "additionalProperties": false,
        "required": ["description", "includes", "system_prompt_md"],
        "properties": {
            "description": { "type": "string", "minLength": 1 },
            "includes": {
                "type": "array",
                "items": { "type": "string" },
                "uniqueItems": true
            },
            "system_prompt_md": { "type": "string", "minLength": 1 }
        }
    })
}

pub fn parse_and_validate_builder_output(
    agent_id: &str,
    available_skill_ids: &[String],
    raw_json: &str,
) -> Result<AgentPresetBuildOutput> {
    let value = parse_builder_json_value(raw_json)?;
    validate_builder_output_value(agent_id, available_skill_ids, &value)
}

pub fn validate_builder_output_value(
    agent_id: &str,
    available_skill_ids: &[String],
    value: &Value,
) -> Result<AgentPresetBuildOutput> {
    let schema = builder_output_schema();
    let validator =
        jsonschema::validator_for(&schema).context("compile agent builder output schema")?;
    let value = strip_unknown_builder_fields(value);
    if let Err(errors) = validator.validate(&value) {
        return Err(anyhow!(
            "Agent builder output does not match schema: {}",
            errors
        ));
    }

    let mut output: AgentPresetBuildOutput =
        serde_json::from_value(value).context("deserialize agent builder output")?;

    output.description = output.description.trim().to_string();
    if output.description.is_empty() {
        return Err(anyhow!("Agent builder output description is empty"));
    }

    let allowed: HashSet<&str> = available_skill_ids.iter().map(|s| s.as_str()).collect();
    let mut removed = Vec::new();
    output.includes.retain(|skill_id| {
        if skill_id == agent_id {
            removed.push(skill_id.clone());
            return false;
        }
        if !allowed.contains(skill_id.as_str()) {
            removed.push(skill_id.clone());
            return false;
        }
        true
    });
    output.includes.sort();
    output.includes.dedup();
    if !removed.is_empty() {
        eprintln!(
            "Warning: agent builder returned unknown/invalid includes, skipped: {}",
            removed.join(", ")
        );
    }

    if !output.system_prompt_md.contains("## Requirements") {
        output.system_prompt_md = format!(
            "{}\n\n## Requirements\n\n- \n",
            output.system_prompt_md.trim_end()
        );
    }

    Ok(output)
}

fn strip_unknown_builder_fields(value: &Value) -> Value {
    let Value::Object(map) = value else {
        return value.clone();
    };
    let mut out = serde_json::Map::new();
    for key in ["description", "includes", "system_prompt_md"] {
        if let Some(value) = map.get(key) {
            out.insert(key.to_string(), value.clone());
        }
    }
    Value::Object(out)
}

fn parse_builder_json_value(raw_json: &str) -> Result<Value> {
    match serde_json::from_str::<Value>(raw_json) {
        Ok(value) => Ok(value),
        Err(_) => {
            let repaired = loads(raw_json, &RepairOptions::default())
                .map_err(|e| anyhow!("failed to parse builder JSON: {}", e))?;
            Ok(repaired)
        }
    }
}

#[cfg(feature = "agents-ai")]
pub fn build_with_openai(request: &AgentPresetBuildRequest) -> Result<AgentPresetBuildOutput> {
    use adk_core::{Content, Llm, LlmRequest};
    use adk_model::{OpenAIClient, OpenAIConfig};
    use futures::StreamExt;

    let api_key = require_env_var("OPENAI_API_KEY")?;
    let model = env_var_fallback("OPENAI_MODEL", "OPENAI_DEFAULT_MODEL")?;
    let base_url = optional_env_var_fallback("OPENAI_BASE_URL", "OPENAI_API_BASE");

    let client = if let Some(base_url) = base_url {
        OpenAIClient::new(OpenAIConfig::compatible(api_key, base_url, &model))
            .context("create OpenAI client")?
    } else {
        OpenAIClient::new(OpenAIConfig::new(api_key, &model)).context("create OpenAI client")?
    };

    let available_skills = request
        .available_skill_ids
        .iter()
        .map(|s| format!("- {}", s))
        .collect::<Vec<_>>()
        .join("\n");

    let system = Content::new("system").with_text(
        "You are a llman agent preset builder. Output MUST be a single JSON object matching the provided JSON schema. No markdown, no commentary.",
    );
    let user = Content::new("user").with_text(format!(
        "agent_id: {id}\n\navailable_skills:\n{skills}\n\nGenerate:\n- description: short 1-line summary\n- includes: array of skill ids (subset of available_skills, MUST NOT include agent_id)\n- system_prompt_md: markdown system prompt body, MUST include routing/decision logic and MUST end with a '## Requirements' section.\n",
        id = request.agent_id,
        skills = available_skills
    ));

    let schema = builder_output_schema();
    let req = LlmRequest::new(&model, vec![system, user]).with_response_schema(schema);

    let runtime = tokio::runtime::Runtime::new().context("create tokio runtime")?;
    let raw = runtime.block_on(async {
        let mut stream = client.generate_content(req, false).await?;
        let mut out = String::new();
        while let Some(chunk) = stream.next().await {
            let resp = chunk?;
            let Some(content) = resp.content else {
                if resp.turn_complete {
                    break;
                }
                continue;
            };
            for part in content.parts {
                if let Some(text) = part.text() {
                    out.push_str(text);
                }
            }
            if resp.turn_complete {
                break;
            }
        }
        Ok::<_, anyhow::Error>(out)
    })?;

    parse_and_validate_builder_output(&request.agent_id, &request.available_skill_ids, &raw)
}

#[cfg(feature = "agents-ai")]
fn require_env_var(name: &str) -> Result<String> {
    let value = std::env::var(name).unwrap_or_default();
    if value.trim().is_empty() {
        return Err(anyhow!(
            "{name} is required for `llman agents new --ai`",
            name = name
        ));
    }
    Ok(value)
}

#[cfg(feature = "agents-ai")]
fn env_var_fallback(primary: &str, fallback: &str) -> Result<String> {
    let primary_value = std::env::var(primary).unwrap_or_default();
    if !primary_value.trim().is_empty() {
        return Ok(primary_value);
    }
    let fallback_value = std::env::var(fallback).unwrap_or_default();
    if !fallback_value.trim().is_empty() {
        return Ok(fallback_value);
    }
    Err(anyhow!(
        "{primary} is required for `llman agents new --ai` (or set {fallback})",
        primary = primary,
        fallback = fallback
    ))
}

#[cfg(feature = "agents-ai")]
fn optional_env_var_fallback(primary: &str, fallback: &str) -> Option<String> {
    let primary_value = std::env::var(primary).unwrap_or_default();
    if !primary_value.trim().is_empty() {
        return Some(primary_value);
    }
    let fallback_value = std::env::var(fallback).unwrap_or_default();
    if !fallback_value.trim().is_empty() {
        return Some(fallback_value);
    }
    None
}

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

    #[test]
    fn validate_builder_output_accepts_valid_json() {
        let agent_id = "foo";
        let available = vec!["a".to_string(), "b".to_string()];
        let value = serde_json::json!({
            "description": "desc",
            "includes": ["a"],
            "system_prompt_md": "hello\n\n## Requirements\n\n- must\n"
        });
        let output = validate_builder_output_value(agent_id, &available, &value).expect("valid");
        assert_eq!(output.description, "desc");
        assert_eq!(output.includes, vec!["a".to_string()]);
        assert!(output.system_prompt_md.contains("## Requirements"));
    }

    #[test]
    fn validate_builder_output_rejects_missing_fields() {
        let agent_id = "foo";
        let available = vec!["a".to_string()];
        let value = serde_json::json!({"description": "x"});
        let err = validate_builder_output_value(agent_id, &available, &value).expect_err("invalid");
        assert!(err.to_string().contains("schema"));
    }

    #[test]
    fn validate_builder_output_filters_invalid_includes() {
        let agent_id = "foo";
        let available = vec!["a".to_string()];
        let value = serde_json::json!({
            "description": "desc",
            "includes": ["foo", "missing", "a"],
            "system_prompt_md": "hello"
        });
        let output = validate_builder_output_value(agent_id, &available, &value).expect("valid");
        assert_eq!(output.includes, vec!["a".to_string()]);
        assert!(output.system_prompt_md.contains("## Requirements"));
    }

    #[test]
    fn validate_builder_output_ignores_unknown_fields() {
        let agent_id = "foo";
        let available = vec!["a".to_string()];
        let value = serde_json::json!({
            "agent_id": "foo",
            "description": "desc",
            "includes": ["a"],
            "system_prompt_md": "hello\n\n## Requirements\n\n- ok\n"
        });
        let output = validate_builder_output_value(agent_id, &available, &value).expect("valid");
        assert_eq!(output.description, "desc");
        assert_eq!(output.includes, vec!["a".to_string()]);
    }
}