Skip to main content

deepstrike_sdk/runtime/
replay_provider.rs

1//! ReplayProvider — an LLMProvider that emits previously-recorded assistant messages
2//! instead of calling a real LLM API.
3//!
4//! Rust port of node/src/runtime/replay-provider.ts. See that file for the full design
5//! rationale. Distinct from `provider_replay` (the session-repair reasoning-content cache
6//! that does NOT skip LLM calls).
7//!
8//! Cost-accounting under replay:
9//! - `input_tokens` is ESTIMATED from the rendered context (NOT a recorded value).
10//! - `output_tokens` is taken from `message.token_count` when present; else `chars/4`.
11//! - `cache_read_input_tokens` / `cache_creation_input_tokens` emitted as 0.
12
13use std::sync::Mutex;
14
15use async_trait::async_trait;
16use deepstrike_core::context::renderer::RenderedContext;
17use deepstrike_core::runtime::session::ProviderReplay;
18use deepstrike_core::types::message::{Content, ContentPart, Message, ToolCall, ToolSchema};
19use futures::Stream;
20
21use crate::providers::{LLMProvider, ProviderRunState, RuntimePolicy, StreamEvent};
22use crate::Result;
23
24/// Options for `ReplayProvider`.
25pub struct ReplayProviderOpts {
26    /// Maps a rendered text payload to a token count. Defaults to `chars / 4`.
27    pub tokenizer: Option<Box<dyn Fn(&str) -> u32 + Send + Sync>>,
28    /// When true, `stream()` wraps to the start once the fixture is exhausted instead of erroring.
29    pub wrap: bool,
30}
31
32impl Default for ReplayProviderOpts {
33    fn default() -> Self {
34        Self { tokenizer: None, wrap: false }
35    }
36}
37
38fn default_tokenizer(text: &str) -> u32 {
39    let len = text.chars().count() as u32;
40    (len + 3) / 4
41}
42
43/// LLMProvider that dequeues recorded assistant messages instead of calling an API.
44pub struct ReplayProvider {
45    messages: Vec<Message>,
46    cursor: Mutex<usize>,
47    tokenizer: Box<dyn Fn(&str) -> u32 + Send + Sync>,
48    wrap: bool,
49}
50
51impl ReplayProvider {
52    pub fn new(messages: Vec<Message>) -> Self {
53        Self::with_opts(messages, ReplayProviderOpts::default())
54    }
55
56    pub fn with_opts(messages: Vec<Message>, opts: ReplayProviderOpts) -> Self {
57        Self {
58            messages,
59            cursor: Mutex::new(0),
60            tokenizer: opts.tokenizer.unwrap_or_else(|| Box::new(default_tokenizer)),
61            wrap: opts.wrap,
62        }
63    }
64
65    pub fn consumed(&self) -> usize {
66        *self.cursor.lock().unwrap()
67    }
68
69    pub fn remaining(&self) -> usize {
70        let c = *self.cursor.lock().unwrap();
71        self.messages.len().saturating_sub(c)
72    }
73
74    pub fn reset(&self) {
75        *self.cursor.lock().unwrap() = 0;
76    }
77
78    fn pull(&self) -> Result<Message> {
79        let mut c = self.cursor.lock().unwrap();
80        if *c >= self.messages.len() {
81            if self.wrap && !self.messages.is_empty() {
82                *c = 0;
83            } else {
84                return Err(crate::Error::Other(format!(
85                    "ReplayProvider: fixture exhausted (consumed={}, total={})",
86                    *c,
87                    self.messages.len()
88                )));
89            }
90        }
91        let msg = self.messages[*c].clone();
92        *c += 1;
93        Ok(msg)
94    }
95
96    fn estimate_input_tokens(&self, context: &RenderedContext, tools: &[ToolSchema]) -> u32 {
97        (self.tokenizer)(&render_context_to_text(context, tools))
98    }
99}
100
101fn render_context_to_text(context: &RenderedContext, tools: &[ToolSchema]) -> String {
102    let mut parts: Vec<String> = Vec::new();
103    if !context.system_text.is_empty() {
104        parts.push(context.system_text.clone());
105    }
106    if !context.system_stable.is_empty() {
107        parts.push(context.system_stable.clone());
108    }
109    if !context.system_knowledge.is_empty() {
110        parts.push(context.system_knowledge.clone());
111    }
112    if let Some(turn) = &context.state_turn {
113        if let Some(t) = message_text(turn) {
114            parts.push(t);
115        }
116    }
117    for turn in &context.turns {
118        if let Some(t) = message_text(turn) {
119            parts.push(t);
120        }
121        for tc in &turn.tool_calls {
122            parts.push(format!("{} {}", tc.name, tc.arguments.to_string()));
123        }
124    }
125    for tool in tools {
126        parts.push(format!("{} {} {}", tool.name, tool.description, tool.parameters));
127    }
128    parts.join("\n")
129}
130
131fn message_text(m: &Message) -> Option<String> {
132    match &m.content {
133        Content::Text(s) if !s.is_empty() => Some(s.clone()),
134        Content::Parts(parts) => {
135            let joined: String = parts
136                .iter()
137                .filter_map(|p| match p {
138                    ContentPart::Text { text } => Some(text.clone()),
139                    ContentPart::ToolResult { output, .. } => Some(output.clone()),
140                    _ => None,
141                })
142                .collect::<Vec<_>>()
143                .join("\n");
144            if joined.is_empty() { None } else { Some(joined) }
145        }
146        _ => None,
147    }
148}
149
150#[async_trait]
151impl LLMProvider for ReplayProvider {
152    fn runtime_policy(&self) -> RuntimePolicy {
153        RuntimePolicy::default()
154    }
155
156    fn peek_provider_replay(&self, _content: &str, _tool_calls: &[ToolCall]) -> Option<ProviderReplay> {
157        None
158    }
159
160    fn seed_provider_replay(
161        &self,
162        _content: &str,
163        _tool_calls: &[ToolCall],
164        _replay: &ProviderReplay,
165    ) {
166    }
167
168    async fn stream(
169        &self,
170        context: &RenderedContext,
171        tools: &[ToolSchema],
172        _extensions: Option<&serde_json::Value>,
173        _state: Option<&ProviderRunState>,
174    ) -> Result<Box<dyn Stream<Item = Result<StreamEvent>> + Send + Unpin>> {
175        let msg = self.pull()?;
176        let input_tokens = self.estimate_input_tokens(context, tools);
177        let output_tokens = msg.token_count.unwrap_or_else(|| {
178            let content = message_text(&msg).unwrap_or_default();
179            (self.tokenizer)(&content)
180        });
181
182        let mut events: Vec<Result<StreamEvent>> = Vec::new();
183        events.push(Ok(StreamEvent::Usage {
184            total_tokens: input_tokens + output_tokens,
185            input_tokens,
186            output_tokens,
187            cache_read_input_tokens: 0,
188            cache_creation_input_tokens: 0,
189            // I1: replay provider does not model the cache layer.
190            cache_read_input_tokens_by_slot: None,
191            // Replay never truncates — a recorded transcript is already complete.
192            stop_reason: None,
193        }));
194        if let Some(text) = message_text(&msg) {
195            if !text.is_empty() {
196                events.push(Ok(StreamEvent::TextDelta { delta: text }));
197            }
198        }
199        for tc in &msg.tool_calls {
200            events.push(Ok(StreamEvent::ToolCall {
201                id: tc.id.to_string(),
202                name: tc.name.to_string(),
203                arguments: tc.arguments.clone(),
204            }));
205        }
206        Ok(Box::new(futures::stream::iter(events)))
207    }
208}