use crate::agent::{Agent, AgentId};
use crate::message::{Message, MessageKind};
use crate::persona::Persona;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RetryPolicy {
pub max_attempts: u32,
pub initial_backoff_ms: u64,
pub backoff_factor: f64,
pub max_backoff_ms: u64,
pub timeout_secs: u64,
}
impl RetryPolicy {
pub const DEFAULT: Self = Self {
max_attempts: 3,
initial_backoff_ms: 500,
backoff_factor: 2.0,
max_backoff_ms: 8000,
timeout_secs: 60,
};
pub const NONE: Self = Self {
max_attempts: 1,
..Self::DEFAULT
};
#[must_use]
pub const fn with_max_attempts(mut self, n: u32) -> Self {
self.max_attempts = n;
self
}
#[must_use]
pub const fn with_timeout_secs(mut self, secs: u64) -> Self {
self.timeout_secs = secs;
self
}
#[must_use]
pub const fn with_initial_backoff_ms(mut self, ms: u64) -> Self {
self.initial_backoff_ms = ms;
self
}
#[must_use]
pub const fn with_backoff_factor(mut self, f: f64) -> Self {
self.backoff_factor = f;
self
}
#[must_use]
pub const fn with_max_backoff_ms(mut self, ms: u64) -> Self {
self.max_backoff_ms = ms;
self
}
pub fn resolve(
retry_config: &crate::backend::credentials::RetryConfig,
cli_max_attempts: Option<u32>,
cli_timeout_secs: Option<u64>,
) -> Self {
let mut p = Self::DEFAULT;
if let Some(n) = retry_config.max_attempts {
p = p.with_max_attempts(n);
}
if let Some(secs) = retry_config.timeout_secs {
p = p.with_timeout_secs(secs);
}
if let Some(ms) = retry_config.initial_backoff_ms {
p = p.with_initial_backoff_ms(ms);
}
if let Some(f) = retry_config.backoff_factor {
p = p.with_backoff_factor(f);
}
if let Some(ms) = retry_config.max_backoff_ms {
p = p.with_max_backoff_ms(ms);
}
if let Some(n) = cli_max_attempts {
p = p.with_max_attempts(n);
}
if let Some(secs) = cli_timeout_secs {
p = p.with_timeout_secs(secs);
}
p
}
}
pub fn should_retry_status(status: u16) -> bool {
status == 408 || status == 429 || (500..=599).contains(&status)
}
pub fn backoff_delay(policy: &RetryPolicy, attempt: u32, jitter_ms: u64) -> std::time::Duration {
let exp = (attempt as u64).saturating_sub(1);
let raw = (policy.initial_backoff_ms as f64) * policy.backoff_factor.powi(exp as i32);
let capped = raw.min(policy.max_backoff_ms as f64) as u64;
let jitter = if jitter_ms == 0 {
0
} else {
use rand::Rng;
rand::rng().random_range(0..jitter_ms)
};
std::time::Duration::from_millis(capped + jitter)
}
const BACKOFF_JITTER_MS: u64 = 250;
pub fn validate_provider(
config: &HttpConfig,
policy: &RetryPolicy,
) -> Result<(), crate::ProserpinaError> {
let url = format!("{}/models", config.base_url.trim_end_matches('/'));
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| {
crate::ProserpinaError::agent_failure(
format!("validate ({})", config.model),
format!("runtime build: {e}"),
)
})?;
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(policy.timeout_secs))
.build()
.map_err(|e| {
crate::ProserpinaError::agent_failure(
format!("validate ({})", config.model),
format!("client build: {e}"),
)
})?;
let label = format!("validate ({}/{})", config.base_url, config.model);
runtime.block_on(async {
let resp = client
.get(&url)
.bearer_auth(&config.api_key)
.send()
.await
.map_err(|e| {
crate::ProserpinaError::agent_failure(&label, format!("HTTP send: {e}"))
})?;
let status = resp.status();
if status.is_success() {
Ok(())
} else {
let body = resp.text().await.unwrap_or_default();
Err(crate::ProserpinaError::agent_failure(
&label,
format!("HTTP {status}: {body}"),
))
}
})
}
pub async fn send_chat_completion(
client: &reqwest::Client,
url: &str,
api_key: &str,
body: &serde_json::Value,
policy: &RetryPolicy,
label: &str,
) -> Result<String, crate::ProserpinaError> {
let max = policy.max_attempts;
let mut last_err: Option<crate::ProserpinaError> = None;
for attempt in 1..=max {
let result = client
.post(url)
.bearer_auth(api_key)
.json(body)
.send()
.await;
match result {
Ok(resp) => {
let status = resp.status();
let status_code = status.as_u16();
let text = resp.text().await.map_err(|e| {
crate::ProserpinaError::agent_failure(label, format!("HTTP body: {e}"))
})?;
if status.is_success() {
return Ok(text);
}
let err = crate::ProserpinaError::agent_failure(
label,
format!("HTTP {status_code}: {text}"),
);
if !should_retry_status(status_code) || attempt == max {
return Err(err);
}
last_err = Some(err);
}
Err(e) => {
let err = crate::ProserpinaError::agent_failure(label, format!("HTTP send: {e}"));
if attempt == max {
return Err(err);
}
last_err = Some(err);
}
}
let delay = backoff_delay(policy, attempt, BACKOFF_JITTER_MS);
eprintln!(
"proserpina: {label} attempt {attempt}/{max} failed, retrying in {}ms",
delay.as_millis()
);
tokio::time::sleep(delay).await;
}
Err(last_err.unwrap_or_else(|| {
crate::ProserpinaError::agent_failure(label, "retry loop exited without an error")
}))
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ChatMessage {
pub role: String,
pub content: String,
}
fn expected_reply_kind(incoming: MessageKind) -> MessageKind {
match incoming {
MessageKind::Prompt => MessageKind::Critique,
MessageKind::Critique => MessageKind::Rebuttal,
other => other,
}
}
pub fn render_prompt(persona: &Persona, incoming: &Message) -> Vec<ChatMessage> {
let mut system = format!(
"You are {}, a critic on a peer-review panel.",
persona.name()
);
if let Some(framing) = persona.framing() {
system.push_str(&format!(" Framing: {framing}."));
}
if let Some(focus) = persona.focus() {
system.push_str(&format!(" Focus: {focus}."));
}
let reply_kind = expected_reply_kind(incoming.kind());
let user = format!(
"From {}: [{}] {}\n\nRespond with kind: {}.",
incoming.sender(),
incoming.kind().label(),
incoming.text(),
reply_kind.label(),
);
vec![
ChatMessage {
role: "system".to_owned(),
content: system,
},
ChatMessage {
role: "user".to_owned(),
content: user,
},
]
}
pub fn parse_completion_response(
body: &str,
author: AgentId,
kind: MessageKind,
) -> Result<Message, crate::error::ProserpinaError> {
let parsed: serde_json::Value = serde_json::from_str(body).map_err(|e| {
crate::ProserpinaError::agent_failure(
author.as_str(),
format!("invalid JSON response: {e}"),
)
})?;
let content = parsed
.get("choices")
.and_then(|c| c.get(0))
.and_then(|c| c.get("message"))
.and_then(|m| m.get("content"))
.and_then(|c| c.as_str())
.ok_or_else(|| {
crate::ProserpinaError::agent_failure(
author.as_str(),
"response had no choices[0].message.content",
)
})?;
Ok(Message::new(author, None, kind, content.to_owned()))
}
#[derive(Debug, Clone)]
pub struct HttpConfig {
pub base_url: String,
pub model: String,
pub api_key: String,
}
fn build_request_body(model: &str, messages: &[ChatMessage]) -> serde_json::Value {
serde_json::json!({
"model": model,
"messages": messages,
})
}
pub struct HttpAgent {
id: AgentId,
persona: Persona,
config: HttpConfig,
runtime: tokio::runtime::Runtime,
client: reqwest::Client,
policy: RetryPolicy,
}
impl HttpAgent {
pub fn new(id: AgentId, persona: Persona, config: HttpConfig) -> Self {
Self::new_with_policy(id, persona, config, RetryPolicy::DEFAULT)
}
pub fn new_with_policy(
id: AgentId,
persona: Persona,
config: HttpConfig,
policy: RetryPolicy,
) -> Self {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(policy.timeout_secs))
.build()
.expect("proserpina: failed to build reqwest client for HttpAgent");
Self {
id,
persona,
config,
runtime: tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("proserpina: failed to build tokio runtime for HttpAgent"),
client,
policy,
}
}
fn failure_label(&self) -> String {
format!("{} ({})", self.id.as_str(), self.config.model)
}
async fn fetch_response(&self, incoming: &Message) -> Result<String, crate::ProserpinaError> {
let messages = render_prompt(&self.persona, incoming);
let body = build_request_body(&self.config.model, &messages);
let url = format!(
"{}/chat/completions",
self.config.base_url.trim_end_matches('/')
);
let label = self.failure_label();
send_chat_completion(
&self.client,
&url,
&self.config.api_key,
&body,
&self.policy,
&label,
)
.await
}
}
impl Agent for HttpAgent {
fn id(&self) -> &AgentId {
&self.id
}
fn persona(&self) -> &Persona {
&self.persona
}
fn respond(&mut self, incoming: &Message) -> Result<Message, crate::ProserpinaError> {
let body = self.runtime.block_on(self.fetch_response(incoming))?;
let kind = expected_reply_kind(incoming.kind());
let author = self.id.clone();
let mut reply = parse_completion_response(&body, author, kind)?;
reply = Message::new(
self.id.clone(),
Some(incoming.sender().clone()),
reply.kind(),
reply.text().to_owned(),
);
Ok(reply)
}
}