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::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<Message>,
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<Message>) -> Self {
56        Self::with_opts(messages, ReplayProviderOpts::default())
57    }
58
59    pub fn with_opts(messages: Vec<Message>, 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<Message> {
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(&self, context: &RenderedContext, tools: &[ToolSchema]) -> u32 {
102        (self.tokenizer)(&render_context_to_text(context, tools))
103    }
104}
105
106fn render_context_to_text(context: &RenderedContext, tools: &[ToolSchema]) -> String {
107    let mut parts: Vec<String> = Vec::new();
108    if !context.system_text.is_empty() {
109        parts.push(context.system_text.clone());
110    }
111    if !context.system_stable.is_empty() {
112        parts.push(context.system_stable.clone());
113    }
114    if !context.system_knowledge.is_empty() {
115        parts.push(context.system_knowledge.clone());
116    }
117    if let Some(turn) = &context.state_turn {
118        if let Some(t) = message_text(turn) {
119            parts.push(t);
120        }
121    }
122    for turn in &context.turns {
123        if let Some(t) = message_text(turn) {
124            parts.push(t);
125        }
126        for tc in &turn.tool_calls {
127            parts.push(format!("{} {}", tc.name, tc.arguments.to_string()));
128        }
129    }
130    for tool in tools {
131        parts.push(format!(
132            "{} {} {}",
133            tool.name, tool.description, tool.parameters
134        ));
135    }
136    parts.join("\n")
137}
138
139fn message_text(m: &Message) -> Option<String> {
140    match &m.content {
141        Content::Text(s) if !s.is_empty() => Some(s.clone()),
142        Content::Parts(parts) => {
143            let joined: String = parts
144                .iter()
145                .filter_map(|p| match p {
146                    ContentPart::Text { text } => Some(text.clone()),
147                    ContentPart::ToolResult { output, .. } => Some(output.clone()),
148                    _ => None,
149                })
150                .collect::<Vec<_>>()
151                .join("\n");
152            if joined.is_empty() {
153                None
154            } else {
155                Some(joined)
156            }
157        }
158        _ => None,
159    }
160}
161
162#[async_trait]
163impl LLMProvider for ReplayProvider {
164    fn runtime_policy(&self) -> RuntimePolicy {
165        RuntimePolicy::default()
166    }
167
168    fn peek_provider_replay(
169        &self,
170        _content: &str,
171        _tool_calls: &[ToolCall],
172    ) -> Option<ProviderReplay> {
173        None
174    }
175
176    fn seed_provider_replay(
177        &self,
178        _content: &str,
179        _tool_calls: &[ToolCall],
180        _replay: &ProviderReplay,
181    ) {
182    }
183
184    async fn stream(
185        &self,
186        context: &RenderedContext,
187        tools: &[ToolSchema],
188        _extensions: Option<&serde_json::Value>,
189        _state: Option<&ProviderRunState>,
190    ) -> Result<Box<dyn Stream<Item = Result<StreamEvent>> + Send + Unpin>> {
191        let msg = self.pull()?;
192        let input_tokens = self.estimate_input_tokens(context, tools);
193        let output_tokens = msg.token_count.unwrap_or_else(|| {
194            let content = message_text(&msg).unwrap_or_default();
195            (self.tokenizer)(&content)
196        });
197
198        let mut events: Vec<Result<StreamEvent>> = Vec::new();
199        events.push(Ok(StreamEvent::Usage {
200            total_tokens: input_tokens + output_tokens,
201            input_tokens,
202            output_tokens,
203            cache_read_input_tokens: 0,
204            cache_creation_input_tokens: 0,
205            // I1: replay provider does not model the cache layer.
206            cache_read_input_tokens_by_slot: None,
207            // Replay never truncates — a recorded transcript is already complete.
208            stop_reason: None,
209        }));
210        if let Some(text) = message_text(&msg) {
211            if !text.is_empty() {
212                events.push(Ok(StreamEvent::TextDelta { delta: text }));
213            }
214        }
215        for tc in &msg.tool_calls {
216            events.push(Ok(StreamEvent::ToolCall {
217                id: tc.id.to_string(),
218                name: tc.name.to_string(),
219                arguments: tc.arguments.clone(),
220            }));
221        }
222        Ok(Box::new(futures::stream::iter(events)))
223    }
224}