zeph_core/agent/speculative/
mod.rs1pub 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#[derive(Debug, Default, Clone)]
61pub struct SpeculativeMetrics {
62 pub committed: u32,
64 pub cancelled: u32,
66 pub evicted_oldest: u32,
68 pub skipped_confirmation: u32,
70 pub wasted_ms: u64,
72}
73
74pub struct SpeculationEngine {
92 executor: Arc<dyn ErasedToolExecutor>,
93 config: SpeculativeConfig,
94 cache: SpeculativeCache,
95 metrics: parking_lot::Mutex<SpeculativeMetrics>,
96 sweeper: Option<SweepHandle>,
97 task_supervisor: Option<Arc<zeph_common::TaskSupervisor>>,
100}
101
102impl SpeculationEngine {
103 #[must_use]
105 pub fn new(executor: Arc<dyn ErasedToolExecutor>, config: SpeculativeConfig) -> Self {
106 Self::new_with_supervisor(executor, config, None)
107 }
108
109 #[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 let shared = cache.shared_inner();
123
124 let sweeper_handle = if let Some(sup) = &supervisor {
125 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 #[must_use]
159 pub fn mode(&self) -> SpeculationMode {
160 self.config.mode
161 }
162
163 #[must_use]
165 pub fn is_active(&self) -> bool {
166 self.config.mode != SpeculationMode::Off
167 }
168
169 #[must_use]
171 pub fn confidence_threshold(&self) -> f32 {
172 self.config.confidence_threshold
173 }
174
175 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 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 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 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 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 pub fn end_turn(&self) -> SpeculativeMetrics {
285 self.cache.cancel_all();
286 std::mem::take(&mut *self.metrics.lock())
287 }
288
289 #[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 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 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 #[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 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 #[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); }
528}