use std::time::Duration;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ProviderRoute {
#[default]
Unknown,
OpenAiResponses,
OpenAiChatGptResponses,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum UpstreamErrorKind {
ServiceUnavailableError,
ServerIsOverloaded,
ServerError,
RateLimitError,
RateLimitExceeded,
TemporarilyUnavailable,
AuthenticationError,
InvalidApiKey,
InvalidAuthentication,
Unauthorized,
InvalidRequestError,
PermissionDenied,
InsufficientQuota,
ContentPolicyViolation,
ContextLengthExceeded,
#[default]
#[serde(other)]
Unknown,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProviderClassification {
pub error_type: UpstreamErrorKind,
pub code: UpstreamErrorKind,
pub http_status: Option<u16>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ProviderFailureReason {
HttpStatus,
Transport,
ResponseFailed,
Protocol,
InvalidRequest,
Authentication,
AttemptTimeout,
IdleTimeout,
RetryExhausted,
RetryBudget,
RetryDisabled,
ReplayUnsafe,
Cancelled,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RetryAccounting {
pub attempts: u64,
pub completed_backoff: Duration,
pub elapsed: Duration,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RetryProgress {
pub route: ProviderRoute,
pub reason: ProviderFailureReason,
pub upstream: ProviderClassification,
pub accounting: RetryAccounting,
pub next_delay: Duration,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
#[error("provider request failed ({reason:?}; attempts: {attempts})", attempts = .accounting.attempts)]
pub struct ProviderFailure {
pub route: ProviderRoute,
pub reason: ProviderFailureReason,
pub last_attempt_reason: Option<ProviderFailureReason>,
pub upstream: ProviderClassification,
pub accounting: RetryAccounting,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum ProviderRetryEvent {
Scheduled(RetryProgress),
Stopped(ProviderFailure),
Succeeded {
route: ProviderRoute,
accounting: RetryAccounting,
},
}
pub trait RetryObserver: Send + Sync {
fn on_retry_event(&self, event: ProviderRetryEvent);
}
impl<F: Fn(ProviderRetryEvent) + Send + Sync> RetryObserver for F {
fn on_retry_event(&self, event: ProviderRetryEvent) {
self(event);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::AgentEvent;
#[test]
fn context_length_kind_uses_stable_wire_name() {
let kind = UpstreamErrorKind::ContextLengthExceeded;
assert_eq!(
serde_json::to_string(&kind).unwrap(),
"\"context_length_exceeded\""
);
assert_eq!(
serde_json::from_str::<UpstreamErrorKind>("\"context_length_exceeded\"").unwrap(),
kind
);
}
#[test]
fn event_roundtrip_and_legacy_shape_remain_compatible() {
let old = r#"{"RunStarted":{"session_id":"session"}}"#;
let event: AgentEvent = serde_json::from_str(old).unwrap();
assert_eq!(serde_json::to_string(&event).unwrap(), old);
let current = AgentEvent::ProviderRetry(ProviderRetryEvent::Stopped(ProviderFailure {
route: ProviderRoute::OpenAiChatGptResponses,
reason: ProviderFailureReason::RetryExhausted,
last_attempt_reason: Some(ProviderFailureReason::ResponseFailed),
upstream: ProviderClassification {
error_type: UpstreamErrorKind::ServiceUnavailableError,
code: UpstreamErrorKind::ServerIsOverloaded,
http_status: None,
},
accounting: RetryAccounting {
attempts: 3,
completed_backoff: Duration::from_millis(125),
elapsed: Duration::from_millis(250),
},
}));
let encoded = serde_json::to_string(¤t).unwrap();
assert_eq!(
serde_json::from_str::<AgentEvent>(&encoded).unwrap(),
current
);
assert!(encoded.contains("service_unavailable_error"));
assert!(encoded.contains("server_is_overloaded"));
assert!(serde_json::from_str::<AgentEvent>(r#"{"ProviderRetry":{"Stopped":{}}}"#).is_err());
assert_eq!(
serde_json::from_str::<UpstreamErrorKind>(r#""future-private-value""#).unwrap(),
UpstreamErrorKind::Unknown
);
}
}
#[cfg(test)]
mod cancellation_hook_tests {
use crate::{
Agent, LoopError, ModelAdapter, ModelSession, ModelTurn, ModelTurnEvent, SessionConfig,
TurnRequest,
};
use agentkit_core::{CancellationController, Item, ItemKind, TurnCancellation, Usage};
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
#[derive(Clone)]
struct CancellingModel {
controller: Arc<CancellationController>,
notified: Arc<AtomicUsize>,
}
#[async_trait::async_trait]
impl ModelAdapter for CancellingModel {
type Session = Self;
async fn start_session(&self, _: SessionConfig) -> Result<Self, LoopError> {
Ok(self.clone())
}
}
#[async_trait::async_trait]
impl ModelSession for CancellingModel {
type Turn = Self;
async fn begin_turn(
&mut self,
_: TurnRequest,
_: Option<TurnCancellation>,
) -> Result<Self, LoopError> {
Ok(self.clone())
}
}
#[async_trait::async_trait]
impl ModelTurn for CancellingModel {
fn on_cancelled(&mut self) {
self.notified.fetch_add(1, Ordering::SeqCst);
}
async fn next_event(
&mut self,
_: Option<TurnCancellation>,
) -> Result<Option<ModelTurnEvent>, LoopError> {
self.controller.interrupt();
Ok(Some(ModelTurnEvent::Usage(Usage::default())))
}
}
#[tokio::test]
async fn driver_notifies_before_dropping_a_turn_cancelled_between_events() {
let controller = Arc::new(CancellationController::new());
let notified = Arc::new(AtomicUsize::new(0));
let agent = Agent::builder()
.model(CancellingModel {
controller: controller.clone(),
notified: notified.clone(),
})
.cancellation(controller.handle())
.input(vec![Item::text(ItemKind::User, "hello")])
.build()
.unwrap();
let mut driver = agent.start(SessionConfig::new("session")).await.unwrap();
driver.next().await.unwrap();
assert_eq!(notified.load(Ordering::SeqCst), 1);
}
}