Skip to main content

agent_engine/runtime/
tool_keepwarm.rs

1//! Keep the prompt cache warm while a tool round runs (`cache_keepwarm_tools`).
2//!
3//! A provider round that asks for tools is followed, once they return, by a
4//! request that re-sends that round's whole prefix. If the tools take longer
5//! than the cache TTL (workers are pinned to 5m and routinely run builds,
6//! test sweeps and `sleep; poll` loops for that long), the entry has expired
7//! by then and the next request rewrites the whole prefix at the write price
8//! (1.25× on 5m) instead of reading it (0.1×, 0.05× on the 5.5 models).
9//!
10//! While the round's tools run, this task re-sends **that round's own
11//! request** — the exact request messages, tools, system prompt, thinking
12//! config and options it went out with — plus one trailing ping text block
13//! (`KeepWarmMode::Replay`, appended by the transport after marker placement),
14//! shortly before the TTL lapses, and again after every accepted ping, until
15//! the tools return, the turn is cancelled or the stream ends (the guard
16//! drops). It is a keep-warm ping in every other respect
17//! (`runtime::keepwarm`): `ApiOptions::keepwarm` cuts `max_tokens`, disables
18//! retries and marks the telemetry record `"keepwarm": true`; the transport
19//! holds the ping text out of marker placement, so every breakpoint lands on
20//! the block the round itself marked and the ping is an uncached tail. A
21//! read refreshes the entry's TTL, so the request after the round reads the
22//! prefix the round cached. Nothing is persisted and no history is touched.
23//!
24//! Policy (the idle keep-warm's, `session::cache_keepwarm`, minus the idle
25//! guesswork: the next request is certain unless the turn is cancelled):
26//! - eligible only on the native Anthropic transport and when the round's
27//!   request cached something;
28//! - due at `anchor + ping_interval(honoured TTL)` (4.5 min on 5m, 55 min on
29//!   1h) on whichever clock comes first; never once the cache is within
30//!   `LATE_GUARD` of expiry (it is presumed gone: the next request rewrites
31//!   it anyway, a ping would only add a write);
32//! - ski-rental bound: a ping is sent only while `ledger + expected ping ≤
33//!   rewriting the prefix at the honoured TTL`;
34//! - the turn's `max_cost_usd` (if any) must admit the ping's worst case;
35//! - stop on a ping that read nothing, wrote or paid uncached for more than
36//!   `SHARE_LIMIT_PERCENT` of the prefix, a 4xx, or exhausted transient
37//!   retries (`BACKOFF`).
38//!
39//! Stopping. When the tools return, no new ping is scheduled, but a ping
40//! already in flight is allowed to finish — for up to [`FINISH_GRACE`] —
41//! and is booked like any other before the next request goes out. Only a
42//! turn cancel, the stream ending, or the grace running out aborts one in
43//! flight. Either way the ping is settled before the round's next request
44//! is sent, so its usage never lands after that request's. (Provider-side
45//! order would not matter anyway: an aborted ping and the next request read
46//! the same prefix, and a ping writes nothing — its tail is uncached.)
47//!
48//! Booking. Every ping that may have been billed is booked into the turn,
49//! by the idle keep-warm's rule (`keepwarm::ping_booking`): with measured
50//! usage the transport records it in the turn's usage counters (turn
51//! budget) and its `Usage` event is forwarded to the turn's event channel
52//! (session / worker cost); a ping dispatched with no usage seen (aborted in
53//! flight, a read timeout) is booked at its worst case — the whole prefix
54//! read, [`PING_INPUT_ESTIMATE`] uncached, `max_tokens` out — through both;
55//! one refused before the network or rejected with an HTTP error books
56//! nothing.
57
58use std::sync::atomic::{AtomicBool, Ordering};
59use std::sync::Arc;
60use std::time::{Duration, SystemTime};
61
62use serde_json::Value;
63use tokio::sync::{mpsc, RwLock};
64use tokio::time::Instant;
65use tokio_util::sync::CancellationToken;
66
67use super::api::{ApiMethods, ApiOptions, KeepWarmMode, KeepWarmRequest};
68use super::keepwarm::{CacheAnchor, PingBooking, PingFailure, PingTap};
69use super::types::{AuthState, SessionEvent, StreamEvent};
70use crate::core::config::CacheTtl;
71use crate::session::cache_keepwarm::{
72    governing_ttl, honoured_ttl, ping_interval, ping_output_estimate, request_cost_usd,
73    rewrite_cost_usd, TtlEvidence, BACKOFF, LATE_GUARD, PING_INPUT_ESTIMATE, SHARE_LIMIT_PERCENT,
74    WALL_POLL,
75};
76use crate::{SharedMessage, ToolRegistry};
77
78/// How long the end of a tool round waits for a ping already in flight
79/// before aborting it (and booking it at its worst case). Longer than every
80/// keep-warm ping in the api-log so far (122 real pings: p50 1.9 s, p95
81/// 11 s, max 15.8 s): the wait only happens when a ping straddles the end of
82/// the round (a few seconds out of every 270 s interval), and it delays the
83/// next request by at most the rest of that ping.
84pub const FINISH_GRACE: Duration = Duration::from_secs(20);
85
86/// Per-turn settings: present only when `cache_keepwarm_tools` is on.
87#[derive(Debug, Clone, Default)]
88pub struct ToolKeepWarm {
89    /// The runtime's base trace context: a ping is traced like an idle
90    /// keep-warm ping and never consumes a one-shot `/trace next`.
91    pub trace: super::trace::TraceContext,
92    /// Replaces `ping_interval(honoured TTL)` (tests only, see
93    /// [`interval_override`]).
94    pub interval_override: Option<Duration>,
95    /// Replaces [`FINISH_GRACE`] (tests only).
96    #[cfg(test)]
97    pub finish_grace: Option<Duration>,
98    /// Notified when the end of a round starts waiting for a ping in flight
99    /// (tests only: the sync point that releases a held ping).
100    #[cfg(test)]
101    pub finish_hook: Option<Arc<tokio::sync::Notify>>,
102}
103
104impl ToolKeepWarm {
105    pub fn new(trace: super::trace::TraceContext, interval_override: Option<Duration>) -> Self {
106        Self {
107            trace,
108            interval_override,
109            #[cfg(test)]
110            finish_grace: None,
111            #[cfg(test)]
112            finish_hook: None,
113        }
114    }
115
116    fn finish_grace(&self) -> Duration {
117        #[cfg(test)]
118        if let Some(grace) = self.finish_grace {
119            return grace;
120        }
121        FINISH_GRACE
122    }
123}
124
125/// The ping interval override, in ms (0 = none). Test-only, like the idle
126/// keep-warm's: written solely by [`testing::IntervalOverride`].
127#[cfg(any(test, feature = "testing"))]
128static INTERVAL_OVERRIDE_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
129
130/// Test seam replacing `ping_interval(honoured TTL)` for tool-round pings.
131/// Separate from the idle keep-warm's seam so tests of one never fire the
132/// other. A constant `None` without `cfg(test)` / the `testing` feature; no
133/// binary input (env, config, argv) can reach it.
134pub fn interval_override() -> Option<Duration> {
135    #[cfg(any(test, feature = "testing"))]
136    {
137        let ms = INTERVAL_OVERRIDE_MS.load(std::sync::atomic::Ordering::Relaxed);
138        if ms > 0 {
139            return Some(Duration::from_millis(ms));
140        }
141    }
142    None
143}
144
145/// Test-only controls (`cfg(test)` / `testing` feature).
146#[cfg(any(test, feature = "testing"))]
147pub mod testing {
148    use std::sync::atomic::Ordering;
149    use std::time::Duration;
150
151    /// Shortens the tool-round ping interval for every runtime in this
152    /// process while alive (tests that use it must be `#[serial]`).
153    #[must_use = "the override is cleared when the guard drops"]
154    pub struct IntervalOverride(());
155
156    impl IntervalOverride {
157        pub fn set(interval: Duration) -> Self {
158            let ms = u64::try_from(interval.as_millis())
159                .unwrap_or(u64::MAX)
160                .max(1);
161            super::INTERVAL_OVERRIDE_MS.store(ms, Ordering::Relaxed);
162            Self(())
163        }
164    }
165
166    impl Drop for IntervalOverride {
167        fn drop(&mut self) {
168            super::INTERVAL_OVERRIDE_MS.store(0, Ordering::Relaxed);
169        }
170    }
171}
172
173/// Taken immediately before a round's provider call.
174#[derive(Debug, Clone, Copy)]
175pub(super) struct RoundMark {
176    sent: CacheAnchor,
177    cached_before: u64,
178}
179
180fn cached_total(counters: &super::budget::UsageCounters) -> u64 {
181    let (_, _, read, write) = counters.totals();
182    read.saturating_add(write)
183}
184
185impl RoundMark {
186    pub(super) fn new(counters: &super::budget::UsageCounters) -> Self {
187        Self {
188            sent: CacheAnchor::now(),
189            cached_before: cached_total(counters),
190        }
191    }
192}
193
194/// Whether a provider response asks for tools.
195pub(super) fn has_tool_use(response: &Value) -> bool {
196    response["content"]
197        .as_array()
198        .is_some_and(|blocks| blocks.iter().any(|b| b["type"] == "tool_use"))
199}
200
201/// Everything one round's request went out with (borrowed from the stream
202/// loop; [`ToolRoundKeepWarm::start`] copies what it keeps — Arc bumps for
203/// the messages).
204pub(super) struct Round<'a> {
205    pub auth: &'a Arc<RwLock<AuthState>>,
206    pub client: &'a reqwest::Client,
207    pub model: &'a str,
208    pub tools: &'a ToolRegistry,
209    pub system_prompt: &'a Option<String>,
210    pub thinking_budget: u32,
211    pub reasoning_level: agent_core::reasoning::ReasoningLevel,
212    pub messages: &'a [SharedMessage],
213    pub options: &'a ApiOptions,
214    pub telemetry_level: super::telemetry::TelemetryLevel,
215}
216
217/// The turn the round belongs to.
218pub(super) struct Turn<'a> {
219    pub usage: &'a Arc<super::budget::UsageCounters>,
220    pub max_cost_usd: Option<f64>,
221    pub tx: &'a mpsc::UnboundedSender<StreamEvent>,
222    pub cancel: &'a CancellationToken,
223}
224
225/// Why the pings of a round stopped (logged once).
226#[derive(Debug, Clone, PartialEq)]
227pub(super) enum Stop {
228    /// The tools returned, the turn was cancelled or the stream ended.
229    Done,
230    /// Within `LATE_GUARD` of expiry (or past it): presumed expired.
231    Expired,
232    /// The next ping would pass the rewrite it saves.
233    Bound,
234    /// The turn's `max_cost_usd` would not admit the next ping.
235    TurnCost,
236    /// A ping read nothing, or wrote / paid uncached for a large share.
237    Miss,
238    /// 4xx / configuration / exhausted transient retries.
239    Failed(String),
240}
241
242impl std::fmt::Display for Stop {
243    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244        match self {
245            Stop::Done => f.write_str("round finished"),
246            Stop::Expired => f.write_str("cache presumed expired"),
247            Stop::Bound => f.write_str("next ping would pass the rewrite it saves"),
248            Stop::TurnCost => f.write_str("turn max_cost_usd reached"),
249            Stop::Miss => f.write_str("ping missed the cache"),
250            Stop::Failed(why) => write!(f, "ping failed: {why}"),
251        }
252    }
253}
254
255/// What the task needs to send and schedule pings.
256struct Job {
257    auth: Arc<RwLock<AuthState>>,
258    client: reqwest::Client,
259    model: String,
260    tools: ToolRegistry,
261    system_prompt: Option<String>,
262    thinking_budget: u32,
263    reasoning_level: agent_core::reasoning::ReasoningLevel,
264    /// The round's request messages, exactly (the transport appends the
265    /// ping text after marker placement, `KeepWarmMode::Replay`).
266    messages: Vec<SharedMessage>,
267    options: ApiOptions,
268    telemetry_level: super::telemetry::TelemetryLevel,
269    anchor: CacheAnchor,
270    anchor_cell: Arc<super::keepwarm::CacheAnchorCell>,
271    ttl: CacheTtl,
272    interval: Duration,
273    prefix_tokens: u64,
274    max_tokens: u64,
275    usage: Arc<super::budget::UsageCounters>,
276    max_cost_usd: Option<f64>,
277    tx: mpsc::UnboundedSender<StreamEvent>,
278}
279
280/// The task's controls: `stop` ends the schedule (no new ping), `abort`
281/// (a child of the turn's cancel) also aborts a ping in flight.
282struct Controls {
283    stop: CancellationToken,
284    abort: CancellationToken,
285    in_flight: Arc<AtomicBool>,
286}
287
288/// Running keep-warm for one tool round. [`finish`](Self::finish) stops it
289/// gracefully; dropping it (any other way out of the round) aborts a ping
290/// in flight, which the task still books.
291pub(super) struct ToolRoundKeepWarm {
292    stop: CancellationToken,
293    abort: CancellationToken,
294    in_flight: Arc<AtomicBool>,
295    grace: Duration,
296    #[cfg(test)]
297    hook: Option<Arc<tokio::sync::Notify>>,
298    handle: Option<tokio::task::JoinHandle<Stop>>,
299}
300
301impl Drop for ToolRoundKeepWarm {
302    fn drop(&mut self) {
303        self.stop.cancel();
304        self.abort.cancel();
305    }
306}
307
308impl ToolRoundKeepWarm {
309    /// Start keeping `round`'s prefix warm, or `None` when it cannot or
310    /// need not be: not the native Anthropic transport, or the round's
311    /// request cached nothing.
312    pub(super) async fn start(
313        settings: &ToolKeepWarm,
314        mark: RoundMark,
315        round: Round<'_>,
316        turn: Turn<'_>,
317    ) -> Option<Self> {
318        if !super::auth::model_is_anthropic(round.model) {
319            return None;
320        }
321        let prefix_tokens = cached_total(turn.usage).saturating_sub(mark.cached_before);
322        if prefix_tokens == 0 {
323            return None;
324        }
325        let auth_type = round.auth.read().await.auth_type.clone();
326        let configured = round.options.cache_ttl.resolve_for_auth_type(&auth_type);
327        let ttl = honoured_ttl(
328            configured,
329            TtlEvidence {
330                downgraded: round
331                    .options
332                    .ttl_downgrade_notified
333                    .load(std::sync::atomic::Ordering::Relaxed),
334                saw_1h: round
335                    .options
336                    .saw_1h_honored
337                    .load(std::sync::atomic::Ordering::Relaxed),
338                ..Default::default()
339            },
340        );
341        let interval = settings
342            .interval_override
343            .unwrap_or_else(|| ping_interval(ttl));
344        // Pings stamp the round's anchor cell (or a private one): an
345        // accepted ping moves the anchor the next ping is scheduled from.
346        let anchor_cell = round.options.cache_anchor.clone().unwrap_or_default();
347        let anchor = anchor_cell
348            .get()
349            .filter(|a| a.mono >= mark.sent.mono)
350            .unwrap_or(mark.sent);
351        let messages = round.messages.to_vec();
352        let options = ApiOptions {
353            memory_backend: None,
354            trace: settings.trace.clone(),
355            request_correlation: None,
356            suppress_stream_deltas: true,
357            usage_counters: Some(Arc::clone(turn.usage)),
358            cache_anchor: Some(Arc::clone(&anchor_cell)),
359            context_rollover: None,
360            keepwarm: None,
361            ..round.options.clone()
362        };
363        let max_tokens = match round.thinking_budget {
364            0 => super::keepwarm::KEEPWARM_OUTPUT_TOKENS,
365            budget => u64::from(budget) + super::keepwarm::KEEPWARM_OUTPUT_TOKENS,
366        };
367        let job = Job {
368            auth: Arc::clone(round.auth),
369            client: round.client.clone(),
370            model: round.model.to_string(),
371            tools: round.tools.clone(),
372            system_prompt: round.system_prompt.clone(),
373            thinking_budget: round.thinking_budget,
374            reasoning_level: round.reasoning_level,
375            messages,
376            options,
377            telemetry_level: round.telemetry_level,
378            anchor,
379            anchor_cell,
380            ttl,
381            interval,
382            prefix_tokens,
383            max_tokens,
384            usage: Arc::clone(turn.usage),
385            max_cost_usd: turn.max_cost_usd,
386            tx: turn.tx.clone(),
387        };
388        let controls = Controls {
389            stop: CancellationToken::new(),
390            abort: turn.cancel.child_token(),
391            in_flight: Arc::default(),
392        };
393        let (stop, abort, in_flight) = (
394            controls.stop.clone(),
395            controls.abort.clone(),
396            Arc::clone(&controls.in_flight),
397        );
398        let handle = tokio::spawn(job.run(controls));
399        Some(Self {
400            stop,
401            abort,
402            in_flight,
403            grace: settings.finish_grace(),
404            #[cfg(test)]
405            hook: settings.finish_hook.clone(),
406            handle: Some(handle),
407        })
408    }
409
410    /// The round's tools are done: schedule no new ping, let one in flight
411    /// finish (up to the grace, then abort it), and wait until it is booked.
412    /// Returns why the pings stopped.
413    pub(super) async fn finish(mut self) -> Stop {
414        self.stop.cancel();
415        let Some(mut handle) = self.handle.take() else {
416            return Stop::Done;
417        };
418        if self.in_flight.load(Ordering::Acquire) {
419            tracing::debug!(
420                event = "tool_round_keepwarm_drain",
421                "tool round done with a keep-warm ping in flight; letting it finish"
422            );
423            #[cfg(test)]
424            if let Some(hook) = &self.hook {
425                hook.notify_one();
426            }
427        }
428        match tokio::time::timeout(self.grace, &mut handle).await {
429            Ok(stop) => stop.unwrap_or(Stop::Done),
430            Err(_) => {
431                tracing::info!(
432                    event = "tool_round_keepwarm_grace_expired",
433                    grace_ms = self.grace.as_millis() as u64,
434                    "tool-round keep-warm ping still in flight at the end of the round; aborting"
435                );
436                self.abort.cancel();
437                handle.await.unwrap_or(Stop::Done)
438            }
439        }
440    }
441}
442
443fn over_share(tokens: u64, prefix: u64) -> bool {
444    tokens.saturating_mul(100) > prefix.saturating_mul(SHARE_LIMIT_PERCENT)
445}
446
447impl Job {
448    fn elapsed(&self) -> Duration {
449        let mono = Instant::now().saturating_duration_since(self.anchor.mono);
450        let wall = SystemTime::now()
451            .duration_since(self.anchor.wall)
452            .unwrap_or_default();
453        mono.max(wall)
454    }
455
456    /// Sleep until the next ping is due (either clock), or `false` once
457    /// stopped or aborted.
458    async fn wait_due(&self, c: &Controls) -> bool {
459        loop {
460            if c.stop.is_cancelled() || c.abort.is_cancelled() {
461                return false;
462            }
463            if self.elapsed() >= self.interval {
464                return true;
465            }
466            let due = self.anchor.mono + self.interval;
467            let wake = due.min(Instant::now() + WALL_POLL);
468            tokio::select! {
469                _ = c.stop.cancelled() => return false,
470                _ = c.abort.cancelled() => return false,
471                _ = tokio::time::sleep_until(wake) => {}
472            }
473        }
474    }
475
476    /// Book a ping that may have been billed but reported no usage (aborted
477    /// in flight, a read timeout after dispatch) at its worst case, into the
478    /// turn's counters and event channel — where a measured ping's usage
479    /// goes. A measured ping is already booked by the transport.
480    fn book_unmeasured(&self, bill: &super::keepwarm::PingBill) {
481        if bill.booking != PingBooking::WorstCase {
482            return;
483        }
484        let (input, output, read) = (PING_INPUT_ESTIMATE, self.max_tokens, self.prefix_tokens);
485        self.usage
486            .record_priced(&self.model, input, output, read, 0, None, None);
487        let _ = self.tx.send(StreamEvent::Session(SessionEvent::Usage {
488            input_tokens: input,
489            output_tokens: output,
490            cache_read_input_tokens: read,
491            cache_creation_input_tokens: 0,
492            cache_creation_5m: None,
493            cache_creation_1h: None,
494            model: Some(self.model.clone()),
495        }));
496        tracing::info!(
497            event = "tool_round_keepwarm_unmeasured",
498            model = %self.model,
499            cost_usd = bill.cost_usd,
500            "tool-round keep-warm ping dispatched without usage; booked at its worst case"
501        );
502    }
503
504    fn ping_cost(&self, output: u64) -> f64 {
505        request_cost_usd(
506            &self.model,
507            self.ttl,
508            self.prefix_tokens,
509            0,
510            PING_INPUT_ESTIMATE,
511            output,
512        )
513    }
514
515    async fn run(mut self, controls: Controls) -> Stop {
516        let stop = self.pings(&controls).await;
517        if stop != Stop::Done {
518            tracing::info!(
519                event = "tool_round_keepwarm_stop",
520                model = %self.model,
521                reason = %stop,
522                "tool-round keep-warm stopped"
523            );
524        }
525        stop
526    }
527
528    async fn pings(&mut self, c: &Controls) -> Stop {
529        let rewrite = rewrite_cost_usd(&self.model, self.ttl, self.prefix_tokens);
530        let worst = self.ping_cost(self.max_tokens);
531        let mut expected = self.ping_cost(ping_output_estimate(self.max_tokens));
532        let mut ledger = 0.0;
533        let mut retries = 0usize;
534        let mut pings = 0u32;
535        loop {
536            if !self.wait_due(c).await {
537                return Stop::Done;
538            }
539            // Test intervals are far below the TTL; production ones leave
540            // more than LATE_GUARD.
541            if self.elapsed() + LATE_GUARD >= governing_ttl(self.ttl) {
542                return Stop::Expired;
543            }
544            if ledger + expected > rewrite {
545                return Stop::Bound;
546            }
547            if let Some(cap) = self.max_cost_usd.filter(|c| c.is_finite()) {
548                if self.usage.cost_usd() + worst > cap {
549                    return Stop::TurnCost;
550                }
551            }
552            // A ping never selects, switches or re-binds an account (the
553            // auth state is the turn's): it stops if its account is cooling.
554            if let Err(e) = super::auth::AuthMethods::refresh_if_needed(
555                Arc::clone(&self.auth),
556                &self.client,
557                &self.options.credential_source,
558                &self.options.token_cache,
559                Some(&self.model),
560                super::auth::RefreshPoint::KeepWarm,
561            )
562            .await
563            {
564                return Stop::Failed(format!("auth refresh: {e}"));
565            }
566            // The tools may have returned during the refresh.
567            if c.stop.is_cancelled() || c.abort.is_cancelled() {
568                return Stop::Done;
569            }
570            let tap = PingTap::default();
571            let sent_at = CacheAnchor::now();
572            c.in_flight.store(true, Ordering::Release);
573            let result = self.ping(&c.abort, &tap).await;
574            c.in_flight.store(false, Ordering::Release);
575            let bill = tap.bill(&self.model, worst);
576            ledger += bill.cost_usd;
577            self.book_unmeasured(&bill);
578            match result {
579                Ok(()) => {
580                    pings += 1;
581                    retries = 0;
582                    let u = &bill.usage;
583                    tracing::info!(
584                        event = "tool_round_keepwarm_ping",
585                        model = %self.model,
586                        ping = pings,
587                        cache_read = u.cache_read_tokens,
588                        cache_write = u.cache_write_tokens,
589                        input = u.input_tokens,
590                        output = u.output_tokens,
591                        cost_usd = bill.cost_usd,
592                        ledger_usd = ledger,
593                        rewrite_usd = rewrite,
594                        "tool-round keep-warm ping"
595                    );
596                    if u.cache_read_tokens == 0
597                        || over_share(u.cache_write_tokens, self.prefix_tokens)
598                        || over_share(u.input_tokens, self.prefix_tokens)
599                    {
600                        return Stop::Miss;
601                    }
602                    expected = bill.cost_usd;
603                    self.anchor = self
604                        .anchor_cell
605                        .get()
606                        .filter(|a| a.mono >= sent_at.mono)
607                        .unwrap_or(sent_at);
608                }
609                Err(PingFailure::Canceled) => {
610                    tracing::info!(
611                        event = "tool_round_keepwarm_aborted",
612                        booking = ?bill.booking,
613                        cost_usd = bill.cost_usd,
614                        "tool-round keep-warm ping aborted in flight"
615                    );
616                    return Stop::Done;
617                }
618                Err(PingFailure::Transient(why)) if retries < BACKOFF.len() => {
619                    tracing::debug!(%why, retry = retries + 1, "tool-round keep-warm ping: transient failure");
620                    let delay = BACKOFF[retries];
621                    retries += 1;
622                    tokio::select! {
623                        _ = c.stop.cancelled() => return Stop::Done,
624                        _ = c.abort.cancelled() => return Stop::Done,
625                        _ = tokio::time::sleep(delay) => {}
626                    }
627                }
628                Err(failure) => return Stop::Failed(failure.to_string()),
629            }
630        }
631    }
632
633    /// Send one ping: the round's request plus the ping text.
634    async fn ping(&self, cancel: &CancellationToken, tap: &PingTap) -> Result<(), PingFailure> {
635        let options = ApiOptions {
636            keepwarm: Some(KeepWarmRequest {
637                mode: KeepWarmMode::Replay,
638                status: Arc::clone(&tap.status),
639                sent: Arc::clone(&tap.sent),
640                connect_failed: Arc::clone(&tap.connect_failed),
641            }),
642            ..self.options.clone()
643        };
644        let (tx, mut rx) = mpsc::unbounded_channel();
645        let call = ApiMethods::call_api_stream_inner(
646            &self.auth,
647            &self.client,
648            &self.model,
649            &self.tools,
650            &self.system_prompt,
651            self.thinking_budget,
652            self.reasoning_level,
653            &self.messages,
654            tx,
655            cancel,
656            0,
657            0,
658            &options,
659            self.telemetry_level,
660        );
661        let (result, _notices) =
662            super::keepwarm::drive_ping(call, &mut rx, tap, Some(&self.tx)).await;
663        match result {
664            Ok(value) if value.get("keepwarm_prefix").is_none() => Ok(()),
665            Ok(_) => Err(PingFailure::Fatal("ping was not sent".into())),
666            Err(e) => Err(super::keepwarm::classify_failure(
667                &e,
668                tap.status.load(std::sync::atomic::Ordering::Relaxed),
669            )),
670        }
671    }
672}