Skip to main content

zeph_core/agent/speculative/
mod.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Speculative tool execution engine.
5//!
6//! Provides two complementary strategies for reducing tool-dispatch latency:
7//!
8//! - **Decoding-level** (`SpeculationMode::Decoding`, issue #2290): drains the LLM
9//!   `ToolStream` SSE events and fires tool calls speculatively as soon as all
10//!   required JSON fields are present in the partial input buffer.
11//!
12//! - **Pattern-level** (`SpeculationMode::Pattern`, issue #2409 PASTE): queries
13//!   `SQLite` at skill activation to predict the most likely next tool calls from
14//!   historical invocation sequences.
15//!
16//! Both strategies share a bounded [`SpeculativeCache`] and per-handle TTL enforcement.
17//! Speculation is completely disabled (`mode = off`) by default and never adds cargo
18//! feature flags — all branches compile unconditionally.
19//!
20//! ## Safety invariants
21//!
22//! - Speculative dispatch **always** uses `execute_tool_call` (never `_confirmed`).
23//! - A call is not dispatched speculatively when `trust_level != Trusted`.
24//! - A call is not dispatched speculatively when `requires_confirmation` returns `true`.
25//! - No synchronous dry-run execution — confirmation is checked via a policy query,
26//!   not by actually running the tool (C1: no double side-effects).
27//! - All in-flight handles are cancelled at turn boundary.
28//! - Per-handle TTL (default 30 s) is enforced by a background sweeper that shares
29//!   the same cache instance (C2: no separate empty cache in the sweeper).
30
31pub mod cache;
32pub mod partial_json;
33pub mod paste;
34pub mod prediction;
35pub mod stream_drainer;
36
37use std::sync::Arc;
38use std::time::Duration;
39
40use tokio::time::Instant;
41use tokio_util::sync::CancellationToken;
42use tracing::debug;
43use zeph_common::SkillTrustLevel;
44use zeph_tools::{ErasedToolExecutor, ToolCall, ToolError, ToolOutput};
45
46use cache::{HandleKey, SpeculativeCache, SpeculativeHandle, hash_args, hash_context};
47use prediction::Prediction;
48
49pub use zeph_config::tools::{SpeculationMode, SpeculativeConfig};
50
51struct SweepHandle(zeph_common::task_supervisor::TaskHandle);
52
53impl SweepHandle {
54    fn abort(self) {
55        self.0.abort();
56    }
57}
58
59/// Metrics collected across a single agent turn.
60#[derive(Debug, Default, Clone)]
61pub struct SpeculativeMetrics {
62    /// Handles that matched and committed.
63    pub committed: u32,
64    /// Handles that were cancelled (mismatch, TTL, turn end).
65    pub cancelled: u32,
66    /// Handles that were evicted because `max_in_flight` was saturated.
67    pub evicted_oldest: u32,
68    /// Handles skipped because `requires_confirmation` returned `true`.
69    pub skipped_confirmation: u32,
70    /// Total wall-clock milliseconds spent in wasted speculative work.
71    pub wasted_ms: u64,
72}
73
74/// Speculative execution engine.
75///
76/// Holds a reference to the underlying executor, the shared cache, and the active
77/// configuration. Create one instance per agent session and share via `Arc`.
78///
79/// # Examples
80///
81/// ```rust,no_run
82/// use std::sync::Arc;
83/// use zeph_config::tools::SpeculativeConfig;
84/// use zeph_core::agent::speculative::SpeculationEngine;
85///
86/// # async fn example(executor: Arc<dyn zeph_tools::ErasedToolExecutor>) {
87/// let config = SpeculativeConfig::default(); // mode = off
88/// let engine = SpeculationEngine::new(executor, config);
89/// # }
90/// ```
91pub struct SpeculationEngine {
92    executor: Arc<dyn ErasedToolExecutor>,
93    config: SpeculativeConfig,
94    cache: SpeculativeCache,
95    metrics: parking_lot::Mutex<SpeculativeMetrics>,
96    sweeper: Option<SweepHandle>,
97    /// Optional session-level supervisor for task registration. `None` in test harnesses
98    /// that construct `SpeculationEngine` without a supervisor.
99    task_supervisor: Option<Arc<zeph_common::TaskSupervisor>>,
100}
101
102impl SpeculationEngine {
103    /// Create a new engine with the given executor and config.
104    #[must_use]
105    pub fn new(executor: Arc<dyn ErasedToolExecutor>, config: SpeculativeConfig) -> Self {
106        Self::new_with_supervisor(executor, config, None)
107    }
108
109    /// Create a new engine with an optional session-level supervisor for task registration.
110    ///
111    /// When `supervisor` is `Some`, the background sweeper and speculative dispatch tasks are
112    /// registered for observability and graceful shutdown. Pass `None` in test harnesses.
113    #[must_use]
114    pub fn new_with_supervisor(
115        executor: Arc<dyn ErasedToolExecutor>,
116        config: SpeculativeConfig,
117        supervisor: Option<Arc<zeph_common::TaskSupervisor>>,
118    ) -> Self {
119        let cache = SpeculativeCache::new(config.max_in_flight);
120
121        // Share the inner Arc so the sweeper operates on the *same* handle set (fixes C2).
122        let shared = cache.shared_inner();
123
124        let sweeper_handle = if let Some(sup) = &supervisor {
125            // `factory` must be `Fn` (not `FnOnce`) because `TaskSupervisor::spawn` may restart
126            // the task. Clone the `Arc` on each factory invocation so `shared` stays available.
127            let task_handle = sup.spawn(zeph_common::task_supervisor::TaskDescriptor {
128                name: "agent.speculative.sweeper",
129                restart: zeph_common::task_supervisor::RestartPolicy::RunOnce,
130                factory: move || {
131                    let shared = Arc::clone(&shared);
132                    async move {
133                        let mut interval = tokio::time::interval(Duration::from_secs(5));
134                        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
135                        loop {
136                            interval.tick().await;
137                            SpeculativeCache::sweep_expired_inner(&shared);
138                        }
139                    }
140                },
141            });
142            Some(SweepHandle(task_handle))
143        } else {
144            None
145        };
146
147        Self {
148            executor,
149            config,
150            cache,
151            metrics: parking_lot::Mutex::new(SpeculativeMetrics::default()),
152            sweeper: sweeper_handle,
153            task_supervisor: supervisor,
154        }
155    }
156
157    /// Current speculation mode.
158    #[must_use]
159    pub fn mode(&self) -> SpeculationMode {
160        self.config.mode
161    }
162
163    /// Returns `true` when speculation is not `Off`.
164    #[must_use]
165    pub fn is_active(&self) -> bool {
166        self.config.mode != SpeculationMode::Off
167    }
168
169    /// Minimum confidence score `[0.0, 1.0]` required to dispatch a speculative task.
170    #[must_use]
171    pub fn confidence_threshold(&self) -> f32 {
172        self.config.confidence_threshold
173    }
174
175    /// Try to dispatch `prediction` speculatively.
176    ///
177    /// Returns `false` when the call is skipped (not speculatable, trust gate, confirmation
178    /// gate, or circuit-breaker). Returns `true` when the handle was inserted in the cache.
179    ///
180    /// The confirmation check is performed via `requires_confirmation_erased` — a pure policy
181    /// query that does **not** execute the tool (fixes C1: no double side-effects).
182    pub fn try_dispatch(&self, prediction: &Prediction, trust_level: SkillTrustLevel) -> bool {
183        if trust_level != SkillTrustLevel::Trusted {
184            return false;
185        }
186
187        let tool_id = &prediction.tool_id;
188        if !self.executor.is_tool_speculatable_erased(tool_id.as_ref()) {
189            return false;
190        }
191
192        let call = prediction.to_tool_call(format!("spec-{}", uuid::Uuid::new_v4()));
193        let args_hash = hash_args(&call.params);
194        let context_hash = hash_context(call.context.as_ref());
195
196        // Policy check: skip if the tool would require user confirmation.
197        // This is a pure metadata query — no execution, no side-effects (C1).
198        if self.executor.requires_confirmation_erased(&call) {
199            let mut m = self.metrics.lock();
200            m.skipped_confirmation += 1;
201            debug!(tool_id = %tool_id, "speculative skip: requires_confirmation");
202            return false;
203        }
204
205        let exec = Arc::clone(&self.executor);
206        let call_clone = call.clone();
207        let cancel = CancellationToken::new();
208        let cancel_child = cancel.child_token();
209
210        let task_name: Arc<str> = Arc::from(format!(
211            "agent.speculative.dispatch.{}",
212            uuid::Uuid::new_v4()
213        ));
214        // No supervisor available (test harness or early construction path): fall back to a
215        // throwaway supervisor so SpeculativeHandle retains a BlockingHandle<R> regardless of
216        // code path.
217        let sup = self.task_supervisor.clone().unwrap_or_else(|| {
218            Arc::new(zeph_common::TaskSupervisor::new(
219                tokio_util::sync::CancellationToken::new(),
220            ))
221        });
222        let join = sup.spawn_oneshot(task_name, move || async move {
223            tokio::select! {
224                result = exec.execute_tool_call_erased(&call_clone) => result,
225                () = cancel_child.cancelled() => {
226                    Err(ToolError::Execution(std::io::Error::other("speculative cancelled")))
227                }
228            }
229        });
230
231        let handle = SpeculativeHandle {
232            key: HandleKey {
233                tool_id: tool_id.clone(),
234                args_hash,
235                context_hash,
236            },
237            join,
238            cancel,
239            ttl_deadline: Instant::now() + Duration::from_secs(self.config.ttl_seconds),
240            started_at: std::time::Instant::now(),
241        };
242
243        debug!(tool_id = %tool_id, confidence = prediction.confidence, "speculative dispatch");
244        self.cache.insert(handle);
245        true
246    }
247
248    /// Attempt to commit a speculative handle for `call`.
249    ///
250    /// If a matching handle exists (same `tool_id` + `args_hash`), awaits its result and
251    /// returns it. If no match, returns `None` — caller should fall through to normal dispatch.
252    pub async fn try_commit(
253        &self,
254        call: &ToolCall,
255    ) -> Option<Result<Option<ToolOutput>, ToolError>> {
256        let args_hash = hash_args(&call.params);
257        let context_hash = hash_context(call.context.as_ref());
258        if let Some(handle) = self
259            .cache
260            .take_match(&call.tool_id, &args_hash, &context_hash)
261        {
262            {
263                let mut m = self.metrics.lock();
264                m.committed += 1;
265            }
266            debug!(tool_id = %call.tool_id, "speculative commit");
267            Some(handle.commit().await)
268        } else {
269            None
270        }
271    }
272
273    /// Cancel and remove the in-flight handle for `tool_id`, if any.
274    ///
275    /// Performs an actual cache lookup and task abort (fixes C3: was metrics-only no-op).
276    pub fn cancel_for(&self, tool_id: &zeph_common::ToolName) {
277        debug!(tool_id = %tool_id, "speculative cancel for tool");
278        self.cache.cancel_by_tool_id(tool_id);
279        let mut m = self.metrics.lock();
280        m.cancelled += 1;
281    }
282
283    /// Cancel all in-flight handles at turn boundary and return metrics snapshot.
284    pub fn end_turn(&self) -> SpeculativeMetrics {
285        self.cache.cancel_all();
286        std::mem::take(&mut *self.metrics.lock())
287    }
288
289    /// Snapshot current metrics without resetting.
290    #[must_use]
291    pub fn metrics_snapshot(&self) -> SpeculativeMetrics {
292        self.metrics.lock().clone()
293    }
294}
295
296impl Drop for SpeculationEngine {
297    fn drop(&mut self) {
298        self.cache.cancel_all();
299        if let Some(handle) = self.sweeper.take() {
300            handle.abort();
301        }
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use zeph_tools::{ToolCall, ToolError, ToolExecutor, ToolOutput};
309
310    struct AlwaysOkExecutor;
311
312    impl ToolExecutor for AlwaysOkExecutor {
313        async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
314            Ok(None)
315        }
316
317        async fn execute_tool_call(
318            &self,
319            _call: &ToolCall,
320        ) -> Result<Option<ToolOutput>, ToolError> {
321            Ok(Some(ToolOutput {
322                tool_name: zeph_common::ToolName::new("test"),
323                summary: "ok".into(),
324                blocks_executed: 1,
325                filter_stats: None,
326                diff: None,
327                streamed: false,
328                terminal_id: None,
329                locations: None,
330                raw_response: None,
331                claim_source: None,
332                ..Default::default()
333            }))
334        }
335
336        fn is_tool_speculatable(&self, _: &str) -> bool {
337            true
338        }
339
340        fn execute_tool_call_confirmed(
341            &self,
342            call: &ToolCall,
343        ) -> impl std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send
344        {
345            self.execute_tool_call(call)
346        }
347        fn checkpoint_undo(&self, _n: usize) -> zeph_tools::CheckpointActionResult {
348            zeph_tools::CheckpointActionResult::unsupported()
349        }
350        fn checkpoint_redo(&self) -> zeph_tools::CheckpointActionResult {
351            zeph_tools::CheckpointActionResult::unsupported()
352        }
353        fn checkpoint_list(&self) -> zeph_tools::CheckpointListResult {
354            zeph_tools::CheckpointListResult::default()
355        }
356        fn requires_confirmation(&self, _call: &ToolCall) -> bool {
357            false
358        }
359    }
360
361    #[tokio::test]
362    async fn dispatch_and_commit_succeeds() {
363        let exec: Arc<dyn ErasedToolExecutor> = Arc::new(AlwaysOkExecutor);
364        let config = SpeculativeConfig {
365            mode: SpeculationMode::Decoding,
366            ..Default::default()
367        };
368        let engine = SpeculationEngine::new(exec, config);
369
370        let pred = Prediction {
371            tool_id: zeph_common::ToolName::new("test"),
372            args: serde_json::Map::new(),
373            confidence: 0.9,
374            source: prediction::PredictionSource::StreamPartial,
375        };
376
377        let dispatched = engine.try_dispatch(&pred, SkillTrustLevel::Trusted);
378        let _ = dispatched;
379    }
380
381    #[tokio::test]
382    async fn untrusted_skill_skips_dispatch() {
383        let exec: Arc<dyn ErasedToolExecutor> = Arc::new(AlwaysOkExecutor);
384        let config = SpeculativeConfig {
385            mode: SpeculationMode::Decoding,
386            ..Default::default()
387        };
388        let engine = SpeculationEngine::new(exec, config);
389
390        let pred = Prediction {
391            tool_id: zeph_common::ToolName::new("test"),
392            args: serde_json::Map::new(),
393            confidence: 0.9,
394            source: prediction::PredictionSource::StreamPartial,
395        };
396
397        let dispatched = engine.try_dispatch(&pred, SkillTrustLevel::Quarantined);
398        assert!(
399            !dispatched,
400            "untrusted skill must not dispatch speculatively"
401        );
402    }
403
404    #[tokio::test]
405    async fn cancel_for_removes_handle() {
406        let exec: Arc<dyn ErasedToolExecutor> = Arc::new(AlwaysOkExecutor);
407        let config = SpeculativeConfig {
408            mode: SpeculationMode::Decoding,
409            ..Default::default()
410        };
411        let engine = SpeculationEngine::new(exec, config);
412
413        let pred = Prediction {
414            tool_id: zeph_common::ToolName::new("test"),
415            args: serde_json::Map::new(),
416            confidence: 0.9,
417            source: prediction::PredictionSource::StreamPartial,
418        };
419
420        engine.try_dispatch(&pred, SkillTrustLevel::Trusted);
421        // After cancel_for the cache should be empty.
422        engine.cancel_for(&zeph_common::ToolName::new("test"));
423        assert!(
424            engine.cache.is_empty(),
425            "cancel_for must remove handle from cache"
426        );
427    }
428
429    #[tokio::test]
430    async fn end_turn_cancels_handles_and_resets_metrics() {
431        let exec: Arc<dyn ErasedToolExecutor> = Arc::new(AlwaysOkExecutor);
432        let config = SpeculativeConfig {
433            mode: SpeculationMode::Decoding,
434            ..Default::default()
435        };
436        let engine = SpeculationEngine::new(exec, config);
437
438        let pred = Prediction {
439            tool_id: zeph_common::ToolName::new("test"),
440            args: serde_json::Map::new(),
441            confidence: 0.9,
442            source: prediction::PredictionSource::StreamPartial,
443        };
444
445        engine.try_dispatch(&pred, SkillTrustLevel::Trusted);
446        assert!(
447            !engine.cache.is_empty(),
448            "precondition: handle must be in cache before end_turn"
449        );
450
451        let _metrics = engine.end_turn();
452        assert!(
453            engine.cache.is_empty(),
454            "end_turn must cancel all in-flight handles"
455        );
456
457        // After end_turn, metrics are reset to zero.
458        let snapshot = engine.metrics_snapshot();
459        assert_eq!(snapshot.committed, 0, "metrics must reset after end_turn");
460        assert_eq!(snapshot.cancelled, 0, "metrics must reset after end_turn");
461    }
462
463    #[tokio::test]
464    async fn is_active_reflects_mode() {
465        let exec: Arc<dyn ErasedToolExecutor> = Arc::new(AlwaysOkExecutor);
466
467        let engine_off = SpeculationEngine::new(
468            Arc::clone(&exec),
469            SpeculativeConfig {
470                mode: SpeculationMode::Off,
471                ..Default::default()
472            },
473        );
474        assert!(!engine_off.is_active(), "mode=Off means is_active()=false");
475
476        let engine_on = SpeculationEngine::new(
477            exec,
478            SpeculativeConfig {
479                mode: SpeculationMode::Decoding,
480                ..Default::default()
481            },
482        );
483        assert!(
484            engine_on.is_active(),
485            "mode=Decoding means is_active()=true"
486        );
487    }
488
489    /// Verify that an engine without a supervisor has no sweeper handle and drops cleanly.
490    #[tokio::test]
491    async fn sweeper_none_without_supervisor() {
492        let exec: Arc<dyn ErasedToolExecutor> = Arc::new(AlwaysOkExecutor);
493        let config = SpeculativeConfig {
494            mode: SpeculationMode::Decoding,
495            ..Default::default()
496        };
497
498        // Without a supervisor the sweeper is not started — this is the expected test-harness
499        // behaviour. Verify that construction and drop do not panic.
500        let engine = SpeculationEngine::new(Arc::clone(&exec), config);
501        assert!(
502            engine.sweeper.is_none(),
503            "sweeper must be None when no supervisor is provided"
504        );
505        drop(engine);
506    }
507
508    /// Verify sweeper abort via the supervised path (`SweepHandle::Supervised`).
509    #[tokio::test]
510    async fn sweeper_supervised_aborted_on_drop() {
511        let exec: Arc<dyn ErasedToolExecutor> = Arc::new(AlwaysOkExecutor);
512        let config = SpeculativeConfig {
513            mode: SpeculationMode::Decoding,
514            ..Default::default()
515        };
516
517        let cancel = tokio_util::sync::CancellationToken::new();
518        let supervisor = Arc::new(zeph_common::TaskSupervisor::new(cancel));
519
520        let engine =
521            SpeculationEngine::new_with_supervisor(Arc::clone(&exec), config, Some(supervisor));
522        assert!(
523            engine.sweeper.is_some(),
524            "sweeper handle must be Some with supervisor"
525        );
526        drop(engine); // Must not panic — exercises SweepHandle::Supervised abort path.
527    }
528}