use anyhow::{Context, Result};
use clawgarden_proto::MessagePayload;
use rand::Rng;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::time::Duration;
const LLM_TIMEOUT_MS: u64 = 30_000;
const ZAI_API_BASE: &str = "https://api.z.ai/api/coding/paas/v4";
const DEFAULT_MODEL: &str = "glm-5-turbo";
#[derive(Debug, Serialize)]
struct SerChatMessage {
role: String,
content: String,
}
#[derive(Debug, Serialize)]
struct ChatRequest {
model: String,
messages: Vec<SerChatMessage>,
max_tokens: u32,
temperature: f32,
}
#[derive(Debug, Deserialize)]
struct ChatMessage {
role: String,
content: String,
#[serde(default)]
reasoning_content: Option<String>,
}
impl ChatMessage {
fn get_response(&self) -> String {
if !self.content.is_empty() {
self.content.clone()
} else if let Some(ref reasoning) = self.reasoning_content {
reasoning.trim().to_string()
} else {
String::new()
}
}
}
#[derive(Debug, Deserialize)]
struct ChatResponse {
choices: Vec<ChatChoice>,
}
#[derive(Debug, Deserialize)]
struct ChatChoice {
message: ChatMessage,
}
pub async fn judge_and_respond(
agent_name: &str,
persona: &str,
memory: &str,
message: &str,
history: &[String],
force: bool,
) -> Result<Option<MessagePayload>> {
let api_key = std::env::var("ZAI_API_KEY")
.or_else(|_| std::env::var("Z_AI_API_KEY"))
.context("ZAI_API_KEY not set")?;
let jitter = rand::thread_rng().gen_range(0..2500u64);
tokio::time::sleep(Duration::from_millis(jitter)).await;
let system = build_system_prompt(agent_name, persona, memory, force);
let user_msg = build_user_message(message, history);
let request = ChatRequest {
model: DEFAULT_MODEL.to_string(),
messages: vec![
SerChatMessage { role: "system".into(), content: system },
SerChatMessage { role: "user".into(), content: user_msg },
],
max_tokens: 512,
temperature: 0.8, };
let client = Client::builder()
.timeout(Duration::from_millis(LLM_TIMEOUT_MS))
.build()?;
let url = format!("{}/chat/completions", ZAI_API_BASE);
let mut attempts = 0;
let max_attempts = 3;
let chat = loop {
attempts += 1;
let resp = client
.post(&url)
.header("Authorization", format!("Bearer {}", api_key))
.header("Content-Type", "application/json")
.json(&request)
.send()
.await;
match resp {
Ok(r) if r.status().is_success() => {
break r.json::<ChatResponse>().await.context("LLM 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!("LLM {} (attempt {}/{}), retrying in {}ms",
status, attempts, max_attempts, attempts * 1000);
tokio::time::sleep(Duration::from_millis(attempts as u64 * 1000)).await;
continue;
}
anyhow::bail!("LLM error {}: {}", status, body);
}
Err(e) => anyhow::bail!("LLM request failed: {}", e),
}
};
let content = chat
.choices
.first()
.map(|c| c.message.get_response())
.filter(|s| !s.is_empty())
.unwrap_or_default();
let trimmed = content.trim();
if is_silence(trimmed) {
log::info!("LLM: staying silent ({})", agent_name);
return Ok(None);
}
log::info!("LLM: responding ({}, {} chars)", agent_name, content.len());
Ok(Some(MessagePayload {
content,
context: vec![],
}))
}
fn is_silence(s: &str) -> bool {
let lower = s.to_lowercase();
lower == "no_response"
|| lower == "..."
|| lower == "(silence)"
|| lower == "(no response)"
|| lower == "pass"
|| lower.starts_with("no_response")
|| lower.starts_with("no response")
|| (lower.len() < 80 && lower.contains("won't respond") && !lower.contains("but"))
|| (lower.len() < 80 && lower.contains("should not respond") && !lower.contains("but"))
|| (lower.len() < 80 && lower.contains("nothing to add"))
|| (lower.len() < 80 && lower.contains("stay silent"))
|| (lower.len() < 80 && lower.contains("i'll pass"))
}
fn build_system_prompt(agent_name: &str, persona: &str, memory: &str, force: bool) -> String {
let mut p = String::new();
if !persona.is_empty() {
p.push_str(persona);
p.push_str("\n\n");
} else {
p.push_str(&format!("You are {}.\n\n", agent_name));
}
if let Ok(username) = std::env::var("TELEGRAM_BOT_USERNAME") {
p.push_str(&format!("Your Telegram username: @{}\n", username));
}
if let Ok(members) = std::env::var("TEAM_MEMBERS") {
if !members.is_empty() {
p.push_str("\nGroup members:\n");
for entry in members.split(',') {
p.push_str(&format!("- {}\n", entry.trim()));
}
p.push('\n');
}
}
if !memory.is_empty() {
p.push_str("[Things you remember]\n");
p.push_str(memory);
p.push_str("\n\n");
}
p.push_str(
r#"너는 그룹 채팅방에 있는 AI 멤버야. 사용자와 다른 AI 멤버의 메시지를 볼 수 있어.
그룹 채팅 규칙:
1. 사용자가 방에 메시지를 보내면, 너의 역할과 관련이 있는지 판단해.
- 인사, 질문, 대화 시도 → 반드시 짧게라도 응답해. 무시하면 안 돼.
- 너의 전문 영역과 관련된 주제 → 응답해.
- 너의 이름을 부르거나 너에게 직접 말하면 → 응답해.
2. 응답하지 않아도 되는 경우:
- 누군가 이미 좋은 답변을 줬을 때
- 다른 사람들끼리 대화 중이고 네가 끼어들 이유가 없을 때
- 단순 확인 ("ㅇㅇ", "ok", "그래") 같은 내용일 때
3. 응답할 때:
- 1~3문장으로 간결하게
- 상대방이 쓰는 언어로 답해
- 자연스럽게, 사람처럼 말해
- 네 이름을 앞에 붙이지 마
4. 응답하지 않겠다고 결정하면 정확히 이렇게만 답해: NO_RESPONSE
다른 설명은 필요 없어.
그룹 멤버:
\
- 사용자 (그룹의 인간 멤버)
\
- 각 AI 멤버는 자기 이름과 역할이 있어. 대화에서 다른 멤버의 이름이 보이면 그 사람이 이 그룹에 있는 거야.
\
\
멘션 규칙:
\
- 특정 멤버에게 요청할 때는 @username으로 불러. 예: "@claw_camus_bot, 이 부분 기술 검토 좀 해줘"
\
- 사용자의 결정이 필요할 때도 명시적으로 물어봐.
\
- @는 생략해도 되지만 username이 들어가야 상대방이 인식해.
\
\
기억: 모든 메시지에 대답하는 것도 이상하지만, 인사나 질문을 무시하는 건 더 이상해."#,
);
if force {
p.push_str(
"\n\n중요: 다른 멤버가 이 메시지에 응답하지 않았어. \n 네가 짧게라도 답을 해줘. 인사면 인사로, 질문이면 답변으로. \n NO_RESPONSE는 절대 하지 마."
);
}
p
}
fn build_user_message(message: &str, history: &[String]) -> String {
let mut out = String::new();
if !history.is_empty() {
out.push_str("Recent conversation:\n");
let start = history.len().saturating_sub(10);
for line in history.iter().skip(start) {
out.push_str(line);
out.push('\n');
}
out.push('\n');
}
out.push_str("Current message:\n");
out.push_str(message);
out.push_str("\n\nShould you respond? If yes, write your response. If no, write: NO_RESPONSE");
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_silence_detection() {
assert!(is_silence("NO_RESPONSE"));
assert!(is_silence("no_response"));
assert!(is_silence("No response"));
assert!(is_silence("..."));
assert!(is_silence("pass"));
assert!(is_silence("I'll pass"));
assert!(is_silence("I have nothing to add here"));
assert!(is_silence("(silence)"));
assert!(!is_silence("그래, 알겠습니다."));
assert!(!is_silence("I think the best approach is..."));
}
#[test]
fn test_build_user_message() {
let msg = "[사용자]: 안녕하세요";
let history = vec![
"[camus]: 반갑습니다".to_string(),
"[eleven]: 환영합니다".to_string(),
];
let result = build_user_message(msg, &history);
assert!(result.contains("Recent conversation"));
assert!(result.contains("[camus]"));
assert!(result.contains("[사용자]"));
}
#[test]
fn test_system_prompt_has_rules() {
let p = build_system_prompt("eleven", "", "", false);
assert!(p.contains("그룹 채팅 규칙"));
assert!(p.contains("NO_RESPONSE"));
}
#[test]
fn test_force_prompt() {
let p = build_system_prompt("eleven", "", "", true);
assert!(p.contains("다른 멤버가 이 메시지에 응답하지 않았어"));
let p2 = build_system_prompt("eleven", "", "", false);
assert!(!p2.contains("다른 멤버가"));
}
}