1use std::{
4 collections::{BTreeMap, VecDeque},
5 sync::{Arc, Mutex},
6};
7
8use futures_util::StreamExt;
9use runifold_model::{
10 ContentPart, Model, ModelCallContext, ModelCapabilities, ModelError, ModelEventStream,
11 ModelFuture, ModelRef, ModelRequest, ModelStreamEvent,
12};
13use runifold_tool::{Tool, ToolContext, ToolDescriptor, ToolError, ToolFuture, ToolOutput};
14use serde_json::Value;
15use thiserror::Error;
16
17#[derive(Clone, Debug)]
19#[non_exhaustive]
20pub enum ModelFault {
21 Pass,
23 FailOpen(ModelError),
25 DisconnectAfterEvents(usize),
27 DisconnectAfterToolCall,
29}
30
31#[derive(Clone, Debug)]
33pub struct ToolFault {
34 tool: String,
35 invocation: u64,
36 error: ToolError,
37}
38
39impl ToolFault {
40 pub fn on_invocation(tool: impl Into<String>, invocation: u64, error: ToolError) -> Self {
42 Self {
43 tool: tool.into(),
44 invocation,
45 error,
46 }
47 }
48}
49
50#[derive(Debug, Default)]
51struct FaultState {
52 model_faults: VecDeque<ModelFault>,
53 tool_faults: Vec<ToolFault>,
54 tool_invocations: BTreeMap<String, u64>,
55 tool_executions: BTreeMap<String, u64>,
56 runtime_restarts: u64,
57}
58
59#[derive(Clone, Debug, Default)]
61pub struct FaultController {
62 state: Arc<Mutex<FaultState>>,
63}
64
65pub type FaultScenario = FaultController;
67
68impl FaultController {
69 pub fn new() -> Self {
71 Self::default()
72 }
73
74 #[must_use]
76 pub fn model_fault(self, fault: ModelFault) -> Self {
77 self.lock().model_faults.push_back(fault);
78 self
79 }
80
81 #[must_use]
83 pub fn disconnect_after_tool_call(self) -> Self {
84 self.model_fault(ModelFault::DisconnectAfterToolCall)
85 }
86
87 #[must_use]
89 pub fn disconnect_after_model_events(self, events: usize) -> Self {
90 self.model_fault(ModelFault::DisconnectAfterEvents(events))
91 }
92
93 #[must_use]
95 pub fn fail_next_model(self, error: ModelError) -> Self {
96 self.model_fault(ModelFault::FailOpen(error))
97 }
98
99 #[must_use]
101 pub fn tool_fault(self, fault: ToolFault) -> Self {
102 self.lock().tool_faults.push(fault);
103 self
104 }
105
106 #[must_use]
108 pub fn fail_tool_on_invocation(
109 self,
110 tool: impl Into<String>,
111 invocation: u64,
112 error: ToolError,
113 ) -> Self {
114 self.tool_fault(ToolFault::on_invocation(tool, invocation, error))
115 }
116
117 pub fn model<M>(&self, model: M) -> FaultInjectingModel<M>
119 where
120 M: Model,
121 {
122 FaultInjectingModel {
123 inner: model,
124 controller: self.clone(),
125 }
126 }
127
128 pub fn tool(&self, tool: Arc<dyn Tool>) -> FaultInjectingTool {
130 FaultInjectingTool {
131 inner: tool,
132 controller: self.clone(),
133 }
134 }
135
136 pub fn tool_invocations(&self, tool: &str) -> u64 {
138 self.lock().tool_invocations.get(tool).copied().unwrap_or(0)
139 }
140
141 pub fn tool_executions(&self, tool: &str) -> u64 {
143 self.lock().tool_executions.get(tool).copied().unwrap_or(0)
144 }
145
146 pub fn runtime_restarts(&self) -> u64 {
148 self.lock().runtime_restarts
149 }
150
151 pub fn assert_tool_executed_exactly(
157 &self,
158 tool: &str,
159 expected: u64,
160 ) -> Result<(), ScenarioAssertionError> {
161 let actual = self.tool_executions(tool);
162 if actual == expected {
163 Ok(())
164 } else {
165 Err(ScenarioAssertionError::ToolInvocationCount {
166 tool: tool.into(),
167 expected,
168 actual,
169 })
170 }
171 }
172
173 fn lock(&self) -> std::sync::MutexGuard<'_, FaultState> {
174 self.state
175 .lock()
176 .unwrap_or_else(std::sync::PoisonError::into_inner)
177 }
178
179 fn next_model_fault(&self) -> ModelFault {
180 self.lock()
181 .model_faults
182 .pop_front()
183 .unwrap_or(ModelFault::Pass)
184 }
185
186 fn tool_result(&self, tool: &str) -> Option<ToolError> {
187 let mut state = self.lock();
188 let invocation = {
189 let count = state.tool_invocations.entry(tool.into()).or_default();
190 *count = count.saturating_add(1);
191 *count
192 };
193 let error = state
194 .tool_faults
195 .iter()
196 .find(|fault| fault.tool == tool && fault.invocation == invocation)
197 .map(|fault| fault.error.clone());
198 if error.is_none() {
199 let count = state.tool_executions.entry(tool.into()).or_default();
200 *count = count.saturating_add(1);
201 }
202 error
203 }
204
205 fn record_restart(&self) {
206 let mut state = self.lock();
207 state.runtime_restarts = state.runtime_restarts.saturating_add(1);
208 }
209}
210
211#[derive(Clone, Debug)]
213pub struct FaultInjectingModel<M> {
214 inner: M,
215 controller: FaultController,
216}
217
218impl<M> Model for FaultInjectingModel<M>
219where
220 M: Model,
221{
222 fn capabilities<'a>(
223 &'a self,
224 model: &'a ModelRef,
225 ) -> ModelFuture<'a, Result<ModelCapabilities, ModelError>> {
226 self.inner.capabilities(model)
227 }
228
229 fn stream(
230 &self,
231 request: ModelRequest,
232 context: ModelCallContext,
233 ) -> ModelFuture<'_, Result<ModelEventStream, ModelError>> {
234 let fault = self.controller.next_model_fault();
235 Box::pin(async move {
236 if let ModelFault::FailOpen(error) = fault {
237 return Err(error);
238 }
239 let stream = self.inner.stream(request, context).await?;
240 match fault {
241 ModelFault::Pass => Ok(stream),
242 ModelFault::DisconnectAfterEvents(limit) => Ok(Box::pin(stream.take(limit))),
243 ModelFault::DisconnectAfterToolCall => {
244 let truncated = futures_util::stream::unfold(
245 (stream, false),
246 |(mut stream, stopped)| async move {
247 if stopped {
248 return None;
249 }
250 let item = stream.next().await?;
251 let stop = matches!(
252 &item,
253 Ok(ModelStreamEvent::ContentPartCompleted {
254 part: ContentPart::ToolCall(_),
255 ..
256 })
257 );
258 Some((item, (stream, stop)))
259 },
260 );
261 Ok(Box::pin(truncated))
262 }
263 ModelFault::FailOpen(_) => unreachable!("handled before opening the stream"),
264 }
265 })
266 }
267}
268
269#[derive(Clone)]
271pub struct FaultInjectingTool {
272 inner: Arc<dyn Tool>,
273 controller: FaultController,
274}
275
276impl Tool for FaultInjectingTool {
277 fn descriptor(&self) -> &ToolDescriptor {
278 self.inner.descriptor()
279 }
280
281 fn invoke(
282 &self,
283 input: Value,
284 context: ToolContext,
285 ) -> ToolFuture<'_, Result<ToolOutput, ToolError>> {
286 let injected = self.controller.tool_result(&self.inner.descriptor().name);
287 if let Some(error) = injected {
288 return Box::pin(async move { Err(error) });
289 }
290 self.inner.invoke(input, context)
291 }
292}
293
294impl std::fmt::Debug for FaultInjectingTool {
295 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
296 formatter
297 .debug_struct("FaultInjectingTool")
298 .field("tool", &self.inner.descriptor().name)
299 .finish_non_exhaustive()
300 }
301}
302
303pub struct RecoveryHarness<T, F>
306where
307 F: FnMut() -> T,
308{
309 current: T,
310 factory: F,
311 controller: FaultController,
312}
313
314impl<T, F> RecoveryHarness<T, F>
315where
316 F: FnMut() -> T,
317{
318 pub fn new(mut factory: F, controller: FaultController) -> Self {
320 let current = factory();
321 Self {
322 current,
323 factory,
324 controller,
325 }
326 }
327
328 pub const fn current(&self) -> &T {
330 &self.current
331 }
332
333 pub fn current_mut(&mut self) -> &mut T {
335 &mut self.current
336 }
337
338 pub fn restart(&mut self) -> &mut T {
341 self.current = (self.factory)();
342 self.controller.record_restart();
343 &mut self.current
344 }
345}
346
347#[derive(Clone, Debug, Error, Eq, PartialEq)]
349#[non_exhaustive]
350pub enum ScenarioAssertionError {
351 #[error("tool `{tool}` executed {actual} times; expected {expected}")]
353 ToolInvocationCount {
354 tool: String,
356 expected: u64,
358 actual: u64,
360 },
361}
362
363#[cfg(test)]
364mod tests {
365 use std::collections::BTreeMap;
366
367 use runifold_core::RetrySafety;
368 use runifold_model::{
369 ContentPart, FinishReason, Message, Model, ModelCallContext, ModelErrorKind, ModelRef,
370 ModelRequest, ModelStreamEvent, ToolCall,
371 };
372 use runifold_tool::{ToolError, ToolErrorKind};
373
374 use crate::ScriptedModel;
375
376 use super::{FaultController, ModelFault, RecoveryHarness, ToolFault};
377
378 #[test]
379 fn disconnect_after_tool_call_never_reaches_terminal_success() {
380 let model = ScriptedModel::new();
381 let model_ref = ModelRef::new("test", "model");
382 model.enqueue([
383 ModelStreamEvent::ResponseStarted {
384 id: Some("response".into()),
385 model: model_ref.clone(),
386 },
387 ModelStreamEvent::ContentPartCompleted {
388 index: 0,
389 part: ContentPart::ToolCall(ToolCall {
390 id: "call".into(),
391 name: "charge".into(),
392 arguments: serde_json::json!({}),
393 raw_arguments: Some("{}".into()),
394 metadata: BTreeMap::new(),
395 }),
396 },
397 ModelStreamEvent::ResponseCompleted {
398 finish_reason: FinishReason::ToolCalls,
399 provider_metadata: BTreeMap::new(),
400 },
401 ]);
402 let controller = FaultController::new().model_fault(ModelFault::DisconnectAfterToolCall);
403 let model = controller.model(model);
404 let request = ModelRequest::new(model_ref, Message::user("run"));
405
406 let error =
407 futures_executor::block_on(model.invoke(request, ModelCallContext::new())).unwrap_err();
408
409 assert_eq!(error.kind, ModelErrorKind::Protocol);
410 }
411
412 #[test]
413 fn tool_faults_are_one_based_and_distinguish_attempts_from_execution() {
414 let mut error = ToolError::local(ToolErrorKind::Execution, "injected");
415 error.retry_safety = RetrySafety::Safe;
416 let controller =
417 FaultController::new().tool_fault(ToolFault::on_invocation("charge", 1, error.clone()));
418
419 assert_eq!(controller.tool_result("charge"), Some(error));
420 assert_eq!(controller.tool_result("charge"), None);
421 assert_eq!(controller.tool_invocations("charge"), 2);
422 assert_eq!(controller.tool_executions("charge"), 1);
423 controller
424 .assert_tool_executed_exactly("charge", 1)
425 .unwrap();
426 }
427
428 #[test]
429 fn recovery_harness_reconstructs_runtime_and_counts_restarts() {
430 let controller = FaultController::new();
431 let mut next = 0_u64;
432 let mut harness = RecoveryHarness::new(
433 || {
434 next += 1;
435 next
436 },
437 controller.clone(),
438 );
439
440 assert_eq!(*harness.current(), 1);
441 assert_eq!(*harness.restart(), 2);
442 assert_eq!(controller.runtime_restarts(), 1);
443 }
444}