use async_trait::async_trait;
use std::collections::VecDeque;
use std::sync::{Arc, Mutex, MutexGuard};
use zeroclaw_api::attribution::{Attributable, ModelProviderKind, ProviderKind, Role};
use zeroclaw_api::model_provider::{
ChatRequest, ChatResponse, ModelProvider, TokenUsage, ToolCall,
};
use crate::case::{LlmTrace, TraceResponse};
struct ReplayState {
turns: Vec<VecDeque<TraceResponse>>,
current: usize,
}
fn lock_recover<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
match mutex.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
pub struct TraceLlmProvider {
state: Arc<Mutex<ReplayState>>,
trace_name: String,
}
impl TraceLlmProvider {
pub fn from_trace(trace: &LlmTrace) -> Self {
let turns = trace
.turns
.iter()
.map(|turn| turn.steps.iter().map(|s| s.response.clone()).collect())
.collect();
Self {
state: Arc::new(Mutex::new(ReplayState { turns, current: 0 })),
trace_name: trace.model_name.clone(),
}
}
pub fn handle(&self) -> ReplayHandle {
ReplayHandle {
state: Arc::clone(&self.state),
trace_name: self.trace_name.clone(),
}
}
}
pub struct ReplayHandle {
state: Arc<Mutex<ReplayState>>,
trace_name: String,
}
impl ReplayHandle {
pub fn finish_turn(&self, turn_index: usize) -> anyhow::Result<()> {
let mut state = lock_recover(&self.state);
let leftover = state.turns.get(state.current).map_or(0, |q| q.len());
if leftover > 0 {
anyhow::bail!(
"TraceLlmProvider({}): turn {turn_index} scripted {leftover} step(s) the agent never requested — the trace over-specifies this turn's LLM round-trips",
self.trace_name
);
}
state.current += 1;
Ok(())
}
}
impl Attributable for TraceLlmProvider {
fn role(&self) -> Role {
Role::Provider(ProviderKind::Model(ModelProviderKind::Custom))
}
fn alias(&self) -> &str {
"eval-replay"
}
}
#[async_trait]
impl ModelProvider for TraceLlmProvider {
async fn chat_with_system(
&self,
_system_prompt: Option<&str>,
_message: &str,
_model: &str,
_temperature: Option<f64>,
) -> anyhow::Result<String> {
Ok(String::new())
}
async fn chat(
&self,
_request: ChatRequest<'_>,
_model: &str,
_temperature: Option<f64>,
) -> anyhow::Result<ChatResponse> {
let step = {
let mut state = lock_recover(&self.state);
let current = state.current;
match state.turns.get_mut(current).and_then(|q| q.pop_front()) {
Some(step) => step,
None => anyhow::bail!(
"TraceLlmProvider({}): turn {current} requested more LLM responses than the trace provides for that turn",
self.trace_name
),
}
};
match step {
TraceResponse::Text {
content,
input_tokens,
output_tokens,
} => Ok(ChatResponse {
text: Some(content),
tool_calls: vec![],
usage: Some(TokenUsage {
input_tokens: Some(input_tokens),
output_tokens: Some(output_tokens),
cached_input_tokens: None,
cache_creation_input_tokens: None,
}),
reasoning_content: None,
}),
TraceResponse::ToolCalls {
tool_calls,
input_tokens,
output_tokens,
} => {
let calls = tool_calls
.into_iter()
.map(|tc| ToolCall {
id: tc.id,
name: tc.name,
arguments: serde_json::to_string(&tc.arguments).unwrap_or_default(),
extra_content: None,
})
.collect();
Ok(ChatResponse {
text: Some(String::new()),
tool_calls: calls,
usage: Some(TokenUsage {
input_tokens: Some(input_tokens),
output_tokens: Some(output_tokens),
cached_input_tokens: None,
cache_creation_input_tokens: None,
}),
reasoning_content: None,
})
}
}
}
}