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 estimated from the replayed message body with the provider tokenizer.
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::InternalRenderedContext;
17use deepstrike_core::runtime::session::ProviderReplay;
18use deepstrike_core::types::message::{Content, ContentPart, CoreMessage, ToolCall, ToolSchema};
19use futures::Stream;
20
21use crate::Result;
22use crate::providers::{LLMProvider, ProviderRunState, RuntimePolicy, StreamEvent};
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 {
35            tokenizer: None,
36            wrap: false,
37        }
38    }
39}
40
41fn default_tokenizer(text: &str) -> u32 {
42    let len = text.chars().count() as u32;
43    (len + 3) / 4
44}
45
46/// LLMProvider that dequeues recorded assistant messages instead of calling an API.
47pub struct ReplayProvider {
48    messages: Vec<CoreMessage>,
49    cursor: Mutex<usize>,
50    tokenizer: Box<dyn Fn(&str) -> u32 + Send + Sync>,
51    wrap: bool,
52}
53
54impl ReplayProvider {
55    pub fn new(messages: Vec<CoreMessage>) -> Self {
56        Self::with_opts(messages, ReplayProviderOpts::default())
57    }
58
59    pub fn with_opts(messages: Vec<CoreMessage>, opts: ReplayProviderOpts) -> Self {
60        Self {
61            messages,
62            cursor: Mutex::new(0),
63            tokenizer: opts
64                .tokenizer
65                .unwrap_or_else(|| Box::new(default_tokenizer)),
66            wrap: opts.wrap,
67        }
68    }
69
70    pub fn consumed(&self) -> usize {
71        *self.cursor.lock().unwrap()
72    }
73
74    pub fn remaining(&self) -> usize {
75        let c = *self.cursor.lock().unwrap();
76        self.messages.len().saturating_sub(c)
77    }
78
79    pub fn reset(&self) {
80        *self.cursor.lock().unwrap() = 0;
81    }
82
83    fn pull(&self) -> Result<CoreMessage> {
84        let mut c = self.cursor.lock().unwrap();
85        if *c >= self.messages.len() {
86            if self.wrap && !self.messages.is_empty() {
87                *c = 0;
88            } else {
89                return Err(crate::Error::Other(format!(
90                    "ReplayProvider: fixture exhausted (consumed={}, total={})",
91                    *c,
92                    self.messages.len()
93                )));
94            }
95        }
96        let msg = self.messages[*c].clone();
97        *c += 1;
98        Ok(msg)
99    }
100
101    fn estimate_input_tokens(
102        &self,
103        context: &InternalRenderedContext,
104        tools: &[ToolSchema],
105    ) -> u32 {
106        (self.tokenizer)(&render_context_to_text(context, tools))
107    }
108}
109
110fn render_context_to_text(context: &InternalRenderedContext, tools: &[ToolSchema]) -> String {
111    let mut parts: Vec<String> = Vec::new();
112    if !context.system_text.is_empty() {
113        parts.push(context.system_text.clone());
114    }
115    if !context.system_stable.is_empty() {
116        parts.push(context.system_stable.clone());
117    }
118    if !context.system_knowledge.is_empty() {
119        parts.push(context.system_knowledge.clone());
120    }
121    if let Some(turn) = &context.state_turn {
122        if let Some(t) = message_text(turn) {
123            parts.push(t);
124        }
125    }
126    for turn in &context.turns {
127        if let Some(t) = message_text(turn) {
128            parts.push(t);
129        }
130        for tc in &turn.tool_calls {
131            parts.push(format!("{} {}", tc.name, tc.arguments.to_string()));
132        }
133    }
134    for tool in tools {
135        parts.push(format!(
136            "{} {} {}",
137            tool.name, tool.description, tool.parameters
138        ));
139    }
140    parts.join("\n")
141}
142
143fn message_text(m: &CoreMessage) -> Option<String> {
144    match &m.content {
145        Content::Text(s) if !s.is_empty() => Some(s.clone()),
146        Content::Parts(parts) => {
147            let joined: String = parts
148                .iter()
149                .filter_map(|p| match p {
150                    ContentPart::Text { text } => Some(text.clone()),
151                    ContentPart::ToolResult { output, .. } => Some(output.clone()),
152                    _ => None,
153                })
154                .collect::<Vec<_>>()
155                .join("\n");
156            if joined.is_empty() {
157                None
158            } else {
159                Some(joined)
160            }
161        }
162        _ => None,
163    }
164}
165
166#[async_trait]
167impl LLMProvider for ReplayProvider {
168    fn runtime_policy(&self) -> RuntimePolicy {
169        RuntimePolicy::default()
170    }
171
172    fn peek_provider_replay(
173        &self,
174        _content: &str,
175        _tool_calls: &[ToolCall],
176    ) -> Option<ProviderReplay> {
177        None
178    }
179
180    fn seed_provider_replay(
181        &self,
182        _content: &str,
183        _tool_calls: &[ToolCall],
184        _replay: &ProviderReplay,
185    ) {
186    }
187
188    async fn stream(
189        &self,
190        context: &InternalRenderedContext,
191        tools: &[ToolSchema],
192        _extensions: Option<&serde_json::Value>,
193        _state: Option<&ProviderRunState>,
194    ) -> Result<Box<dyn Stream<Item = Result<StreamEvent>> + Send + Unpin>> {
195        let msg = self.pull()?;
196        let input_tokens = self.estimate_input_tokens(context, tools);
197        let content = message_text(&msg).unwrap_or_default();
198        let output_tokens = (self.tokenizer)(&content);
199
200        let mut events: Vec<Result<StreamEvent>> = Vec::new();
201        events.push(Ok(StreamEvent::Usage {
202            total_tokens: input_tokens + output_tokens,
203            input_tokens,
204            output_tokens,
205            cache_read_input_tokens: 0,
206            cache_creation_input_tokens: 0,
207            // I1: replay provider does not model the cache layer.
208            cache_read_input_tokens_by_slot: None,
209            // Replay never truncates — a recorded transcript is already complete.
210            stop_reason: None,
211        }));
212        if let Some(text) = message_text(&msg) {
213            if !text.is_empty() {
214                events.push(Ok(StreamEvent::TextDelta { delta: text }));
215            }
216        }
217        for tc in &msg.tool_calls {
218            events.push(Ok(StreamEvent::ToolCall {
219                id: tc.id.to_string(),
220                name: tc.name.to_string(),
221                arguments: tc.arguments.clone(),
222            }));
223        }
224        Ok(Box::new(futures::stream::iter(events)))
225    }
226}