clawgarden-agent 0.9.9

Agent runtime with persona/memory loader, judge, and pi RPC for ClawGarden
Documentation
//! Intent Generator — BIMR Step 1+2: Agent decides whether to respond.
//!
//! Uses the light model (e.g., glm-4.5-air) to generate a cheap,
//! structured intent JSON. Output is ~40 tokens, not a full response.

use anyhow::{Context, Result};
use clawgarden_proto::IntentResponsePayload;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::time::Duration;

use crate::pi_rpc::light_model_config;

// ── OpenAI-compatible chat types (lightweight) ─────────────────────────────

#[derive(Debug, Serialize)]
struct ChatRequest {
    model: String,
    messages: Vec<SerMessage>,
    max_tokens: u32,
    temperature: f32,
}

#[derive(Debug, Serialize)]
struct SerMessage {
    role: String,
    content: String,
}

#[derive(Debug, Deserialize)]
struct ChatResponse {
    choices: Vec<ChatChoice>,
}

#[derive(Debug, Deserialize)]
struct ChatChoice {
    message: ChoiceMessage,
}

#[derive(Debug, Deserialize)]
struct ChoiceMessage {
    #[serde(default)]
    content: Option<String>,
    #[serde(default)]
    reasoning_content: Option<String>,
}

impl ChoiceMessage {
    fn get_text(&self) -> String {
        if let Some(ref c) = self.content {
            if !c.is_empty() {
                return c.clone();
            }
        }
        if let Some(ref r) = self.reasoning_content {
            return r.trim().to_string();
        }
        String::new()
    }
}

// ── Public API ─────────────────────────────────────────────────────────────

/// Generate an intent for the current conversation turn.
///
/// Uses the light model to produce a structured JSON:
/// `{"want": true, "priority": 3, "reason": "...", "angle": "..."}`
///
/// On any failure, returns a default "pass" intent so the BIMR flow
/// is never blocked by a single agent's light model error.
pub async fn generate_intent(
    agent_name: &str,
    persona: &str,
    message: &str,
    history: &[String],
    turn_number: u32,
    last_speaker: Option<&str>,
) -> IntentResponsePayload {
    match generate_intent_inner(agent_name, persona, message, history, turn_number, last_speaker).await {
        Ok(intent) => intent,
        Err(e) => {
            log::warn!(
                "Intent generation failed for {}: {} — defaulting to pass",
                agent_name,
                e
            );
            IntentResponsePayload {
                want: true,
                priority: 3,
                reason: format!("fallback: light model unavailable ({})", e),
                angle: None,
                agent_name: agent_name.to_string(),
            }
        }
    }
}

async fn generate_intent_inner(
    agent_name: &str,
    persona: &str,
    message: &str,
    history: &[String],
    turn_number: u32,
    last_speaker: Option<&str>,
) -> Result<IntentResponsePayload> {
    let cfg = light_model_config()?;

    let system = build_intent_system_prompt(agent_name, persona);
    let user = build_intent_user_message(message, history, turn_number, last_speaker);

    let client = Client::builder()
        .timeout(Duration::from_millis(cfg.timeout_ms))
        .build()?;

    let url = format!("{}/chat/completions", cfg.api_base);

    let request = ChatRequest {
        model: cfg.model.clone(),
        messages: vec![
            SerMessage {
                role: "system".into(),
                content: system,
            },
            SerMessage {
                role: "user".into(),
                content: user,
            },
        ],
        max_tokens: cfg.max_tokens,
        temperature: cfg.temperature,
    };

    let mut attempts = 0u32;
    let max_attempts = 3u32;

    let response = loop {
        attempts += 1;
        let r = client
            .post(&url)
            .header("Authorization", format!("Bearer {}", cfg.api_key))
            .header("Content-Type", "application/json")
            .json(&request)
            .send()
            .await;

        match r {
            Ok(r) if r.status().is_success() => {
                break r.json::<ChatResponse>().await.context("Light model parse failed")?;
            }
            Ok(r) => {
                let status = r.status();
                let body = r.text().await.unwrap_or_default();
                if (status.as_u16() == 429 || status.is_server_error()) && attempts < max_attempts {
                    log::warn!(
                        "Light model {} (attempt {}/{}), retrying",
                        status,
                        attempts,
                        max_attempts
                    );
                    tokio::time::sleep(Duration::from_millis(attempts as u64 * 500)).await;
                    continue;
                }
                anyhow::bail!("Light model error {}: {}", status, body);
            }
            Err(e) => anyhow::bail!("Light model request failed: {}", e),
        }
    };

    let content = response
        .choices
        .first()
        .map(|c| c.message.get_text())
        .unwrap_or_default();

    parse_intent_response(&content, agent_name)
}

// ── Prompt Engineering ─────────────────────────────────────────────────────

fn build_intent_system_prompt(agent_name: &str, persona: &str) -> String {
    let mut p = String::new();

    if !persona.is_empty() {
        // Truncate persona to keep prompt short for light model
        let persona_preview: String = persona.chars().take(500).collect();
        p.push_str(&persona_preview);
        p.push_str("\n\n");
    } else {
        p.push_str(&format!("You are {}.\n\n", agent_name));
    }

    p.push_str(
        r#"You are an agent in a multi-agent team. Your task in THIS call is NOT to respond — only to decide whether you WANT to respond.

Reply with JSON only:
{"want": true/false, "priority": 1-5, "reason": "brief explanation", "angle": "one-line summary of your intended direction"}

Priority guide:
5 = This is EXACTLY my expertise, I have critical new info
4 = Strongly relevant to my role, I can add significant value
3 = Somewhat relevant, I could contribute
2 = Tangentially related, I might have something useful
1 = Not really my area, but I could respond if needed

Be honest. If someone else is better suited, set want=false."#,
    );

    p
}

fn build_intent_user_message(
    message: &str,
    history: &[String],
    turn_number: u32,
    last_speaker: Option<&str>,
) -> String {
    let mut out = String::new();

    if !history.is_empty() {
        out.push_str("Recent conversation:\n");
        // Limit history for light model — keep it short
        for line in history.iter().take(10) {
            out.push_str(line);
            out.push('\n');
        }
        out.push('\n');
    }

    out.push_str("New message:\n");
    out.push_str(message);
    out.push('\n');

    if let Some(speaker) = last_speaker {
        out.push_str(&format!(
            "\nLast speaker: {} (avoid repeat)\n",
            speaker
        ));
    }
    out.push_str(&format!("\nTurn: {}/3\n", turn_number));
    out.push_str("\nYour intent (JSON only):");

    out
}

// ── Response Parsing ───────────────────────────────────────────────────────

fn parse_intent_response(content: &str, agent_name: &str) -> Result<IntentResponsePayload> {
    let trimmed = content.trim();

    // Try to extract JSON from the response
    let json_str = if trimmed.starts_with('{') {
        trimmed.to_string()
    } else if let Some(start) = trimmed.find('{') {
        if let Some(end) = trimmed.rfind('}') {
            trimmed[start..=end].to_string()
        } else {
            trimmed.to_string()
        }
    } else {
        // No JSON found — default to pass
        log::info!(
            "Intent: model returned non-JSON for {}, defaulting to pass",
            agent_name
        );
        return Ok(IntentResponsePayload {
            want: false,
            priority: 0,
            reason: "non-JSON response".into(),
            angle: None,
            agent_name: agent_name.to_string(),
        });
    };

    // Parse the JSON
    #[derive(Deserialize)]
    struct RawIntent {
        #[serde(default)]
        want: Option<bool>,
        #[serde(default)]
        priority: Option<u8>,
        #[serde(default)]
        reason: Option<String>,
        #[serde(default)]
        angle: Option<String>,
    }

    match serde_json::from_str::<RawIntent>(&json_str) {
        Ok(raw) => Ok(IntentResponsePayload {
            want: raw.want.unwrap_or(false),
            priority: raw.priority.unwrap_or(1).min(5),
            reason: raw.reason.unwrap_or_default(),
            angle: raw.angle,
            agent_name: agent_name.to_string(),
        }),
        Err(e) => {
            log::warn!("Intent parse error for {}: {} — raw: {}", agent_name, e, json_str);
            Ok(IntentResponsePayload {
                want: false,
                priority: 0,
                reason: format!("parse error: {}", e),
                angle: None,
                agent_name: agent_name.to_string(),
            })
        }
    }
}

// ── Tests ──────────────────────────────────────────────────────────────────

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

    #[test]
    fn test_parse_intent_response_valid() {
        let json = r#"{"want": true, "priority": 4, "reason": "tech stack", "angle": "recommend Rust"}"#;
        let intent = parse_intent_response(json, "alex").unwrap();
        assert!(intent.want);
        assert_eq!(intent.priority, 4);
        assert_eq!(intent.reason, "tech stack");
        assert_eq!(intent.angle.as_deref(), Some("recommend Rust"));
    }

    #[test]
    fn test_parse_intent_response_pass() {
        let json = r#"{"want": false, "priority": 1, "reason": "not my area"}"#;
        let intent = parse_intent_response(json, "senya").unwrap();
        assert!(!intent.want);
        assert_eq!(intent.priority, 1);
    }

    #[test]
    fn test_parse_intent_response_wrapped_in_markdown() {
        let content = "```json\n{\"want\": true, \"priority\": 3, \"reason\": \"can help\"}\n```";
        let intent = parse_intent_response(content, "camus").unwrap();
        assert!(intent.want);
        assert_eq!(intent.priority, 3);
    }

    #[test]
    fn test_parse_intent_response_no_json() {
        let content = "I think someone else should respond.";
        let intent = parse_intent_response(content, "alex").unwrap();
        assert!(!intent.want);
    }

    #[test]
    fn test_parse_intent_response_priority_clamped() {
        let json = r#"{"want": true, "priority": 10, "reason": "very important"}"#;
        let intent = parse_intent_response(json, "alex").unwrap();
        assert_eq!(intent.priority, 5); // clamped to max 5
    }

    #[test]
    fn test_build_intent_system_prompt() {
        let p = build_intent_system_prompt("alex", "You are a developer.");
        assert!(p.contains("developer"));
        assert!(p.contains("JSON"));
    }

    #[test]
    fn test_build_intent_system_prompt_no_persona() {
        let p = build_intent_system_prompt("alex", "");
        assert!(p.contains("alex"));
        assert!(p.contains("JSON"));
    }

    #[test]
    fn test_build_intent_user_message() {
        let msg = build_intent_user_message(
            "Hello",
            &["[senya]: Hi".to_string()],
            1,
            Some("senya"),
        );
        assert!(msg.contains("Recent conversation"));
        assert!(msg.contains("[senya]"));
        assert!(msg.contains("Hello"));
        assert!(msg.contains("Last speaker: senya"));
        assert!(msg.contains("Turn: 1/3"));
    }

    #[test]
    fn test_build_intent_user_message_empty_history() {
        let msg = build_intent_user_message("Hello", &[], 2, None);
        assert!(!msg.contains("Recent conversation"));
        assert!(msg.contains("New message"));
        assert!(msg.contains("Turn: 2/3"));
    }
}