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;
#[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()
}
}
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)
}
fn build_intent_system_prompt(agent_name: &str, persona: &str) -> String {
let mut p = String::new();
if !persona.is_empty() {
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");
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
}
fn parse_intent_response(content: &str, agent_name: &str) -> Result<IntentResponsePayload> {
let trimmed = content.trim();
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 {
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(),
});
};
#[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(),
})
}
}
}
#[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); }
#[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"));
}
}