use std::time::Duration; use config::ai::AiConfig; use serde::{Deserialize, Serialize}; use thiserror::Error; #[derive(Debug, Error)] pub enum AgentError { #[error("ai is not configured: {0}")] NotConfigured(String), #[error("ai request failed: {0}")] Transport(String), #[error("ai returned status {status}: {body}")] Status { status: u16, body: String }, #[error("ai response could not be parsed: {0}")] Decode(String), #[error("invalid ai task: {0}")] InvalidTask(String), /// 任务本身合法,但执行期间依赖(git / 数据库 / 仓库快照)不可用。 /// /// 与 [`Self::InvalidTask`] 区分开:这类失败应当重试,而不是直接进 DLQ。 #[error("ai task execution failed: {0}")] Execution(String), } pub type AgentResult = Result; impl AgentError { /// 该错误是否值得重投。 /// /// 任务描述本身非法(例如目标仓库已被删除)重试多少次结果都一样,应直接失败; /// 端点抖动与依赖暂不可用则应重试。 #[must_use] pub fn is_retryable(&self) -> bool { matches!( self, Self::Transport(_) | Self::Status { .. } | Self::Execution(_) ) } } #[derive(Debug, Clone, Serialize)] struct ChatRequest { model: String, messages: Vec, temperature: f32, max_tokens: u32, #[serde(skip_serializing_if = "Option::is_none")] response_format: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] struct ChatMessage { role: String, content: String, } #[derive(Debug, Clone, Serialize)] struct ResponseFormat { #[serde(rename = "type")] kind: String, } #[derive(Debug, Clone, Deserialize)] struct ChatResponse { #[serde(default)] choices: Vec, #[serde(default)] usage: Option, } #[derive(Debug, Clone, Deserialize)] struct ChatChoice { message: Option, } #[derive(Debug, Clone, Deserialize)] struct ChatChoiceMessage { #[serde(default)] content: Option, } #[derive(Debug, Clone, Deserialize)] struct ChatUsage { #[serde(default)] prompt_tokens: Option, #[serde(default)] completion_tokens: Option, } /// 一次模型调用的完整产物:正文、用量与原始响应体。 #[derive(Debug, Clone)] pub struct AgentCompletion { pub content: String, pub prompt_tokens: Option, pub completion_tokens: Option, pub raw: serde_json::Value, } /// OpenAI 兼容的单一模型客户端。 /// /// 平台内所有 AI 能力都经由本客户端访问 `[ai].model`,不存在按功能切换模型的入口。 #[derive(Clone)] pub struct AgentClient { http: reqwest::Client, endpoint: String, api_key: String, model: String, temperature: f32, max_output_tokens: u32, max_attempts: i32, } impl AgentClient { pub fn new(cfg: &AiConfig) -> AgentResult { if cfg.base_url.trim().is_empty() || cfg.model.trim().is_empty() { return Err(AgentError::NotConfigured( "base_url and model are required".to_string(), )); } let http = reqwest::Client::builder() .timeout(Duration::from_secs(cfg.request_timeout_secs.max(1))) .build() .map_err(|e| AgentError::Transport(e.to_string()))?; Ok(Self { http, endpoint: cfg.endpoint(), api_key: cfg.api_key.trim().to_string(), model: cfg.model.trim().to_string(), temperature: cfg.temperature, max_output_tokens: cfg.max_output_tokens, max_attempts: cfg.max_attempts.max(1), }) } pub fn model(&self) -> &str { &self.model } /// 发送一次 chat completion 请求,失败时按指数退避重试。 pub async fn complete( &self, system_prompt: &str, user_prompt: &str, json_mode: bool, ) -> AgentResult { let body = ChatRequest { model: self.model.clone(), messages: vec![ ChatMessage { role: "system".to_string(), content: system_prompt.to_string(), }, ChatMessage { role: "user".to_string(), content: user_prompt.to_string(), }, ], temperature: self.temperature, max_tokens: self.max_output_tokens, response_format: json_mode.then(|| ResponseFormat { kind: "json_object".to_string(), }), }; let mut last_error: Option = None; for attempt in 0..self.max_attempts { if attempt > 0 { let backoff = Duration::from_millis(500 * (1u64 << attempt.min(5))); tokio::time::sleep(backoff).await; } match self.send_once(&body).await { Ok(completion) => return Ok(completion), Err(error) => { let retryable = error.is_retryable(); tracing::warn!(%error, attempt = attempt + 1, "ai completion attempt failed"); last_error = Some(error); if !retryable { break; } } } } Err(last_error.unwrap_or_else(|| AgentError::Transport("no attempt made".to_string()))) } async fn send_once(&self, body: &ChatRequest) -> AgentResult { let mut request = self .http .post(&self.endpoint) .header(reqwest::header::CONTENT_TYPE, "application/json") .json(body); if !self.api_key.is_empty() { request = request.bearer_auth(&self.api_key); } let response = request .send() .await .map_err(|e| AgentError::Transport(e.to_string()))?; let status = response.status(); let text = response .text() .await .map_err(|e| AgentError::Transport(e.to_string()))?; if !status.is_success() { return Err(AgentError::Status { status: status.as_u16(), body: truncate(&text, 2048), }); } let parsed: ChatResponse = serde_json::from_str(&text) .map_err(|e| AgentError::Decode(format!("{e}: {}", truncate(&text, 512))))?; let content = parsed .choices .first() .and_then(|choice| choice.message.as_ref()) .and_then(|message| message.content.clone()) .ok_or_else(|| AgentError::Decode("response has no message content".to_string()))?; let raw: serde_json::Value = serde_json::from_str(&text).unwrap_or(serde_json::Value::Null); Ok(AgentCompletion { content, prompt_tokens: parsed.usage.as_ref().and_then(|u| u.prompt_tokens), completion_tokens: parsed.usage.as_ref().and_then(|u| u.completion_tokens), raw, }) } } pub(crate) fn truncate(value: &str, max: usize) -> String { if value.chars().count() <= max { return value.to_string(); } let mut out: String = value.chars().take(max).collect(); out.push_str("…(truncated)"); out } /// 从模型输出中提取 JSON 对象;容忍围栏代码块与前后说明文字。 pub fn extract_json(content: &str) -> AgentResult { let direct = content.trim(); if let Ok(value) = serde_json::from_str::(direct) { return Ok(value); } if let Some(inner) = fenced_json(direct) && let Ok(value) = serde_json::from_str::(&inner) { return Ok(value); } if let (Some(start), Some(end)) = (direct.find('{'), direct.rfind('}')) && start < end && let Ok(value) = serde_json::from_str::(&direct[start..=end]) { return Ok(value); } Err(AgentError::Decode( "model output did not contain JSON".to_string(), )) } fn fenced_json(value: &str) -> Option { let start = value.find("```")?; let rest = &value[start + 3..]; let rest = rest.strip_prefix("json").unwrap_or(rest); let end = rest.find("```")?; Some(rest[..end].trim().to_string()) }