polyc-agent 2026.8.3

The agent turn loop: provider + tool-call routing, shared by the control plane and harness.
//! Prometheus metric for per-turn prompt-cache effectiveness.
//!
//! Registered into the process default registry — same pattern as
//! `polyc-llm`'s `metrics.rs` (its `call_duration`): no separate scrape
//! endpoint, no separate registry plumbing. Fires wherever
//! [`crate::step::TurnCtx`] actually drives a turn to completion — the
//! harness process (harness-dialed turns, via `run_turn`/
//! `run_turn_captured`) and the control plane's in-process dev/no-harness
//! path (`run_turn_with`) both do, so both call [`crate::init_metrics`].

use std::sync::OnceLock;

use polyc_llm::Usage;
use prometheus::{IntCounterVec, register_int_counter_vec};

/// Per-turn prompt token counts, labeled `token_kind`:
///
/// - `input` — the full prompt-token count a turn billed
///   ([`Usage::input_tokens`], folded across every provider call the turn's
///   tool-calling loop made).
/// - `cache_read` — the subset of `input` the provider's cache satisfied
///   ([`Usage::cache_read_input_tokens`]).
/// - `cache_creation` — the subset of `input` the provider wrote into its
///   cache on this turn ([`Usage::cache_creation_input_tokens`]).
///
/// Cache effectiveness is `cache_read / input`, computed at query time (a
/// `rate()` division in `PromQL`) rather than published here as a
/// pre-computed ratio — averaging ratios across turns of very different
/// prompt sizes is unsound; exposing the two counters lets the query
/// weight correctly. Zero for every label when a turn's provider reports no
/// caching, which is honest: CONF-19 requires the effectiveness be
/// *observable*, not that it be nonzero.
fn turn_prompt_tokens() -> &'static IntCounterVec {
    static V: OnceLock<IntCounterVec> = OnceLock::new();
    V.get_or_init(|| {
        register_int_counter_vec!(
            "polychrome_turn_prompt_tokens_total",
            "Per-turn prompt token counts by kind (input, cache_read, cache_creation); \
             prompt-cache effectiveness is cache_read / input.",
            &["token_kind"]
        )
        .expect("register polychrome_turn_prompt_tokens_total")
    })
}

/// Record one turn's folded [`Usage`] once it is complete.
///
/// Called from [`crate::step::TurnCtx::finish`] — the single choke point
/// every [`crate::TurnResult`] passes through (including the
/// `finish_failed` mid-stream-failure path, which calls `finish`
/// internally) — so this runs exactly once per turn regardless of which
/// return site produced the result, and reflects whatever the loop
/// actually billed even when the turn iterated the provider more than once.
pub(crate) fn record_turn(usage: &Usage) {
    let counters = turn_prompt_tokens();
    counters
        .with_label_values(&["input"])
        .inc_by(usage.input_tokens);
    counters
        .with_label_values(&["cache_read"])
        .inc_by(usage.cache_read_input_tokens);
    counters
        .with_label_values(&["cache_creation"])
        .inc_by(usage.cache_creation_input_tokens);
}

/// Force-register [`turn_prompt_tokens`] with every `token_kind` label
/// pre-created (zero-valued), so it appears in a `/metrics` scrape before
/// any turn completes. See [`crate::init_metrics`].
///
/// An `IntCounterVec` produces NO scrape output for a label combination
/// that has never been touched — registering the vec alone is not enough.
/// `with_label_values` creates the zero-valued child without recording an
/// observation, which is what makes it appear.
pub(crate) fn force() {
    for kind in ["input", "cache_read", "cache_creation"] {
        turn_prompt_tokens().with_label_values(&[kind]);
    }
}

#[cfg(test)]
mod tests {
    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]

    use polyc_llm::Usage;
    use prometheus::{Encoder as _, TextEncoder};

    use super::record_turn;

    /// A turn's folded usage lands in the shared counter vec, split by
    /// `token_kind`, and the cache-read count stays a strict subset of the
    /// input count in the scrape (never double-counted as an addition).
    #[test]
    fn record_turn_is_visible_in_a_registry_scrape() {
        record_turn(&Usage {
            input_tokens: 1000,
            output_tokens: 42,
            cache_read_input_tokens: 800,
            cache_creation_input_tokens: 100,
        });
        let mut buf = Vec::new();
        TextEncoder::new()
            .encode(&prometheus::default_registry().gather(), &mut buf)
            .expect("encode");
        let text = String::from_utf8(buf).expect("utf8");
        assert!(
            text.contains("polychrome_turn_prompt_tokens_total"),
            "missing counter in scrape:\n{text}"
        );
        assert!(text.contains("token_kind=\"input\""));
        assert!(text.contains("token_kind=\"cache_read\""));
        assert!(text.contains("token_kind=\"cache_creation\""));
    }

    /// A turn with no cache read (the common case for a provider that
    /// doesn't cache) still increments `input`, keeping the denominator
    /// live for the effectiveness ratio even when the numerator is zero.
    #[test]
    fn record_turn_with_no_cache_read_still_counts_input() {
        record_turn(&Usage {
            input_tokens: 500,
            output_tokens: 10,
            cache_read_input_tokens: 0,
            cache_creation_input_tokens: 0,
        });
        let mut buf = Vec::new();
        TextEncoder::new()
            .encode(&prometheus::default_registry().gather(), &mut buf)
            .expect("encode");
        let text = String::from_utf8(buf).expect("utf8");
        assert!(text.contains("polychrome_turn_prompt_tokens_total"));
    }
}