Skip to main content

yoagent/
types.rs

1use serde::{Deserialize, Serialize};
2use std::fmt;
3use std::sync::Arc;
4
5// ---------------------------------------------------------------------------
6// Content types
7// ---------------------------------------------------------------------------
8
9/// Content block of a message.
10///
11/// Exhaustiveness policy (two separate levers):
12/// - The **enum** is `#[non_exhaustive]`: new content kinds may be added in
13///   minor releases, so downstream `match` arms need a wildcard.
14/// - The `ToolCall` and `Thinking` **variants** are separately
15///   `#[non_exhaustive]`: their fields grow with provider features (PR #32
16///   added `provider_metadata`), so downstream constructs them via the
17///   `Content::tool_call*` / `Content::thinking*` constructors and uses `..`
18///   in patterns. `Text` and `Image` stay literally constructible — they are
19///   user-facing shapes that do not grow.
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
21#[serde(tag = "type")]
22#[non_exhaustive]
23pub enum Content {
24    #[serde(rename = "text")]
25    Text { text: String },
26    #[serde(rename = "image")]
27    Image {
28        data: String,
29        #[serde(rename = "mimeType")]
30        mime_type: String,
31    },
32    #[serde(rename = "thinking")]
33    #[non_exhaustive]
34    Thinking {
35        thinking: String,
36        #[serde(skip_serializing_if = "Option::is_none")]
37        signature: Option<String>,
38        /// Reasoning the provider returned encrypted instead of as text:
39        /// Anthropic's `redacted_thinking` `data`, or Amazon Bedrock's
40        /// `redactedContent` (base64) — `thinking` is empty for such a
41        /// block — or an OpenAI Responses / Azure reasoning item (its `id`,
42        /// `summary` and `encrypted_content` as a JSON object, plus the ids of
43        /// the output items that followed it: `call_ids` and `message_id`),
44        /// whose readable summary, if any, stays in `thinking`.
45        /// It is opaque: replayed unmodified, and only to the protocol named
46        /// by `redacted_protocol`; every other provider skips it.
47        #[serde(default, skip_serializing_if = "Option::is_none")]
48        redacted: Option<String>,
49        /// The API protocol whose response carried `redacted`, so the
50        /// payload is replayed only where it came from. Encrypted reasoning
51        /// from one API is not documented to be valid on another (Bedrock's
52        /// `redactedContent` is not documented to be Anthropic's
53        /// `redacted_thinking` data, even for Claude on Bedrock), so a
54        /// mismatch — or `None`, which only deserialized data written
55        /// before this field existed can have — is never sent anywhere.
56        /// Set together with `redacted` by [`Content::thinking_redacted`].
57        ///
58        /// An unrecognised value (a protocol added in a newer yoagent) loads
59        /// as `None` rather than failing the whole message.
60        #[serde(
61            default,
62            skip_serializing_if = "Option::is_none",
63            rename = "redactedProtocol",
64            alias = "redacted_protocol",
65            deserialize_with = "lenient_protocol"
66        )]
67        redacted_protocol: Option<crate::provider::ApiProtocol>,
68    },
69    #[serde(rename = "toolCall")]
70    #[non_exhaustive]
71    ToolCall {
72        id: String,
73        name: String,
74        arguments: serde_json::Value,
75        /// Provider-specific metadata (e.g. Gemini thought signatures).
76        /// Not passed to tool execution; used by providers when building
77        /// the next request.
78        #[serde(
79            default,
80            skip_serializing_if = "Option::is_none",
81            rename = "providerMetadata",
82            alias = "provider_metadata"
83        )]
84        provider_metadata: Option<serde_json::Value>,
85    },
86}
87
88/// `redacted_protocol`: an unknown protocol (written by a newer yoagent,
89/// `ApiProtocol` being `#[non_exhaustive]`) deserializes as `None`, which is
90/// never replayed, instead of failing the whole message.
91fn lenient_protocol<'de, D>(
92    deserializer: D,
93) -> Result<Option<crate::provider::ApiProtocol>, D::Error>
94where
95    D: serde::Deserializer<'de>,
96{
97    let value = <Option<serde_json::Value> as serde::Deserialize>::deserialize(deserializer)?;
98    Ok(value.and_then(|v| serde_json::from_value(v).ok()))
99}
100
101impl Content {
102    /// Construct a tool-call content block.
103    ///
104    /// The `ToolCall` variant is `#[non_exhaustive]` so provider-specific
105    /// fields can be added without breaking downstream crates — use this
106    /// constructor instead of a struct literal.
107    pub fn tool_call(
108        id: impl Into<String>,
109        name: impl Into<String>,
110        arguments: serde_json::Value,
111    ) -> Self {
112        Self::ToolCall {
113            id: id.into(),
114            name: name.into(),
115            arguments,
116            provider_metadata: None,
117        }
118    }
119
120    /// Construct a thinking content block without a signature.
121    pub fn thinking(text: impl Into<String>) -> Self {
122        Self::Thinking {
123            thinking: text.into(),
124            signature: None,
125            redacted: None,
126            redacted_protocol: None,
127        }
128    }
129
130    /// Construct a redacted (provider-encrypted) thinking block: no text,
131    /// the opaque `data` kept for replay to `protocol` — the API whose
132    /// response carried it — and to no other.
133    ///
134    /// ```
135    /// use yoagent::provider::ApiProtocol;
136    /// use yoagent::types::Content;
137    ///
138    /// let block = Content::thinking_redacted(ApiProtocol::AnthropicMessages, "EmwKAhgB...");
139    /// assert!(matches!(
140    ///     block,
141    ///     Content::Thinking { redacted_protocol: Some(ApiProtocol::AnthropicMessages), .. }
142    /// ));
143    /// ```
144    pub fn thinking_redacted(
145        protocol: crate::provider::ApiProtocol,
146        data: impl Into<String>,
147    ) -> Self {
148        Self::Thinking {
149            thinking: String::new(),
150            signature: None,
151            redacted: Some(data.into()),
152            redacted_protocol: Some(protocol),
153        }
154    }
155
156    /// The encrypted reasoning `protocol` may be sent back, if this is a
157    /// redacted thinking block that came from `protocol`. `None` for any
158    /// other block, and for redacted data from another protocol (or of
159    /// unknown origin), which must not be replayed there.
160    pub(crate) fn redacted_for(&self, protocol: crate::provider::ApiProtocol) -> Option<&str> {
161        match self {
162            Self::Thinking {
163                redacted: Some(data),
164                redacted_protocol: Some(p),
165                ..
166            } if *p == protocol => Some(data),
167            _ => None,
168        }
169    }
170
171    /// Construct a thinking content block with a provider signature.
172    pub fn thinking_signed(text: impl Into<String>, signature: impl Into<String>) -> Self {
173        Self::Thinking {
174            thinking: text.into(),
175            signature: Some(signature.into()),
176            redacted: None,
177            redacted_protocol: None,
178        }
179    }
180
181    /// Construct a tool-call content block carrying provider metadata
182    /// (e.g. a Gemini thought signature).
183    pub fn tool_call_with_metadata(
184        id: impl Into<String>,
185        name: impl Into<String>,
186        arguments: serde_json::Value,
187        provider_metadata: serde_json::Value,
188    ) -> Self {
189        Self::ToolCall {
190            id: id.into(),
191            name: name.into(),
192            arguments,
193            provider_metadata: Some(provider_metadata),
194        }
195    }
196}
197
198// ---------------------------------------------------------------------------
199// Messages
200// ---------------------------------------------------------------------------
201
202#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
203#[serde(tag = "role")]
204pub enum Message {
205    #[serde(rename = "user")]
206    User {
207        content: Vec<Content>,
208        timestamp: u64,
209    },
210    #[serde(rename = "assistant")]
211    #[non_exhaustive]
212    Assistant {
213        content: Vec<Content>,
214        #[serde(rename = "stopReason")]
215        stop_reason: StopReason,
216        model: String,
217        provider: String,
218        usage: Usage,
219        timestamp: u64,
220        #[serde(
221            skip_serializing_if = "Option::is_none",
222            rename = "errorMessage",
223            alias = "error_message"
224        )]
225        error_message: Option<String>,
226    },
227    #[serde(rename = "toolResult")]
228    ToolResult {
229        #[serde(rename = "toolCallId")]
230        tool_call_id: String,
231        #[serde(rename = "toolName")]
232        tool_name: String,
233        content: Vec<Content>,
234        #[serde(rename = "isError")]
235        is_error: bool,
236        timestamp: u64,
237    },
238}
239
240impl Message {
241    pub fn user(text: impl Into<String>) -> Self {
242        Self::User {
243            content: vec![Content::Text { text: text.into() }],
244            timestamp: now_ms(),
245        }
246    }
247
248    /// Construct an assistant message.
249    ///
250    /// The `Assistant` variant is `#[non_exhaustive]` — its fields grow with
251    /// provider features (`error_message` was itself a later addition), so
252    /// custom `StreamProvider` implementations construct it here instead of
253    /// with a struct literal. `timestamp` is set to now and `error_message`
254    /// to `None`; use [`Message::with_error_message`] /
255    /// [`Message::with_timestamp`] to override.
256    pub fn assistant(
257        content: Vec<Content>,
258        stop_reason: StopReason,
259        model: impl Into<String>,
260        provider: impl Into<String>,
261        usage: Usage,
262    ) -> Self {
263        Self::Assistant {
264            content,
265            stop_reason,
266            model: model.into(),
267            provider: provider.into(),
268            usage,
269            timestamp: now_ms(),
270            error_message: None,
271        }
272    }
273
274    /// Set the error message (no-op on non-assistant messages).
275    pub fn with_error_message(mut self, msg: impl Into<String>) -> Self {
276        if let Self::Assistant { error_message, .. } = &mut self {
277            *error_message = Some(msg.into());
278        }
279        self
280    }
281
282    /// Override the timestamp (applies to all message kinds).
283    pub fn with_timestamp(mut self, ts: u64) -> Self {
284        match &mut self {
285            Self::User { timestamp, .. }
286            | Self::Assistant { timestamp, .. }
287            | Self::ToolResult { timestamp, .. } => *timestamp = ts,
288        }
289        self
290    }
291
292    pub fn role(&self) -> &str {
293        match self {
294            Self::User { .. } => "user",
295            Self::Assistant { .. } => "assistant",
296            Self::ToolResult { .. } => "toolResult",
297        }
298    }
299
300    /// Check if this assistant message represents a context overflow error.
301    ///
302    /// Some providers (SSE-based: Anthropic, OpenAI) return overflow as a
303    /// `StopReason::Error` message rather than an HTTP error. This method
304    /// checks the `error_message` field against known overflow patterns, and
305    /// recognizes a turn the loop ended with a [`ProviderError::ContextOverflow`]
306    /// (including one classified from an empty 400/413 body, which has no
307    /// overflow phrase in it).
308    ///
309    /// [`ProviderError::ContextOverflow`]: crate::provider::ProviderError::ContextOverflow
310    pub fn is_context_overflow(&self) -> bool {
311        match self {
312            Self::Assistant {
313                stop_reason: StopReason::Error,
314                error_message: Some(msg),
315                ..
316            } => {
317                msg.starts_with(crate::provider::traits::CONTEXT_OVERFLOW_DISPLAY_PREFIX)
318                    || crate::provider::is_context_overflow_message(msg)
319            }
320            _ => false,
321        }
322    }
323}
324
325// ---------------------------------------------------------------------------
326// AgentMessage — LLM messages + extensible custom types
327// ---------------------------------------------------------------------------
328
329/// An app-specific message kept in the history but never sent to the model:
330/// UI-only notes, notifications, bookkeeping. Named `ExtensionMessage`
331/// before 0.25 (renamed so it isn't confused with
332/// [`Extension`](crate::Extension), the plug-in contract).
333#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
334pub struct CustomMessage {
335    pub role: String,
336    pub kind: String,
337    pub data: serde_json::Value,
338}
339
340impl CustomMessage {
341    /// A message of `kind` carrying `data`. Its `role` is `"extension"`, the
342    /// value stored sessions already hold, so saved data reads back the same.
343    pub fn new(kind: impl Into<String>, data: impl Serialize) -> Self {
344        Self {
345            role: "extension".into(),
346            kind: kind.into(),
347            data: serde_json::to_value(data).unwrap_or(serde_json::Value::Null),
348        }
349    }
350}
351
352#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
353#[serde(untagged)]
354pub enum AgentMessage {
355    /// Standard LLM message
356    Llm(Message),
357    /// App-specific message (UI-only, notifications, etc.). Called
358    /// `Extension` before 0.25; the variant name is never serialized
359    /// (`untagged`), so stored sessions are unaffected.
360    Custom(CustomMessage),
361}
362
363/// The pre-0.25 name of [`CustomMessage`].
364#[deprecated(since = "0.25.0", note = "renamed to CustomMessage")]
365pub type ExtensionMessage = CustomMessage;
366
367impl AgentMessage {
368    pub fn role(&self) -> &str {
369        match self {
370            Self::Llm(m) => m.role(),
371            Self::Custom(custom) => &custom.role,
372        }
373    }
374
375    pub fn as_llm(&self) -> Option<&Message> {
376        match self {
377            Self::Llm(m) => Some(m),
378            Self::Custom(_) => None,
379        }
380    }
381}
382
383impl From<Message> for AgentMessage {
384    fn from(m: Message) -> Self {
385        Self::Llm(m)
386    }
387}
388
389// ---------------------------------------------------------------------------
390// Stop reasons & usage
391// ---------------------------------------------------------------------------
392
393/// Why the model stopped generating.
394///
395/// `#[non_exhaustive]` since 0.17.0: stop reasons grow with provider features,
396/// so every addition was otherwise a breaking release. Downstream `match` arms
397/// need a `_ =>` wildcard; inside this crate the enum is still exhaustive, so
398/// adding a variant remains a compile error where it matters most.
399#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
400#[serde(rename_all = "camelCase")]
401#[non_exhaustive]
402pub enum StopReason {
403    Stop,
404    Length,
405    ToolUse,
406    Error,
407    Aborted,
408    /// The provider's safety system declined the request. The stream completes
409    /// normally (HTTP 200) but with `stop_reason: refusal` and empty or partial
410    /// content; `error_message` carries an explanation. Currently emitted by
411    /// Anthropic models that support the `refusal` stop reason (e.g. Claude
412    /// Fable 5). The agent loop does not special-case it (the turn ends like a
413    /// normal `Stop`); callers can match on it to retry on a fallback model.
414    Refusal,
415}
416
417#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
418pub struct Usage {
419    pub input: u64,
420    pub output: u64,
421    // camelCase on the wire (AgentEvent contract); `alias` keeps session
422    // files written by yoagent < 0.13 loadable.
423    #[serde(default, rename = "cacheRead", alias = "cache_read")]
424    pub cache_read: u64,
425    #[serde(default, rename = "cacheWrite", alias = "cache_write")]
426    pub cache_write: u64,
427    #[serde(default, rename = "totalTokens", alias = "total_tokens")]
428    pub total_tokens: u64,
429}
430
431impl Usage {
432    /// Fraction of input tokens served from cache (0.0–1.0).
433    /// Returns 0.0 if no input tokens were processed.
434    pub fn cache_hit_rate(&self) -> f64 {
435        let total_input = self.input + self.cache_read + self.cache_write;
436        if total_input == 0 {
437            return 0.0;
438        }
439        self.cache_read as f64 / total_input as f64
440    }
441}
442
443// ---------------------------------------------------------------------------
444// Cache configuration
445// ---------------------------------------------------------------------------
446
447/// Controls yoagent-managed prompt caching hints.
448///
449/// By default, caching is enabled with automatic breakpoint placement. What
450/// that produces on the wire depends on the protocol — see [`CacheStrategy`]
451/// for the per-provider table. `enabled: false` suppresses every hint yoagent
452/// would otherwise send; it cannot switch off a provider's *automatic*
453/// server-side caching, which is not under client control.
454#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
455#[non_exhaustive]
456pub struct CacheConfig {
457    /// Master switch — set to false to disable all caching hints.
458    /// Default: true.
459    pub enabled: bool,
460    /// How cache breakpoints are placed.
461    pub strategy: CacheStrategy,
462    /// Stable identifier for this conversation, for providers that route cache
463    /// lookups by key rather than by explicit breakpoints (OpenAI's
464    /// `prompt_cache_key`).
465    ///
466    /// Leave `None` and one is derived from the system prompt (see
467    /// [`StreamConfig::cache_session_key`](crate::provider::StreamConfig::cache_session_key)).
468    /// That derivation is correct for the common case and costs nothing, but
469    /// sessions sharing a system prompt share a key. Set this explicitly when
470    /// sessions must be routed apart, or to spread a hot key.
471    ///
472    /// Read only by the key-routed protocols — OpenAI Chat Completions (when
473    /// the compat flag allows it), the OpenAI Responses API and Azure OpenAI.
474    /// Ignored by Anthropic, which takes explicit breakpoints, and by Google,
475    /// Vertex and Bedrock, which yoagent sends no cache hints to.
476    ///
477    /// Note for [`crate::SubAgentTool`]: it holds one `CacheConfig` and clones
478    /// it into every invocation, so a key set there is shared by every run the
479    /// tool performs rather than identifying one conversation.
480    #[serde(default)]
481    pub session_key: Option<String>,
482}
483
484impl CacheConfig {
485    /// Caching enabled with automatic breakpoint placement.
486    ///
487    /// Identical to [`Default::default`]; provided because this struct is
488    /// `#[non_exhaustive]` and `new()` is where callers look first.
489    pub fn new() -> Self {
490        Self::default()
491    }
492
493    /// All caching hints suppressed.
494    pub fn disabled() -> Self {
495        Self {
496            enabled: false,
497            ..Self::default()
498        }
499    }
500
501    /// Set the session key used by key-routed providers.
502    ///
503    /// A blank key is treated as unset — sending `prompt_cache_key: ""` would
504    /// route every caller who did that onto one cache, which is worse than
505    /// sending nothing. `with_session_key(format!("tenant-{id}"))` with an
506    /// empty `id` is the ordinary way to arrive here.
507    pub fn with_session_key(mut self, key: impl Into<String>) -> Self {
508        let key = key.into();
509        self.session_key = if key.trim().is_empty() {
510            None
511        } else {
512            Some(key)
513        };
514        self
515    }
516
517    /// Set the breakpoint-placement strategy.
518    ///
519    /// Needed because this struct is `#[non_exhaustive]`: downstream crates
520    /// cannot use a struct literal, so `Manual { .. }` would otherwise be
521    /// unreachable from outside.
522    pub fn with_strategy(mut self, strategy: CacheStrategy) -> Self {
523        self.strategy = strategy;
524        self
525    }
526
527    /// Whether yoagent should emit any caching hint at all.
528    ///
529    /// Three configurations mean "no": `enabled: false`, `Disabled`, and
530    /// `Manual` with every flag off. The third is easy to miss — Anthropic
531    /// honours it correctly by placing no breakpoints, so a key-routed
532    /// provider that still sent a key would be reading the same value as
533    /// "yes". One predicate, so every protocol agrees on what off means.
534    pub fn hints_enabled(&self) -> bool {
535        if !self.enabled {
536            return false;
537        }
538        !matches!(
539            self.strategy,
540            CacheStrategy::Disabled
541                | CacheStrategy::Manual {
542                    cache_system: false,
543                    cache_tools: false,
544                    cache_messages: false,
545                }
546        )
547    }
548}
549
550impl Default for CacheConfig {
551    fn default() -> Self {
552        Self {
553            enabled: true,
554            strategy: CacheStrategy::Auto,
555            session_key: None,
556        }
557    }
558}
559
560// ---------------------------------------------------------------------------
561// Tool execution strategy
562// ---------------------------------------------------------------------------
563
564/// Controls how multiple tool calls from a single LLM response are executed.
565///
566/// When the LLM returns multiple tool calls (e.g., "read file A, read file B,
567/// run bash C"), this determines whether they run sequentially or in parallel.
568#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
569#[serde(tag = "type", rename_all = "camelCase")]
570pub enum ToolExecutionStrategy {
571    /// Run tools one at a time, check steering between each.
572    /// Use for debugging or tools with shared mutable state.
573    Sequential,
574    /// Run all tool calls concurrently, check steering after all complete.
575    /// Default — most tool calls are independent and this gives the best latency.
576    #[default]
577    Parallel,
578    /// Run in batches of N, check steering between batches.
579    /// Balances speed with human-in-the-loop control.
580    Batched { size: usize },
581}
582
583/// Strategy for prompt caching.
584///
585/// Providers expose caching in two different shapes, and this enum means
586/// something different in each:
587///
588/// | provider | shape | what this enum controls |
589/// |---|---|---|
590/// | Anthropic | explicit breakpoints | where `cache_control` markers are placed |
591/// | OpenAI (native), OpenAI Responses, Azure | key-routed | whether `prompt_cache_key` is sent |
592/// | DeepSeek, Gemini, and other automatic backends | automatic, server-side | nothing |
593/// | Bedrock | supported but **not yet wired** | nothing *yet* |
594///
595/// **Explicit breakpoints** (Anthropic) are the model this enum was designed
596/// around: the client chooses cache boundaries and pays a write premium for
597/// them. [`Auto`](Self::Auto) and [`Manual`](Self::Manual) select which
598/// boundaries.
599///
600/// **Key-routed** (OpenAI) caches automatically on prefixes of ~1024 tokens or
601/// more; there are no breakpoints to place. `prompt_cache_key` only improves
602/// *routing* — it steers requests from one conversation toward the same cache
603/// — so the `Auto`/`Manual` distinction has nothing to act on and both send the
604/// key. Only [`Disabled`](Self::Disabled) is meaningful. The key comes from
605/// [`CacheConfig::session_key`], or is derived from the system prompt. On
606/// Chat Completions it is gated on
607/// [`OpenAiCompat::supports_prompt_cache_key`](crate::provider::OpenAiCompat),
608/// because the field is OpenAI's and a strict compat server may reject unknown
609/// keys outright rather than ignore them; the Responses API (OpenAI and Azure)
610/// defines the field itself, so it is always sent there.
611///
612/// **Automatic, server-side** (DeepSeek, Gemini) caches on its own with nothing
613/// to configure. DeepSeek quantises hits to 64-token blocks and charges nothing
614/// to populate; Gemini reports `cachedContentTokenCount` but its explicit
615/// cached-content API is a separate create-then-reference resource with its own
616/// TTL and billing, deliberately not driven from here. For these, this setting
617/// is inert — including `Disabled`, which cannot switch off caching the client
618/// never asked for.
619///
620/// **Not yet wired** is a separate row on purpose. Bedrock accepts explicit
621/// `cachePoint` blocks; yoagent sends none today. That is a gap in this crate,
622/// not a property of the vendor, and conflating the two would make the
623/// omission read as a deliberate design decision.
624///
625/// A hit rate is not comparable across protocols without knowing which shape
626/// produced it. See `docs/concepts/prompt-caching.md`.
627#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
628#[serde(rename_all = "camelCase")]
629#[non_exhaustive]
630pub enum CacheStrategy {
631    /// Automatic placement (recommended).
632    ///
633    /// Anthropic: caches system prompt, tool definitions, and recent history.
634    /// OpenAI (Chat Completions and Responses) and Azure: sends
635    /// `prompt_cache_key`. Elsewhere: no effect.
636    #[default]
637    Auto,
638    /// Send no caching hints at all.
639    ///
640    /// Does **not** disable a provider's automatic server-side caching — that
641    /// is not client-controllable. On Anthropic this means paying full input
642    /// price for every prefix, so reach for it only when a rewrite-heavy
643    /// workload makes cache writes pure loss.
644    Disabled,
645    /// Fine-grained control over what gets cached.
646    ///
647    /// Anthropic-only in effect: no other protocol exposes placement. Treated
648    /// as [`Auto`](Self::Auto) by key-routed providers, which have one knob
649    /// rather than three.
650    Manual {
651        /// Cache the system prompt.
652        cache_system: bool,
653        /// Cache tool definitions.
654        cache_tools: bool,
655        /// Cache conversation history (second-to-last message).
656        cache_messages: bool,
657    },
658}
659
660// ---------------------------------------------------------------------------
661// Thinking level
662// ---------------------------------------------------------------------------
663
664/// How hard the model should reason before answering.
665///
666/// A provider-neutral ladder. Each provider maps it onto its own knob, and
667/// where a provider's ladder is shorter than this one the upper levels are
668/// **clamped** to the highest value this crate knows the provider accepts
669/// rather than sent as a value it would reject —
670/// with one exception: Anthropic's adaptive `effort` is passed through
671/// unclamped, so a model with a shorter effort ladder can reject it (see
672/// below). What each level becomes, per provider:
673///
674/// | Level     | Anthropic (adaptive) | Anthropic legacy / Bedrock budget | OpenAI-compat `reasoning_effort`¹ / OpenAI Responses / Azure `reasoning.effort`, by ceiling⁴ | DeepSeek `reasoning_effort`² | Gemini 2.x / Vertex `thinkingBudget`³ |
675/// |-----------|----------|--------|----------|--------|--------|
676/// | `Off`     | (omitted) | (omitted) | (omitted) | (omitted; `thinking: disabled`) | (omitted) |
677/// | `Minimal` | `low`    | 1,024  | `low`    | `low`  | 1,024  |
678/// | `Low`     | `low`    | 1,024  | `low`    | `low`  | 1,024  |
679/// | `Medium`  | `medium` | 2,048  | `medium` | `medium` (DeepSeek rounds up to `high`) | 8,192  |
680/// | `High`    | `high`   | 8,192  | `high`   | `high` | 24,576 |
681/// | `XHigh`   | `xhigh`  | 16,384 | `High`: `high` *(clamped)*; `XHigh`, `Max`: `xhigh` | `high` *(clamped)* | 24,576 *(clamped)* |
682/// | `Max`     | `max`    | 30,720 | `High`: `high` *(clamped)*; `XHigh`: `xhigh` *(clamped)*; `Max`: `max` | `max` | 24,576 *(clamped)* |
683///
684/// ¹ On Chat Completions, only when
685/// [`OpenAiCompat::supports_reasoning_effort`] is set; otherwise no
686/// `reasoning_effort` is sent. The Responses and Azure providers always send
687/// `reasoning.effort`, except for an omitted `Off`. Omitting it does not mean
688/// "no reasoning": an OpenAI reasoning model runs at its default (`medium`),
689/// and xAI's Grok cannot disable reasoning at all, so `Off` leaves it at its
690/// default (`high`). This crate never sends OpenAI's `none` rung, which some
691/// models reject with HTTP 400. A clamp (`XHigh`/`Max` sent lower) is logged
692/// once per process with `tracing::warn!`.
693///
694/// ⁴ The ceiling is [`OpenAiCompat::max_reasoning_effort`], a
695/// [`ReasoningEffortCeiling`] declared per model (default `High`); the
696/// Responses and Azure providers read it from `ModelConfig::compat`.
697/// Presets: `gpt_6_astra`, `gpt_6_sol`, `gpt_6_1_sol`, `gpt_6_luna` — `Max`; `gpt_5_5` —
698/// `XHigh`; [`OpenAiCompat::xai`] — `XHigh` (Grok models without the rung
699/// treat `xhigh` as `high`); every other config `High`.
700///
701/// ² "DeepSeek" means any OpenAI-compat provider with both
702/// [`OpenAiCompat::supports_thinking_control`] and
703/// [`OpenAiCompat::supports_reasoning_effort`] set. DeepSeek's
704/// `reasoning_effort` accepts `low`/`high`/`max`
705/// (<https://api-docs.deepseek.com/guides/thinking_mode>), and DeepSeek itself
706/// maps a requested `xhigh` to `high`, so `XHigh` is sent as `high` and only
707/// `Max` selects `max`. `Off` is sent as `thinking: {"type": "disabled"}`
708/// rather than as an effort value.
709///
710/// ³ Gemini 3 and later take `thinkingLevel` instead (never both — Gemini
711/// rejects a request carrying the two). The generation is read from the model
712/// id's version (`gemini-3*`, `gemini-3.8-flash`, `models/…`, Vertex resource
713/// paths); override with [`GoogleCompat::thinking_level`]:
714///
715/// | Level | Gemini 3+ / Vertex `thinkingLevel` |
716/// |-------|-----------|
717/// | `Off` | (omitted — the model runs at its **default** level; see below) |
718/// | `Minimal` | `MINIMAL`, or `LOW` *(clamped)* on 3.7 / 3.8 Flash, 3.x Pro and unlisted models |
719/// | `Low` | `LOW` |
720/// | `Medium` | `MEDIUM` |
721/// | `High`, `XHigh`, `Max` | `HIGH` (`XHigh`/`Max` *clamped*) |
722///
723/// `Off` does **not** disable thinking on Gemini 3: it omits
724/// `thinkingConfig`, so the model thinks at its own default — `HIGH` on
725/// 3.1 Pro, `MEDIUM` on 3.5–3.8 Flash, `MINIMAL` on Flash-Lite. Thinking
726/// cannot be turned off at all on 3 Pro / 3.1 Pro. Use `Minimal` to ask for
727/// the least thinking: `MINIMAL` matches "the "no thinking" setting for most
728/// queries" (Google) where the model accepts it. Image models take only the
729/// levels they list (3.1 Flash / Flash-Lite Image: `MINIMAL`, `HIGH` — `Low`
730/// rounds down, `Medium` up; 3 Pro Image: `HIGH` only), and Gemini 3 TTS
731/// models get no `thinkingConfig`.
732///
733/// Anthropic's adaptive `effort` is passed through as-is, so a model with a
734/// shorter ladder rejects what it does not know: `xhigh` arrived with Opus
735/// 4.7, so Opus 4.6 / Sonnet 4.6 accept `max` but not `xhigh`. The crate has
736/// no per-model effort table; pick a level the model supports.
737///
738/// **`Off` sends nothing** on every provider except DeepSeek (which gets an
739/// explicit `thinking: disabled`), and that means "no thinking" only on
740/// models that think on request. On Anthropic `Off` omits the `thinking`
741/// field (it never sends `disabled`): Claude Opus 5.5 and Fable 5.1 always
742/// think (at their default effort), and Opus 5 thinks whenever the field is
743/// absent. OpenAI reasoning models, Grok and Gemini 3 likewise run at their
744/// own defaults (see ¹ and ³).
745///
746/// Marked `#[non_exhaustive]` so the next rung a vendor adds is not a breaking
747/// change: `match` on it from outside the crate needs a wildcard arm.
748///
749/// [`OpenAiCompat::supports_thinking_control`]: crate::provider::OpenAiCompat::supports_thinking_control
750/// [`OpenAiCompat::supports_reasoning_effort`]: crate::provider::OpenAiCompat::supports_reasoning_effort
751/// [`OpenAiCompat::max_reasoning_effort`]: crate::provider::OpenAiCompat::max_reasoning_effort
752/// [`OpenAiCompat::xai`]: crate::provider::OpenAiCompat::xai
753/// [`ReasoningEffortCeiling`]: crate::provider::ReasoningEffortCeiling
754/// [`GoogleCompat::thinking_level`]: crate::provider::GoogleCompat::thinking_level
755#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
756#[serde(rename_all = "lowercase")]
757#[non_exhaustive]
758pub enum ThinkingLevel {
759    /// Request no reasoning: no thinking or effort field is sent (DeepSeek
760    /// excepted, which gets `thinking: disabled`). A model that always thinks
761    /// then runs at its own default — see the table above.
762    #[default]
763    Off,
764    /// The least thinking a provider can be asked for. Sent as `MINIMAL` on
765    /// Gemini 3 models that accept it (3.6 / 3.5 Flash, 3.x Flash-Lite,
766    /// 3 Flash, 3.1 Flash / Flash-Lite Image); everywhere else identical to
767    /// `Low`. On Gemini 3 this, not `Off`, is how to ask for least thinking.
768    Minimal,
769    Low,
770    Medium,
771    High,
772    /// Above `High`, below `Max` — Anthropic's and OpenAI's `xhigh`. Where a
773    /// provider or model has no `xhigh` rung it is clamped down to that
774    /// provider's `High` value (see the table above), except on Anthropic's
775    /// adaptive path, where `xhigh` is passed through as-is and a model
776    /// without the rung (Opus 4.6 / Sonnet 4.6) rejects it. Serializes as
777    /// `"xhigh"`.
778    XHigh,
779    /// The highest setting this crate sends — Anthropic's, DeepSeek's and
780    /// (on GPT-5.6 / GPT-6) OpenAI's `max`. Elsewhere it is clamped to the
781    /// highest value this crate knows is accepted — for OpenAI-shaped
782    /// providers, the model's declared `max_reasoning_effort` (see the table
783    /// above).
784    Max,
785}
786
787// ---------------------------------------------------------------------------
788// Tool definition
789// ---------------------------------------------------------------------------
790
791/// Callback for streaming partial results during tool execution.
792///
793/// Tools call this to emit progress updates (e.g., partial output, status messages)
794/// that are forwarded as `AgentEvent::ToolExecutionUpdate` events for UI consumption.
795/// Partial results are **not** sent to the LLM — only the final `ToolResult` is.
796pub type ToolUpdateFn = Arc<dyn Fn(ToolResult) + Send + Sync>;
797
798/// Callback for emitting user-facing progress messages during tool execution.
799///
800/// Each invocation emits an `AgentEvent::ProgressMessage` event. Unlike `ToolUpdateFn`,
801/// these are simple text messages intended for user-facing display (e.g., status lines,
802/// notifications), not structured tool results.
803pub type ProgressFn = Arc<dyn Fn(String) + Send + Sync>;
804
805/// Context passed to tool execution. Bundles all per-invocation state.
806///
807/// Using a struct instead of individual parameters future-proofs the trait —
808/// adding fields to `ToolContext` is non-breaking, which `#[non_exhaustive]`
809/// is what actually makes true. Tools receive this rather than build it, so
810/// the attribute costs implementors nothing; it only stops a struct literal in
811/// downstream test code from breaking on every new field.
812#[non_exhaustive]
813pub struct ToolContext {
814    /// The ID of this tool call (for correlation).
815    pub tool_call_id: String,
816    /// The name of the tool being invoked.
817    pub tool_name: String,
818    /// Cancellation token — check `is_cancelled()` in long-running tools.
819    pub cancel: tokio_util::sync::CancellationToken,
820    /// Optional callback for streaming partial `ToolResult`s (UI/logging only).
821    pub on_update: Option<ToolUpdateFn>,
822    /// Optional callback for emitting user-facing progress messages.
823    pub on_progress: Option<ProgressFn>,
824    /// Set by the loop; delegation tools report their runs' stats here, via
825    /// [`report_delegated_run`](Self::report_delegated_run), so they survive a
826    /// failed delegation.
827    pub(crate) sub_agent_report: Option<SubAgentReport>,
828    /// Set by the loop: the tree extensions a delegated run must apply, its
829    /// depth, and the calling run's label.
830    pub(crate) delegation: Delegation,
831}
832
833/// What a run hands down to the runs it delegates to.
834#[derive(Clone, Default)]
835pub(crate) struct Delegation {
836    pub(crate) tree: Vec<Arc<dyn crate::extension::Extension>>,
837    /// The depth a delegated run has (the calling run's depth + 1).
838    pub(crate) depth: usize,
839    /// The calling run's label, which a delegated run keeps.
840    pub(crate) label: Option<String>,
841    /// The tool call this context belongs to: what a run it delegates to
842    /// was started by.
843    pub(crate) call_id: Option<String>,
844    /// The id the calling run gave its extensions (when it has any).
845    pub(crate) parent_run_id: Option<String>,
846}
847
848impl ToolContext {
849    /// A context for one tool invocation, with no cancellation token and no
850    /// callbacks.
851    ///
852    /// This struct is `#[non_exhaustive]`, so downstream crates build it here
853    /// rather than with a struct literal. The loop constructs the real one;
854    /// this is for tests and for callers driving a tool directly.
855    pub fn new(tool_call_id: impl Into<String>, tool_name: impl Into<String>) -> Self {
856        Self {
857            tool_call_id: tool_call_id.into(),
858            tool_name: tool_name.into(),
859            cancel: tokio_util::sync::CancellationToken::new(),
860            on_update: None,
861            on_progress: None,
862            sub_agent_report: None,
863            delegation: Delegation::default(),
864        }
865    }
866
867    /// Use the given cancellation token instead of a fresh one.
868    pub fn with_cancel(mut self, cancel: tokio_util::sync::CancellationToken) -> Self {
869        self.cancel = cancel;
870        self
871    }
872
873    /// Stream partial [`ToolResult`]s to this callback (UI/logging only).
874    pub fn with_on_update(mut self, on_update: ToolUpdateFn) -> Self {
875        self.on_update = Some(on_update);
876        self
877    }
878
879    /// Emit user-facing progress messages through this callback.
880    pub fn with_on_progress(mut self, on_progress: ProgressFn) -> Self {
881        self.on_progress = Some(on_progress);
882        self
883    }
884
885    /// Report one delegated agent run's stats to the loop that invoked this
886    /// tool, so its spend is counted in that loop's
887    /// [`SessionStats::sub_agents`].
888    ///
889    /// [`SubAgentTool`](crate::SubAgentTool) calls this for you; a **custom**
890    /// delegation tool — one that runs its own [`agent_loop`](crate::agent_loop())
891    /// or [`Agent`](crate::Agent) — calls it once per run it started, with that
892    /// run's stats as carried by its [`AgentEvent::AgentEnd`]. Report even
893    /// when the run failed and the tool is about to return `Err`: the spend of
894    /// a failed delegation is still spend, and this is the only way it reaches
895    /// the parent.
896    ///
897    /// **Call it before [`execute`](AgentTool::execute) returns.** The loop
898    /// collects reports as soon as the tool's future completes; a report made
899    /// after that — from a task the tool spawned and did not await, say — is
900    /// silently lost. Clones of this context report to the same place, so a
901    /// tool that fans out to several runs can hand each a clone.
902    ///
903    /// A no-op on a context the loop did not build (e.g. one from
904    /// [`ToolContext::new`]): there is no parent to report to.
905    pub fn report_delegated_run(&self, stats: SessionStats) {
906        if let Some(report) = &self.sub_agent_report {
907            report.lock().unwrap_or_else(|e| e.into_inner()).push(stats);
908        }
909    }
910
911    /// The tree extensions ([`Agent::with_tree_extension`](crate::Agent::with_tree_extension))
912    /// a run this tool delegates to must apply, ahead of its own. A custom
913    /// delegation tool passes them on, with the depth and label, through
914    /// [`Agent::delegated_from`](crate::Agent::delegated_from) or
915    /// [`AgentLoopConfig::delegated_from`](crate::agent_loop::AgentLoopConfig::delegated_from).
916    pub fn tree_extensions(&self) -> &[Arc<dyn crate::extension::Extension>] {
917        &self.delegation.tree
918    }
919
920    /// The depth of a run this tool delegates to: 1 when called by a
921    /// top-level run.
922    pub fn delegation_depth(&self) -> usize {
923        self.delegation.depth.max(1)
924    }
925
926    /// The calling run's label ([`Agent::with_run_label`](crate::Agent::with_run_label)),
927    /// for a run this tool delegates to.
928    pub fn run_label(&self) -> Option<&str> {
929        self.delegation.label.as_deref()
930    }
931}
932
933impl Clone for ToolContext {
934    fn clone(&self) -> Self {
935        Self {
936            tool_call_id: self.tool_call_id.clone(),
937            tool_name: self.tool_name.clone(),
938            cancel: self.cancel.clone(),
939            on_update: self.on_update.clone(),
940            on_progress: self.on_progress.clone(),
941            sub_agent_report: self.sub_agent_report.clone(),
942            delegation: self.delegation.clone(),
943        }
944    }
945}
946
947impl std::fmt::Debug for ToolContext {
948    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
949        f.debug_struct("ToolContext")
950            .field("tool_call_id", &self.tool_call_id)
951            .field("tool_name", &self.tool_name)
952            .field("cancel", &self.cancel)
953            .field("on_update", &self.on_update.as_ref().map(|_| "<callback>"))
954            .field(
955                "on_progress",
956                &self.on_progress.as_ref().map(|_| "<callback>"),
957            )
958            .finish()
959    }
960}
961
962/// A tool the agent can call. Implement this trait for your tools.
963#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
964#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
965pub trait AgentTool: crate::rt::MaybeSend + crate::rt::MaybeSync {
966    /// Unique tool name (used in LLM tool_use)
967    fn name(&self) -> &str;
968    /// Human-readable label for UI
969    fn label(&self) -> &str;
970    /// Description for the LLM
971    fn description(&self) -> &str;
972    /// JSON Schema for parameters
973    fn parameters_schema(&self) -> serde_json::Value;
974    /// Execute the tool.
975    ///
976    /// The `ctx` parameter provides per-invocation context:
977    /// - `ctx.tool_call_id` / `ctx.tool_name` — for correlation and logging
978    /// - `ctx.cancel` — cancellation token; check `is_cancelled()` in long-running tools
979    /// - `ctx.on_update` — optional callback for streaming partial `ToolResult`s (UI/logging only)
980    /// - `ctx.on_progress` — optional callback for user-facing progress text (`ProgressMessage`)
981    async fn execute(
982        &self,
983        params: serde_json::Value,
984        ctx: ToolContext,
985    ) -> Result<ToolResult, ToolError>;
986}
987
988#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
989#[serde(rename_all = "camelCase")]
990pub struct ToolResult {
991    pub content: Vec<Content>,
992    #[serde(default)]
993    pub details: serde_json::Value,
994}
995
996#[derive(Debug, thiserror::Error)]
997pub enum ToolError {
998    #[error("{0}")]
999    Failed(String),
1000    #[error("Tool not found: {0}")]
1001    NotFound(String),
1002    #[error("Invalid arguments: {0}")]
1003    InvalidArgs(String),
1004    #[error("Cancelled")]
1005    Cancelled,
1006}
1007
1008// ---------------------------------------------------------------------------
1009// Agent events (for streaming UI updates)
1010// ---------------------------------------------------------------------------
1011
1012/// Events emitted by the agent loop for streaming UI updates.
1013///
1014/// # Wire format (stability contract)
1015///
1016/// `AgentEvent` and [`StreamDelta`] serialize as internally-tagged JSON —
1017/// `{"type": "<camelCase variant>", ...camelCase fields}` — so external
1018/// frontends (websocket fanout servers, TypeScript clients, JSONL pipes) can
1019/// consume the event stream directly:
1020///
1021/// ```json
1022/// {"type":"messageUpdate","message":{...},"delta":{"type":"text","delta":"hi"}}
1023/// {"type":"toolExecutionEnd","toolCallId":"tc_1","toolName":"bash","result":{...},"isError":false}
1024/// ```
1025///
1026/// This shape is a **public contract**: variant tags, field names, and the
1027/// internal tagging are frozen by snapshot tests. Renaming a variant or field
1028/// is a breaking change for wire clients, not just for Rust callers.
1029#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1030#[serde(
1031    tag = "type",
1032    rename_all = "camelCase",
1033    rename_all_fields = "camelCase"
1034)]
1035#[non_exhaustive]
1036pub enum AgentEvent {
1037    AgentStart,
1038    /// The run finished. Carries the messages it produced and a
1039    /// [`SessionStats`] rollup of what they cost.
1040    ///
1041    /// The variant is `#[non_exhaustive]` — the payload is expected to grow.
1042    /// Match with `..`.
1043    #[non_exhaustive]
1044    AgentEnd {
1045        messages: Vec<AgentMessage>,
1046        /// `#[serde(default)]`: `AgentEvent` is a frozen wire format, and
1047        /// archived streams predate this field.
1048        #[serde(default)]
1049        stats: SessionStats,
1050    },
1051    TurnStart,
1052    TurnEnd {
1053        message: AgentMessage,
1054        tool_results: Vec<Message>,
1055    },
1056    MessageStart {
1057        message: AgentMessage,
1058    },
1059    MessageUpdate {
1060        message: AgentMessage,
1061        delta: StreamDelta,
1062    },
1063    MessageEnd {
1064        message: AgentMessage,
1065    },
1066    ToolExecutionStart {
1067        tool_call_id: String,
1068        tool_name: String,
1069        args: serde_json::Value,
1070    },
1071    ToolExecutionUpdate {
1072        tool_call_id: String,
1073        tool_name: String,
1074        partial_result: ToolResult,
1075    },
1076    ToolExecutionEnd {
1077        tool_call_id: String,
1078        tool_name: String,
1079        result: ToolResult,
1080        is_error: bool,
1081    },
1082    ProgressMessage {
1083        tool_call_id: String,
1084        tool_name: String,
1085        text: String,
1086    },
1087    InputRejected {
1088        reason: String,
1089    },
1090    /// A provider attempt failed with a retryable error and will be retried.
1091    ///
1092    /// Sent after that attempt's messages: when the attempt had opened one, a
1093    /// `MessageEnd` with [`StopReason::Error`] closes it first, so a streaming
1094    /// client discards its partial text. That `MessageEnd` is **not** the
1095    /// turn's result — this event is what tells the two apart, so a consumer
1096    /// that reacts to an error `MessageEnd` should wait for the next event. A
1097    /// final failure is never followed by `ProviderRetry`. If the run is
1098    /// cancelled during `delay_ms`, the announced attempt does not happen and
1099    /// the turn ends with [`StopReason::Aborted`].
1100    #[non_exhaustive]
1101    ProviderRetry {
1102        /// The attempt that failed, 1-based.
1103        attempt: usize,
1104        /// Attempts allowed in total: `RetryConfig::max_retries + 1`.
1105        max_attempts: usize,
1106        /// The provider error, as text.
1107        error: String,
1108        /// How long the loop waits before the next attempt.
1109        delay_ms: u64,
1110    },
1111    /// A tool was called repeatedly with identical arguments.
1112    ///
1113    /// Emitted on both escalations: the first trip steers the model and
1114    /// continues, a later trip on the same signature stops the run. `aborted`
1115    /// distinguishes them, so a caller can tell a nudge from a stop and an
1116    /// audit can record why a run ended.
1117    #[non_exhaustive]
1118    LoopDetected {
1119        tool_name: String,
1120        repetitions: usize,
1121        aborted: bool,
1122    },
1123    /// History was compacted before a turn.
1124    ///
1125    /// Emitted by [`LlmCompaction`](crate::LlmCompaction) on both of its paths
1126    /// — the spliced summary and the deterministic fallback — so a consumer can
1127    /// tell which one ran and what it cost. The built-in
1128    /// [`DefaultCompaction`](crate::context::DefaultCompaction) does not emit
1129    /// this; it has no event channel and never issues a request.
1130    ContextCompacted {
1131        /// Which compaction path produced this result.
1132        method: CompactionMethod,
1133        messages_before: usize,
1134        messages_after: usize,
1135        tokens_before: usize,
1136        tokens_after: usize,
1137        /// What the summarization request produced and cost, when one was
1138        /// made. `None` on a purely deterministic compaction.
1139        ///
1140        /// Present as one optional payload rather than three sibling fields so
1141        /// the cost, the span it bought, and the fact that a request happened
1142        /// cannot disagree with each other.
1143        summary: Option<SummaryStats>,
1144    },
1145}
1146
1147impl AgentEvent {
1148    /// Construct an [`AgentEvent::AgentEnd`].
1149    ///
1150    /// The variant is `#[non_exhaustive]` — its payload grows — so downstream
1151    /// crates (and tests) build it here rather than with a struct literal.
1152    pub fn agent_end(messages: Vec<AgentMessage>, stats: SessionStats) -> Self {
1153        Self::AgentEnd { messages, stats }
1154    }
1155
1156    /// Construct an [`AgentEvent::ProviderRetry`].
1157    pub fn provider_retry(
1158        attempt: usize,
1159        max_attempts: usize,
1160        error: impl Into<String>,
1161        delay: std::time::Duration,
1162    ) -> Self {
1163        Self::ProviderRetry {
1164            attempt,
1165            max_attempts,
1166            error: error.into(),
1167            delay_ms: u64::try_from(delay.as_millis()).unwrap_or(u64::MAX),
1168        }
1169    }
1170
1171    /// Construct an [`AgentEvent::LoopDetected`].
1172    ///
1173    /// `aborted` separates the two escalations: `false` is a steer the model
1174    /// can recover from, `true` means the run stopped.
1175    pub fn loop_detected(tool_name: impl Into<String>, repetitions: usize, aborted: bool) -> Self {
1176        Self::LoopDetected {
1177            tool_name: tool_name.into(),
1178            repetitions,
1179            aborted,
1180        }
1181    }
1182}
1183
1184/// Session-level rollup carried by [`AgentEvent::AgentEnd`].
1185///
1186/// The per-turn numbers already existed — `Usage` on every assistant message,
1187/// `tokens_cached` on the `llm_stream` span, `cache_read` in the GASP record —
1188/// but nothing summed them, so answering "what was this run's cache hit rate"
1189/// meant replaying the whole event stream. Any change that moves caching
1190/// (breakpoint placement, compaction strategy, model choice) had to be judged
1191/// by hand-built harnesses instead of a number the library reports.
1192///
1193/// ```
1194/// # use yoagent::{SessionStats, Usage};
1195/// # let stats = SessionStats::default();
1196/// // Reading your cache hit rate:
1197/// println!("{:.1}% cached over {} turns", stats.cache_hit_rate() * 100.0, stats.turns);
1198/// ```
1199///
1200/// **Hit rate is `cache_read / (input + cache_read + cache_write)`** — cache
1201/// writes count against you, because they are prompt tokens the provider
1202/// processed and billed. Counting only `input` shrinks the denominator and so
1203/// **overstates** the rate for a write-charging provider: Anthropic books a
1204/// re-processed prefix to `cache_write`, and an `input`-only metric makes it
1205/// look roughly ten times cheaper than it is. See
1206/// `docs/evals/llm-compaction-live.md`, where that error was made and caught.
1207///
1208/// Read a rate against its session length, not against 100%: every turn's new
1209/// content is necessarily a miss, so the ceiling is about `(n-1)/(n+1)` — ~88%
1210/// at 15 turns, ~96% only past 49.
1211#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1212#[serde(rename_all = "camelCase")]
1213#[non_exhaustive]
1214pub struct SessionStats {
1215    /// Provider usage summed over every LLM turn in this run.
1216    ///
1217    /// `total_tokens` is **not** summed and stays 0: providers disagree on what
1218    /// it counts, and some never set it. Derive a total from the four
1219    /// components instead.
1220    #[serde(default)]
1221    pub usage: Usage,
1222    /// LLM turns taken. Tool executions are not turns; an errored turn counts,
1223    /// because the provider billed it, and provider retries within one turn do
1224    /// not appear separately.
1225    #[serde(default)]
1226    pub turns: u32,
1227    /// Dollar cost of [`usage`](Self::usage), when the model's rates are
1228    /// known (see [`ModelConfig::cost`](crate::provider::ModelConfig::cost)).
1229    ///
1230    /// `None` is never "free", but it means one of two things: the spend
1231    /// **cannot be priced** (a turn with non-zero usage came from a model with
1232    /// `cost: None`, which is what gateways and custom endpoints return, and
1233    /// first-party constructors for a model the price table does not list),
1234    /// or there was **nothing to price** (a run that
1235    /// took no turns, or whose turns reported no usage on an unpriced model;
1236    /// on a priced model a zero-usage turn makes this `Some(0.0)`).
1237    /// [`is_unpriced`](Self::is_unpriced) tells them apart. A model configured
1238    /// as free (`Some` with zero rates) reports `Some(0.0)`. Unpriced is
1239    /// sticky: once any turn cannot be priced this stays `None`, because a
1240    /// sum that silently skips the unpriced part under-reports.
1241    ///
1242    /// Scope: this run's own turns. What [`SubAgentTool`](crate::SubAgentTool)s
1243    /// spent is kept apart in [`sub_agents`](Self::sub_agents); use
1244    /// [`total_cost_usd`](Self::total_cost_usd) for the whole bill.
1245    #[serde(default, skip_serializing_if = "Option::is_none")]
1246    pub cost_usd: Option<f64>,
1247    /// Times the loop observed compaction rewrite history.
1248    ///
1249    /// Counts any turn where the strategy returned a different message count or
1250    /// a different *estimated* token total ([`context::total_tokens`]), so
1251    /// in-place tool-output truncation is included alongside reshaping. A
1252    /// compaction that runs and reclaims nothing is indistinguishable from no
1253    /// compaction at all.
1254    ///
1255    /// [`context::total_tokens`]: crate::context::total_tokens
1256    ///
1257    /// Deliberately **not** split into spliced-summary vs deterministic
1258    /// fallback. That detail exists — on [`AgentEvent::ContextCompacted`],
1259    /// with its [`SummaryStats`] — but
1260    /// [`CompactionStrategy::compact`](crate::context::CompactionStrategy::compact)
1261    /// is synchronous and has no event channel, so the loop cannot see it. Wire
1262    /// `LlmCompaction::with_event_sender` to the same channel for the
1263    /// breakdown — the events *describe* the same compactions this counts.
1264    ///
1265    /// What the summarization requests cost is in
1266    /// [`compaction`](Self::compaction), not here.
1267    #[serde(default)]
1268    pub compactions: u32,
1269    /// What this run's [`SubAgentTool`](crate::SubAgentTool) delegations
1270    /// spent, summed over the whole delegation tree — a sub-agent's nested
1271    /// sub-agents included.
1272    ///
1273    /// A **separate bucket**: [`usage`](Self::usage), [`turns`](Self::turns)
1274    /// and [`cost_usd`](Self::cost_usd) stay this agent's own, so delegation
1275    /// remains attributable. Sub-agents run their own loop on a private
1276    /// channel, and before this field existed their spend reached the parent
1277    /// nowhere, so every total silently under-reported delegation. Read
1278    /// [`total_usage`](Self::total_usage) / [`total_cost_usd`](Self::total_cost_usd)
1279    /// for the whole bill.
1280    ///
1281    /// Omitted from the wire when nothing was delegated, so a run without
1282    /// sub-agents serializes exactly as it did before.
1283    #[serde(default, skip_serializing_if = "SubAgentSpend::is_empty")]
1284    pub sub_agents: SubAgentSpend,
1285    /// Decision-model requests made during this run — the advisory hints and
1286    /// the tool gate (feature `decision`), including those of sub-agents it
1287    /// delegated to. Always empty without the feature, and omitted from the
1288    /// wire when empty.
1289    ///
1290    /// Its dollar cost is part of [`total_cost_usd`](Self::total_cost_usd);
1291    /// its tokens are not part of [`total_usage`](Self::total_usage), which
1292    /// counts LLM tokens only.
1293    #[serde(default, skip_serializing_if = "DecisionStats::is_empty")]
1294    pub decision: DecisionStats,
1295    /// What compaction's own model requests cost —
1296    /// [`LlmCompaction`](crate::LlmCompaction)'s summarization requests —
1297    /// including those of sub-agents this run delegated to. Empty with the
1298    /// deterministic strategies, which make no request, and omitted from the
1299    /// wire when empty.
1300    ///
1301    /// A **separate bucket**, like [`decision`](Self::decision):
1302    /// [`usage`](Self::usage), [`turns`](Self::turns) and
1303    /// [`cost_usd`](Self::cost_usd) stay the agent's own turns, and
1304    /// [`sub_agents`](Self::sub_agents) what the sub-agents' own turns spent.
1305    /// Its tokens are part of [`total_usage`](Self::total_usage) and its
1306    /// dollar cost part of [`total_cost_usd`](Self::total_cost_usd), each
1307    /// request priced at the summarization model's own rates.
1308    ///
1309    /// Summarization runs in the background, so a request is counted by the
1310    /// run whose compaction step first finds it finished — usually the run
1311    /// that started it, but a request still in flight when a run ends is
1312    /// counted by the next run that uses the same strategy (and never, if
1313    /// there is none). Each request is counted once, whether its briefing was
1314    /// spliced, discarded or rejected; a request that failed or timed out
1315    /// reported no usage and adds nothing.
1316    #[serde(default, skip_serializing_if = "CompactionSpend::is_empty")]
1317    pub compaction: CompactionSpend,
1318}
1319
1320/// What compaction's model requests cost; see [`SessionStats::compaction`]
1321/// and [`Agent::compaction_spend`](crate::Agent::compaction_spend).
1322#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1323#[serde(rename_all = "camelCase")]
1324#[non_exhaustive]
1325pub struct CompactionSpend {
1326    /// Provider usage summed over the summarization requests. `total_tokens`
1327    /// stays 0, as in [`SessionStats::usage`].
1328    #[serde(default)]
1329    pub usage: Usage,
1330    /// Dollar cost of [`usage`](Self::usage) at the summarization model's
1331    /// rates, with the crate's rule: `None` once any request with non-zero
1332    /// usage could not be priced (a summarizer with `cost: None`), and when
1333    /// nothing was priced. [`is_unpriced`](Self::is_unpriced) tells them
1334    /// apart.
1335    #[serde(default, skip_serializing_if = "Option::is_none")]
1336    pub cost_usd: Option<f64>,
1337    /// Summarization requests that returned a response, accepted or not.
1338    #[serde(default)]
1339    pub requests: u32,
1340}
1341
1342impl CompactionSpend {
1343    /// Whether no summarization request was counted.
1344    pub fn is_empty(&self) -> bool {
1345        self.requests == 0 && usage_is_zero(&self.usage) && self.cost_usd.is_none()
1346    }
1347
1348    /// Whether compaction spend happened that cannot be priced: non-zero
1349    /// [`usage`](Self::usage) with no [`cost_usd`](Self::cost_usd). `false`
1350    /// for an empty bucket.
1351    pub fn is_unpriced(&self) -> bool {
1352        is_unpriced(&self.usage, self.cost_usd)
1353    }
1354
1355    /// Fold another bucket into this one — for a caller accumulating across
1356    /// runs. Keeps [`cost_usd`](Self::cost_usd) `None` once any part is
1357    /// unpriced.
1358    pub fn merge(&mut self, other: &CompactionSpend) {
1359        self.cost_usd = combine_cost(&self.usage, self.cost_usd, &other.usage, other.cost_usd);
1360        self.usage = add_usage(&self.usage, &other.usage);
1361        self.requests = self.requests.saturating_add(other.requests);
1362    }
1363
1364    /// One summarization response, priced with the summarizer's rates
1365    /// (`None` = an unpriced model).
1366    pub(crate) fn record_request(
1367        &mut self,
1368        usage: &Usage,
1369        cost: Option<&crate::provider::CostConfig>,
1370    ) {
1371        self.merge(&CompactionSpend {
1372            // Through `add_usage` so `total_tokens` is dropped here too.
1373            usage: add_usage(&Usage::default(), usage),
1374            cost_usd: cost.map(|c| c.cost_usd(usage)),
1375            requests: 1,
1376        });
1377    }
1378}
1379
1380/// What decision-model requests cost during a run; see
1381/// [`SessionStats::decision`].
1382#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1383#[serde(rename_all = "camelCase")]
1384#[non_exhaustive]
1385pub struct DecisionStats {
1386    /// Evaluations attempted.
1387    #[serde(default)]
1388    pub requests: u32,
1389    /// Evaluations that produced no usable answer — errors, invalid or
1390    /// unusable responses, a missing API key, and timeouts.
1391    #[serde(default)]
1392    pub failures: u32,
1393    /// The subset of `failures` that timed out.
1394    #[serde(default)]
1395    pub timeouts: u32,
1396    /// Tokens of the successful evaluations (`input` and `output` only).
1397    #[serde(default)]
1398    pub usage: Usage,
1399    /// Their dollar cost, with the crate's rule: `None` once any evaluation
1400    /// could not be priced, and when nothing was priced.
1401    #[serde(default, skip_serializing_if = "Option::is_none")]
1402    pub cost_usd: Option<f64>,
1403    /// Successful evaluations whose cost is unknown — an unpriced model, or a
1404    /// response that reported no usage. Any makes [`cost_usd`](Self::cost_usd)
1405    /// `None` for good.
1406    #[serde(default)]
1407    pub unpriced: u32,
1408}
1409
1410impl DecisionStats {
1411    /// Whether no evaluation was attempted.
1412    pub fn is_empty(&self) -> bool {
1413        self.requests == 0 && usage_is_zero(&self.usage) && self.cost_usd.is_none()
1414    }
1415
1416    /// Whether some decision spend cannot be priced: an evaluation on an
1417    /// unpriced model, one that reported no usage, or tokens without a cost.
1418    pub fn is_unpriced(&self) -> bool {
1419        self.unpriced > 0 || is_unpriced(&self.usage, self.cost_usd)
1420    }
1421
1422    /// Fold another rollup into this one.
1423    pub fn merge(&mut self, other: &DecisionStats) {
1424        self.cost_usd = if self.unpriced > 0 || other.unpriced > 0 {
1425            None
1426        } else {
1427            combine_cost(&self.usage, self.cost_usd, &other.usage, other.cost_usd)
1428        };
1429        self.usage = add_usage(&self.usage, &other.usage);
1430        self.requests = self.requests.saturating_add(other.requests);
1431        self.failures = self.failures.saturating_add(other.failures);
1432        self.timeouts = self.timeouts.saturating_add(other.timeouts);
1433        self.unpriced = self.unpriced.saturating_add(other.unpriced);
1434    }
1435
1436    /// One successful evaluation; `cost_usd: None` counts it as unpriced.
1437    #[cfg_attr(not(feature = "decision"), allow(dead_code))]
1438    pub(crate) fn record_success(&mut self, input: u64, output: u64, cost_usd: Option<f64>) {
1439        self.merge(&DecisionStats {
1440            requests: 1,
1441            usage: Usage {
1442                input,
1443                output,
1444                ..Default::default()
1445            },
1446            cost_usd,
1447            unpriced: u32::from(cost_usd.is_none()),
1448            ..Default::default()
1449        });
1450    }
1451
1452    /// Spend already billed by a request that then failed (a non-batching
1453    /// backend that answered some questions before one failed). Not a
1454    /// request of its own; the failure is recorded separately.
1455    #[cfg_attr(not(feature = "decision"), allow(dead_code))]
1456    pub(crate) fn record_billed(&mut self, input: u64, output: u64, cost_usd: Option<f64>) {
1457        self.merge(&DecisionStats {
1458            usage: Usage {
1459                input,
1460                output,
1461                ..Default::default()
1462            },
1463            cost_usd,
1464            unpriced: u32::from(cost_usd.is_none()),
1465            ..Default::default()
1466        });
1467    }
1468
1469    /// One failed evaluation.
1470    #[cfg_attr(not(feature = "decision"), allow(dead_code))]
1471    pub(crate) fn record_failure(&mut self, timed_out: bool) {
1472        self.requests = self.requests.saturating_add(1);
1473        self.failures = self.failures.saturating_add(1);
1474        if timed_out {
1475            self.timeouts = self.timeouts.saturating_add(1);
1476        }
1477    }
1478}
1479
1480impl SessionStats {
1481    /// A rollup with the given figures.
1482    ///
1483    /// This struct is `#[non_exhaustive]`, so downstream crates cannot use a
1484    /// struct literal; without this the only construction path would be
1485    /// `Default::default()` plus field assignment.
1486    pub fn new(usage: Usage, turns: u32, cost_usd: Option<f64>, compactions: u32) -> Self {
1487        Self {
1488            usage,
1489            turns,
1490            cost_usd,
1491            compactions,
1492            sub_agents: SubAgentSpend::default(),
1493            decision: DecisionStats::default(),
1494            compaction: CompactionSpend::default(),
1495        }
1496    }
1497
1498    /// Whether this run's **own** spend cannot be priced: non-zero
1499    /// [`usage`](Self::usage) with no [`cost_usd`](Self::cost_usd). `false`
1500    /// when there is simply nothing to price. Delegated spend is judged
1501    /// separately by [`SubAgentSpend::is_unpriced`].
1502    pub fn is_unpriced(&self) -> bool {
1503        is_unpriced(&self.usage, self.cost_usd)
1504    }
1505
1506    /// This run's own usage plus everything its sub-agents spent, plus what
1507    /// compaction's summarization requests used
1508    /// ([`compaction`](Self::compaction)). Decision-model tokens are not
1509    /// included (see [`decision`](Self::decision)).
1510    ///
1511    /// `total_tokens` stays 0, as in [`usage`](Self::usage), and for the same
1512    /// reason: providers disagree on what it counts.
1513    pub fn total_usage(&self) -> Usage {
1514        add_usage(
1515            &add_usage(&self.usage, &self.sub_agents.usage),
1516            &self.compaction.usage,
1517        )
1518    }
1519
1520    /// Dollar cost of this run plus its sub-agents, each priced at its **own**
1521    /// model's rates.
1522    ///
1523    /// `None` when any part of that spend cannot be priced — an unpriced
1524    /// sub-agent makes the whole figure unknown rather than silently low — and
1525    /// also when no part carries a cost at all (nothing was spent and nothing
1526    /// was priced): a priced turn that reported no usage makes it
1527    /// `Some(0.0)`, not `None`. Spend of zero tokens needs no price, so a
1528    /// zero-usage part never poisons the rest.
1529    ///
1530    /// Decision-model spend ([`decision`](Self::decision)) and compaction
1531    /// spend ([`compaction`](Self::compaction)) are included, under the same
1532    /// rule.
1533    pub fn total_cost_usd(&self) -> Option<f64> {
1534        let own_and_delegated = combine_cost(
1535            &self.usage,
1536            self.cost_usd,
1537            &self.sub_agents.usage,
1538            self.sub_agents.cost_usd,
1539        );
1540        let turns_usage = add_usage(&self.usage, &self.sub_agents.usage);
1541        let with_compaction = combine_cost(
1542            &turns_usage,
1543            own_and_delegated,
1544            &self.compaction.usage,
1545            self.compaction.cost_usd,
1546        );
1547        if self.decision.is_unpriced() {
1548            return None;
1549        }
1550        let llm_usage = add_usage(&turns_usage, &self.compaction.usage);
1551        combine_cost(
1552            &llm_usage,
1553            with_compaction,
1554            &self.decision.usage,
1555            self.decision.cost_usd,
1556        )
1557    }
1558
1559    /// Fold another run's stats into this one — own figures into own, the
1560    /// delegated bucket into the delegated bucket — with the same unpriced
1561    /// rule as [`SubAgentSpend::merge`].
1562    pub(crate) fn merge(&mut self, other: &SessionStats) {
1563        self.cost_usd = combine_cost(&self.usage, self.cost_usd, &other.usage, other.cost_usd);
1564        self.usage = add_usage(&self.usage, &other.usage);
1565        self.turns = self.turns.saturating_add(other.turns);
1566        self.compactions = self.compactions.saturating_add(other.compactions);
1567        self.sub_agents.merge(&other.sub_agents);
1568        self.decision.merge(&other.decision);
1569        self.compaction.merge(&other.compaction);
1570    }
1571
1572    /// The [`SessionStats`] of a sub-agent run, if `result` came from a
1573    /// [`SubAgentTool`](crate::SubAgentTool).
1574    ///
1575    /// Present on the tool's own return value and on
1576    /// [`AgentEvent::ToolExecutionEnd`] — including when the sub-agent
1577    /// **failed**: the loop attaches what it spent before failing to the error
1578    /// result. Its [`usage`](Self::usage) is the sub-agent's own spend and its
1579    /// [`sub_agents`](Self::sub_agents) what *it* delegated, so own and nested
1580    /// spend stay distinguishable; [`total_usage`](Self::total_usage) covers the
1581    /// subtree.
1582    ///
1583    /// A custom tool that reported **several** runs from one call (see
1584    /// [`ToolContext::report_delegated_run`]) gets their combination: `usage`,
1585    /// `turns` and `cost_usd` summed over those runs' own turns, `sub_agents`
1586    /// over what they in turn delegated. The subtree totals stay exact; only
1587    /// the split between the individual runs is not kept.
1588    ///
1589    /// Do not add these to [`AgentEvent::AgentEnd`]'s stats as well: the loop
1590    /// already folds every delegation into that run's
1591    /// [`sub_agents`](Self::sub_agents).
1592    pub fn from_sub_agent_result(result: &ToolResult) -> Option<SessionStats> {
1593        serde_json::from_value(result.details.get(SUB_AGENT_STATS_KEY)?.clone()).ok()
1594    }
1595
1596    /// Fraction of prompt tokens served from cache across the whole session
1597    /// (0.0–1.0). Delegates to [`Usage::cache_hit_rate`] so there is one
1598    /// definition of the metric rather than two that can drift.
1599    pub fn cache_hit_rate(&self) -> f64 {
1600        self.usage.cache_hit_rate()
1601    }
1602
1603    /// Fold one turn's usage into the rollup, costing it when rates are known.
1604    ///
1605    /// `total_tokens` is deliberately not summed. It is a per-response provider
1606    /// report, and the providers disagree on it: `anthropic.rs` never sets it
1607    /// at all, `bedrock.rs` computes `input + output` and so excludes cache,
1608    /// and the rest pass through a payload value that includes cached tokens.
1609    /// Summing it would launder that inconsistency into a session-level number
1610    /// that reads as authoritative and is 0 for every Anthropic run. The four
1611    /// components sum cleanly; derive a total from those.
1612    ///
1613    /// Cost accrues per turn rather than once at the end. Today that is
1614    /// arithmetically identical — `CostConfig::cost_usd` is linear in every
1615    /// `Usage` field and `config.model_config` is fixed for the life of a run
1616    /// (`run_loop` holds `&AgentLoopConfig`, and `set_model` needs `&mut self`).
1617    /// It is written this way so a per-turn model override stays correct if one
1618    /// is ever introduced, and so `cost_usd` reflects whether any turn was
1619    /// priceable rather than requiring a separate check.
1620    ///
1621    /// Pricing follows [`combine_cost`]: a turn with non-zero usage and no
1622    /// configured rates makes [`cost_usd`](Self::cost_usd) `None` for the rest
1623    /// of the run, rather than leaving a partial sum that reads as the whole.
1624    pub(crate) fn record_turn(
1625        &mut self,
1626        usage: &Usage,
1627        cost: Option<&crate::provider::CostConfig>,
1628    ) {
1629        let turn_cost = cost.map(|c| c.cost_usd(usage));
1630        self.cost_usd = combine_cost(&self.usage, self.cost_usd, usage, turn_cost);
1631        self.usage = add_usage(&self.usage, usage);
1632        self.turns = self.turns.saturating_add(1);
1633    }
1634}
1635
1636/// Key under which a sub-agent's [`SessionStats`] ride in
1637/// [`ToolResult::details`]. Read it with
1638/// [`SessionStats::from_sub_agent_result`] rather than by hand.
1639pub const SUB_AGENT_STATS_KEY: &str = "sub_agent_stats";
1640
1641fn add_usage(a: &Usage, b: &Usage) -> Usage {
1642    Usage {
1643        input: a.input.saturating_add(b.input),
1644        output: a.output.saturating_add(b.output),
1645        cache_read: a.cache_read.saturating_add(b.cache_read),
1646        cache_write: a.cache_write.saturating_add(b.cache_write),
1647        // Not summed — see `SessionStats::record_turn`.
1648        total_tokens: 0,
1649    }
1650}
1651
1652fn usage_is_zero(u: &Usage) -> bool {
1653    u.input == 0 && u.output == 0 && u.cache_read == 0 && u.cache_write == 0
1654}
1655
1656/// Spend that happened but has no price: non-zero usage, no cost.
1657fn is_unpriced(usage: &Usage, cost: Option<f64>) -> bool {
1658    cost.is_none() && !usage_is_zero(usage)
1659}
1660
1661/// The cost of two pieces of spend together — the one rule every rollup
1662/// shares ([`SessionStats::record_turn`], [`SessionStats::total_cost_usd`],
1663/// [`SubAgentSpend::merge`], `Agent::total_cost_usd`).
1664///
1665/// `None` if either piece is unpriced — sticky, so a later priced piece never
1666/// revives a sum that silently skipped part of the bill. A zero-usage piece
1667/// with no cost needs no price and does not poison the other. `None` too when
1668/// neither piece carries a cost at all (nothing was spent and nothing was
1669/// priced); a priced piece that spent nothing contributes `Some(0.0)`.
1670///
1671/// Both naive `Option` sums are wrong: `zip` drops a priced piece when the
1672/// other merely spent nothing, and `unwrap_or(0.0)` turns unpriced into free.
1673pub(crate) fn combine_cost(
1674    a_usage: &Usage,
1675    a_cost: Option<f64>,
1676    b_usage: &Usage,
1677    b_cost: Option<f64>,
1678) -> Option<f64> {
1679    if is_unpriced(a_usage, a_cost) || is_unpriced(b_usage, b_cost) {
1680        return None;
1681    }
1682    match (a_cost, b_cost) {
1683        (None, None) => None,
1684        (a, b) => Some(a.unwrap_or(0.0) + b.unwrap_or(0.0)),
1685    }
1686}
1687
1688/// Spend of delegated [`SubAgentTool`](crate::SubAgentTool) runs, carried as
1689/// [`SessionStats::sub_agents`] and returned by
1690/// [`Agent::sub_agent_spend`](crate::Agent::sub_agent_spend).
1691///
1692/// Every figure covers the whole delegation tree: a sub-agent's own nested
1693/// sub-agents are included, so one top-level number accounts for all of it.
1694#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1695#[serde(rename_all = "camelCase")]
1696#[non_exhaustive]
1697pub struct SubAgentSpend {
1698    /// Provider usage summed over every delegated run. `total_tokens` stays 0,
1699    /// as in [`SessionStats::usage`].
1700    #[serde(default)]
1701    pub usage: Usage,
1702    /// Dollar cost of [`usage`](Self::usage), each run priced at its own
1703    /// model's rates — a sub-agent on a cheaper model is billed as such, never
1704    /// re-priced at the parent's.
1705    ///
1706    /// `None` is never "free", but it means one of two things: the delegated
1707    /// spend **cannot be priced** — sticky as soon as any delegated spend came
1708    /// from a model with `cost: None`, because a sum that silently
1709    /// skips the unpriced part is the under-report this field exists to
1710    /// remove — or there was **nothing to price** (no delegation, or runs that
1711    /// reported no usage). [`is_unpriced`](Self::is_unpriced) tells them
1712    /// apart.
1713    #[serde(default, skip_serializing_if = "Option::is_none")]
1714    pub cost_usd: Option<f64>,
1715    /// Sub-agent invocations, nested ones included. Non-zero means delegation
1716    /// happened even when it cost nothing measurable.
1717    #[serde(default)]
1718    pub runs: u32,
1719}
1720
1721impl SubAgentSpend {
1722    /// Whether nothing was delegated.
1723    pub fn is_empty(&self) -> bool {
1724        self.runs == 0 && usage_is_zero(&self.usage) && self.cost_usd.is_none()
1725    }
1726
1727    /// Whether delegated spend happened that cannot be priced: non-zero
1728    /// [`usage`](Self::usage) with no [`cost_usd`](Self::cost_usd). `false`
1729    /// for an empty bucket, where `cost_usd` is `None` because there is
1730    /// nothing to price.
1731    pub fn is_unpriced(&self) -> bool {
1732        is_unpriced(&self.usage, self.cost_usd)
1733    }
1734
1735    /// Fold another bucket into this one — for a caller accumulating across
1736    /// runs.
1737    ///
1738    /// Use this rather than adding fields by hand: it keeps
1739    /// [`cost_usd`](Self::cost_usd) `None` once any part is unpriced, which a
1740    /// plain `Option` sum gets wrong in both directions.
1741    pub fn merge(&mut self, other: &SubAgentSpend) {
1742        self.cost_usd = combine_cost(&self.usage, self.cost_usd, &other.usage, other.cost_usd);
1743        self.usage = add_usage(&self.usage, &other.usage);
1744        self.runs = self.runs.saturating_add(other.runs);
1745    }
1746
1747    /// Fold in one sub-agent run, given that run's own stats — its own spend
1748    /// and, recursively, everything it delegated.
1749    pub(crate) fn record_run(&mut self, child: &SessionStats) {
1750        let mut subtree = SubAgentSpend {
1751            usage: child.usage.clone(),
1752            cost_usd: child.cost_usd,
1753            runs: 1,
1754        };
1755        subtree.merge(&child.sub_agents);
1756        self.merge(&subtree);
1757    }
1758}
1759
1760/// Where a [`SubAgentTool`](crate::SubAgentTool) reports its run's stats to the
1761/// loop that invoked it. A side channel rather than the tool's return value
1762/// because a failed delegation returns `Err(ToolError)`, which has nowhere to
1763/// carry them — and the spend of a failed run is still spend.
1764///
1765/// A `Vec` because [`ToolContext`] is `Clone`: a custom tool may hand clones to
1766/// several sub-agents within one call, and each run must be counted.
1767pub(crate) type SubAgentReport = Arc<std::sync::Mutex<Vec<SessionStats>>>;
1768
1769#[cfg(test)]
1770mod spend_rollup_tests {
1771    use super::*;
1772    use crate::provider::CostConfig;
1773
1774    fn u(input: u64) -> Usage {
1775        Usage {
1776            input,
1777            ..Usage::default()
1778        }
1779    }
1780
1781    /// $1 per million input tokens, so `u(1_000_000)` costs exactly $1.
1782    fn priced() -> CostConfig {
1783        CostConfig::new(1.0, 0.0)
1784    }
1785
1786    /// An unpriced turn that spent tokens poisons the run's cost, and a
1787    /// priced turn after it must not revive a partial sum — the same rule as
1788    /// `SubAgentSpend::merge`.
1789    #[test]
1790    fn record_turn_unpriced_then_priced_is_unknown() {
1791        let mut stats = SessionStats::default();
1792        stats.record_turn(&u(1_000_000), None);
1793        assert_eq!(stats.cost_usd, None);
1794        stats.record_turn(&u(1_000_000), Some(&priced()));
1795        assert_eq!(stats.cost_usd, None, "a partial sum would under-report");
1796        assert!(stats.is_unpriced());
1797        assert_eq!(stats.usage, u(2_000_000));
1798        assert_eq!(stats.turns, 2);
1799    }
1800
1801    #[test]
1802    fn record_turn_priced_then_unpriced_is_unknown() {
1803        let mut stats = SessionStats::default();
1804        stats.record_turn(&u(1_000_000), Some(&priced()));
1805        assert_eq!(stats.cost_usd, Some(1.0));
1806        stats.record_turn(&u(1), None);
1807        assert_eq!(stats.cost_usd, None);
1808    }
1809
1810    /// A zero-rate config means free, not unknown: it prices at 0.0 and does
1811    /// not poison the sum.
1812    #[test]
1813    fn record_turn_free_model_prices_at_zero() {
1814        let mut stats = SessionStats::default();
1815        stats.record_turn(&u(1_000_000), Some(&priced()));
1816        stats.record_turn(&u(1_000_000), Some(&CostConfig::default()));
1817        assert_eq!(stats.cost_usd, Some(1.0));
1818        assert!(!stats.is_unpriced());
1819    }
1820
1821    #[test]
1822    fn record_turn_sums_priced_turns_and_ignores_empty_unpriced_ones() {
1823        let mut stats = SessionStats::default();
1824        // No usage reported: nothing to price, so no poison.
1825        stats.record_turn(&Usage::default(), None);
1826        assert_eq!(stats.cost_usd, None);
1827        assert!(!stats.is_unpriced(), "nothing spent is not unpriced");
1828        stats.record_turn(&u(1_000_000), Some(&priced()));
1829        stats.record_turn(&u(2_000_000), Some(&priced()));
1830        assert_eq!(stats.cost_usd, Some(3.0));
1831        assert!(!stats.is_unpriced());
1832    }
1833
1834    #[test]
1835    fn usage_sums_saturate_instead_of_overflowing() {
1836        let mut a = SubAgentSpend {
1837            usage: u(u64::MAX - 1),
1838            cost_usd: Some(1.0),
1839            runs: u32::MAX,
1840        };
1841        a.merge(&a.clone());
1842        assert_eq!(a.usage.input, u64::MAX);
1843        assert_eq!(a.runs, u32::MAX);
1844    }
1845
1846    /// Compaction spend is part of both totals, priced with its own rates,
1847    /// and leaves the run's own figures alone.
1848    #[test]
1849    fn compaction_spend_counts_in_the_totals() {
1850        let mut stats = SessionStats::default();
1851        stats.record_turn(&u(1_000_000), Some(&priced()));
1852        stats
1853            .compaction
1854            .record_request(&u(1_000_000), Some(&CostConfig::new(3.0, 0.0)));
1855        assert_eq!(stats.cost_usd, Some(1.0));
1856        assert_eq!(stats.usage.input, 1_000_000);
1857        assert_eq!(stats.total_cost_usd(), Some(4.0));
1858        assert_eq!(stats.total_usage().input, 2_000_000);
1859        assert_eq!(stats.compaction.requests, 1);
1860    }
1861
1862    /// An unpriced summarizer makes the whole bill unknown — and stays
1863    /// unknown after a later priced request — while a zero-usage unpriced
1864    /// response needs no price.
1865    #[test]
1866    fn unpriced_compaction_poisons_the_total() {
1867        let mut stats = SessionStats::default();
1868        stats.record_turn(&u(1_000_000), Some(&priced()));
1869
1870        stats.compaction.record_request(&u(0), None);
1871        assert_eq!(stats.total_cost_usd(), Some(1.0));
1872        assert!(!stats.compaction.is_unpriced());
1873
1874        stats.compaction.record_request(&u(10), None);
1875        stats.compaction.record_request(&u(10), Some(&priced()));
1876        assert!(stats.compaction.is_unpriced());
1877        assert_eq!(stats.compaction.cost_usd, None);
1878        assert_eq!(stats.total_cost_usd(), None);
1879        assert_eq!(stats.compaction.requests, 3);
1880    }
1881
1882    /// Merging runs (the `Agent` window) keeps the compaction bucket apart.
1883    #[test]
1884    fn merge_folds_the_compaction_bucket() {
1885        let mut run = SessionStats::default();
1886        run.compaction
1887            .record_request(&u(1_000_000), Some(&priced()));
1888        let mut total = SessionStats::default();
1889        total.merge(&run);
1890        total.merge(&run);
1891        assert_eq!(total.compaction.requests, 2);
1892        assert_eq!(total.compaction.cost_usd, Some(2.0));
1893        assert_eq!(total.usage, Usage::default());
1894        assert_eq!(total.total_cost_usd(), Some(2.0));
1895    }
1896}
1897
1898/// What a summarization request produced, carried by
1899/// [`AgentEvent::ContextCompacted`].
1900///
1901/// Weigh [`usage`](Self::usage) against the event's `tokens_before -
1902/// tokens_after` to decide whether an LLM compaction strategy earns its keep.
1903#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1904#[serde(rename_all = "camelCase")]
1905#[non_exhaustive]
1906pub struct SummaryStats {
1907    /// Messages the briefing replaced.
1908    ///
1909    /// Zero when a briefing was produced but could not be kept — the request
1910    /// was still paid for, so the event still reports it, but `method` will be
1911    /// [`CompactionMethod::Deterministic`].
1912    pub messages_summarized: usize,
1913    /// Tokens the summarization request itself consumed.
1914    pub usage: Usage,
1915    /// Dollar cost of `usage`, when the summarization model's rates are
1916    /// configured (see [`CostConfig`](crate::provider::CostConfig)).
1917    #[serde(skip_serializing_if = "Option::is_none")]
1918    pub cost_usd: Option<f64>,
1919}
1920
1921impl SummaryStats {
1922    /// A record of one summarization request.
1923    pub fn new(messages_summarized: usize, usage: Usage, cost_usd: Option<f64>) -> Self {
1924        Self {
1925            messages_summarized,
1926            usage,
1927            cost_usd,
1928        }
1929    }
1930}
1931
1932/// Which compaction path produced an [`AgentEvent::ContextCompacted`].
1933#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1934#[serde(rename_all = "camelCase")]
1935#[non_exhaustive]
1936pub enum CompactionMethod {
1937    /// History was replaced by an LLM-written summary.
1938    Summarized,
1939    /// Deterministic tiered compaction ran: truncate → summarize → drop.
1940    ///
1941    /// On [`LlmCompaction`](crate::LlmCompaction) this means no briefing made
1942    /// it into the result — none was ready, one was discarded as stale, or one
1943    /// was produced but could not be kept within the budget. The loop stayed
1944    /// unblocked; the compaction was lossy. Check `summary` to tell a free
1945    /// fallback from one that still paid for a request.
1946    Deterministic,
1947}
1948
1949/// Incremental content delta carried by [`AgentEvent::MessageUpdate`].
1950///
1951/// Serializes internally tagged (`{"type":"text","delta":"..."}`); see the
1952/// wire-format contract on [`AgentEvent`].
1953#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1954#[serde(
1955    tag = "type",
1956    rename_all = "camelCase",
1957    rename_all_fields = "camelCase"
1958)]
1959#[non_exhaustive]
1960pub enum StreamDelta {
1961    Text { delta: String },
1962    Thinking { delta: String },
1963    ToolCallDelta { delta: String },
1964}
1965
1966// ---------------------------------------------------------------------------
1967// Agent context (passed to the loop)
1968// ---------------------------------------------------------------------------
1969
1970pub struct AgentContext {
1971    pub system_prompt: String,
1972    pub messages: Vec<AgentMessage>,
1973    pub tools: Vec<Box<dyn AgentTool>>,
1974}
1975
1976// ---------------------------------------------------------------------------
1977// Input filtering
1978// ---------------------------------------------------------------------------
1979
1980/// Result of applying an input filter to a user message.
1981#[derive(Debug, Clone)]
1982pub enum FilterResult {
1983    /// Message passes unchanged.
1984    Pass,
1985    /// Message passes, but append a warning to context for the LLM to see.
1986    Warn(String),
1987    /// Message is rejected. Agent loop returns immediately.
1988    Reject(String),
1989}
1990
1991/// Synchronous filter applied to user input before the LLM call.
1992///
1993/// Implement this for injection detection, content moderation, PII redaction, etc.
1994/// Filters run in the hot path and must be fast. For a filter that awaits
1995/// something (a moderation API, a classifier), implement
1996/// [`AsyncInputFilter`] instead and register it with
1997/// [`Agent::with_async_input_filter`](crate::Agent::with_async_input_filter)
1998/// (or push an [`AsyncFilter`] onto
1999/// [`AgentLoopConfig::input_filters`](crate::agent_loop::AgentLoopConfig)).
2000pub trait InputFilter: crate::rt::MaybeSend + crate::rt::MaybeSync {
2001    fn filter(&self, text: &str) -> FilterResult;
2002
2003    /// The async filter behind this one, if any. The loop awaits it instead
2004    /// of calling [`filter`](Self::filter).
2005    ///
2006    /// Provided (returns `None`), so existing filters are unaffected; only
2007    /// [`AsyncFilter`] overrides it.
2008    fn as_async(&self) -> Option<&dyn AsyncInputFilter> {
2009        None
2010    }
2011}
2012
2013/// An input filter that may await — the async counterpart of
2014/// [`InputFilter`], with the same [`FilterResult`] semantics.
2015///
2016/// It runs in the same place and order as the sync filters (they share one
2017/// list), before the prompt reaches the model.
2018///
2019/// **You own the timeout.** The loop awaits the filter as long as it takes,
2020/// and a slow filter delays every prompt: bound it yourself (e.g.
2021/// `tokio::time::timeout`) and decide there whether a timeout passes or
2022/// rejects. A filter that **panics** is contained and treated as a
2023/// `Reject` (fail closed): the run ends with an
2024/// [`AgentEvent::InputRejected`] and the agent keeps its state.
2025#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
2026#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
2027pub trait AsyncInputFilter: crate::rt::MaybeSend + crate::rt::MaybeSync {
2028    /// Judge the prompt's text (every user text block, joined by newlines).
2029    async fn filter(&self, text: &str) -> FilterResult;
2030}
2031
2032/// Adapts an [`AsyncInputFilter`] into the [`InputFilter`] list.
2033///
2034/// The loop recognises it through [`InputFilter::as_async`] and awaits the
2035/// inner filter. Its synchronous [`InputFilter::filter`] cannot await, so it
2036/// fails closed: called directly, it rejects.
2037pub struct AsyncFilter<F>(F);
2038
2039impl<F: AsyncInputFilter> AsyncFilter<F> {
2040    /// Wrap `filter` for an [`InputFilter`] list.
2041    pub fn new(filter: F) -> Self {
2042        Self(filter)
2043    }
2044}
2045
2046impl<F: AsyncInputFilter> InputFilter for AsyncFilter<F> {
2047    fn filter(&self, _text: &str) -> FilterResult {
2048        FilterResult::Reject(
2049            "an async input filter was called synchronously; await it via InputFilter::as_async"
2050                .into(),
2051        )
2052    }
2053
2054    fn as_async(&self) -> Option<&dyn AsyncInputFilter> {
2055        Some(&self.0)
2056    }
2057}
2058
2059// ---------------------------------------------------------------------------
2060// Reading the user's request out of a conversation
2061// ---------------------------------------------------------------------------
2062
2063/// Whether a user-role text was written by the loop or by compaction rather
2064/// than by the user: compaction summaries and markers, execution-limit and
2065/// loop-abort notes, the loop-detection nudge, and extension continue
2066/// messages.
2067///
2068/// Use it to skip those messages when reading "what the user said" out of
2069/// [`ToolCallRequest::messages`] or [`TurnContext::messages`]. The one list,
2070/// built from the constants where each message is written, so it cannot
2071/// drift; [`ToolCallRequest::user_request`] uses it.
2072pub fn is_loop_injected(text: &str) -> bool {
2073    [
2074        crate::context::SUMMARY_PREFIX,
2075        crate::context::COMPACTION_MARKER,
2076        crate::llm_compaction::SUMMARY_MARKER,
2077        crate::agent_loop::AGENT_STOPPED_PREFIX,
2078        crate::agent_loop::LOOP_ABORT_PREFIX,
2079        crate::agent_loop::LOOP_NUDGE_PREFIX,
2080        crate::extension::EXTENSION_MESSAGE_PREFIX,
2081    ]
2082    .iter()
2083    .any(|prefix| text.starts_with(prefix))
2084}
2085
2086fn text_blocks(content: &[Content]) -> Option<String> {
2087    let text: Vec<&str> = content
2088        .iter()
2089        .filter_map(|c| match c {
2090            Content::Text { text } if !text.trim().is_empty() => Some(text.as_str()),
2091            _ => None,
2092        })
2093        .collect();
2094    (!text.is_empty()).then(|| text.join("\n"))
2095}
2096
2097/// The text of a message the user wrote: `None` for other roles, for user
2098/// messages without text, and for loop-injected ones.
2099fn real_user_text(m: &Message) -> Option<String> {
2100    match m {
2101        Message::User { content, .. } if is_real_user(m) => text_blocks(content),
2102        _ => None,
2103    }
2104}
2105
2106/// Whether `m` is a message the user wrote — not loop-injected — with or
2107/// without text (an image-only prompt counts).
2108fn is_real_user(m: &Message) -> bool {
2109    match m {
2110        Message::User { content, .. } => !content
2111            .iter()
2112            .find_map(|c| match c {
2113                Content::Text { text } => Some(text.as_str()),
2114                _ => None,
2115            })
2116            .is_some_and(is_loop_injected),
2117        _ => false,
2118    }
2119}
2120
2121/// Whether a message marks where compaction removed or rewrote history:
2122/// the drop marker, an `LlmCompaction` summary, or a level-2 `[Summary]`.
2123/// Nothing before one can be trusted to belong to the current request.
2124fn is_compaction_boundary(m: &Message) -> bool {
2125    let Message::User { content, .. } = m else {
2126        return false;
2127    };
2128    content.iter().any(|c| {
2129        matches!(c, Content::Text { text } if
2130            text.starts_with(crate::context::COMPACTION_MARKER)
2131                || text.starts_with(crate::llm_compaction::SUMMARY_MARKER)
2132                || text.starts_with(crate::context::SUMMARY_PREFIX))
2133    })
2134}
2135
2136/// Below this many characters a user message may be a reply ("yes, go
2137/// ahead") that needs the question it answers.
2138const SHORT_REPLY_CHARS: usize = 40;
2139
2140/// Whether assistant text ends by asking a question: its final sentence
2141/// ends in `?` once fenced code blocks and inline code are removed and
2142/// trailing whitespace and closing markup are trimmed. A `?` inside code
2143/// (Rust's `?`, a regex, a URL query) does not count.
2144pub(crate) fn ends_with_question(text: &str) -> bool {
2145    let mut prose = String::new();
2146    let mut in_fence = false;
2147    for line in text.lines() {
2148        if line.trim_start().starts_with("```") {
2149            in_fence = !in_fence;
2150            continue;
2151        }
2152        if in_fence {
2153            continue;
2154        }
2155        // Drop inline code spans: every other backtick-delimited segment.
2156        for (i, part) in line.split('`').enumerate() {
2157            if i % 2 == 0 {
2158                prose.push_str(part);
2159            }
2160        }
2161        prose.push('\n');
2162    }
2163    let trimmed = prose
2164        .trim_end_matches(|c: char| c.is_whitespace() || matches!(c, '*' | '_' | ')' | '"' | '\''));
2165    trimmed.ends_with('?')
2166}
2167
2168/// The most recent message the user wrote, not looking back past a
2169/// compaction boundary — and not past that message either: if it has no
2170/// text (an image-only prompt), the answer is `None`.
2171pub(crate) fn latest_user_text_of(messages: &[&Message]) -> Option<String> {
2172    let start = messages
2173        .iter()
2174        .rposition(|m| is_compaction_boundary(m))
2175        .map_or(0, |b| b + 1);
2176    messages[start..]
2177        .iter()
2178        .rev()
2179        .find(|m| is_real_user(m))
2180        .and_then(|m| real_user_text(m))
2181}
2182
2183/// What the user is asking for, as a decision policy should judge it.
2184///
2185/// 1. The latest message the user wrote **after the most recent compaction
2186///    boundary** — nothing before a boundary is trusted, because
2187///    compaction keeps the session's head, which may hold an unrelated,
2188///    older request. Loop-injected messages are skipped. The search stops at
2189///    that message even when it has no text (an image-only prompt): an
2190///    earlier message is never promoted in its place.
2191///    - When that message is short (under 40 characters) **and** the
2192///      assistant text before it ends with a question, the assistant text
2193///      and the user's earlier request (both after the boundary) are
2194///      included, labelled, so "yes, go ahead" carries what it confirms.
2195/// 2. When there is no such message, or it has no text: the text of the
2196///    run's own prompts (`run_prompts`: the messages this run was given,
2197///    which compaction cannot remove).
2198/// 3. Otherwise `None`.
2199pub(crate) fn user_request_of(messages: &[&Message], run_prompts: &[Message]) -> Option<String> {
2200    user_request_parts_of(messages, run_prompts).map(|p| p.to_prose())
2201}
2202
2203/// The pieces [`ToolCallRequest::user_request`] joins into prose, for
2204/// policies that want them separately; see
2205/// [`ToolCallRequest::user_request_parts`].
2206///
2207/// Marked `#[non_exhaustive]`: fields may be added in minor releases. Read
2208/// it; [`ToolCallRequest`] and [`TurnContext`] build it.
2209#[derive(Debug, Clone, PartialEq, Eq)]
2210#[non_exhaustive]
2211pub struct UserRequestParts {
2212    /// The request's text: the latest message the user wrote after the most
2213    /// recent compaction boundary — or, from [`UserRequestSource::RunPrompts`],
2214    /// the last of the run's prompts (all of them are in
2215    /// [`run_prompts`](Self::run_prompts)).
2216    pub latest: String,
2217    /// When `latest` is a short reply (under 40 characters) to an assistant
2218    /// message ending with a question: that question and the request before
2219    /// it.
2220    pub reply: Option<ReplyContext>,
2221    /// Where `latest` came from.
2222    pub source: UserRequestSource,
2223    /// From [`UserRequestSource::RunPrompts`]: the text of each of the run's
2224    /// prompts, in order. Empty for [`UserRequestSource::Conversation`].
2225    pub run_prompts: Vec<String>,
2226}
2227
2228/// What a short reply answers; see [`UserRequestParts::reply`].
2229#[derive(Debug, Clone, PartialEq, Eq)]
2230#[non_exhaustive]
2231pub struct ReplyContext {
2232    /// The assistant text the reply answers (it ends with a question).
2233    pub question: String,
2234    /// The user's message before that question (after the same compaction
2235    /// boundary), when there is one.
2236    pub earlier_request: Option<String>,
2237}
2238
2239/// Where [`UserRequestParts::latest`] came from.
2240#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2241#[non_exhaustive]
2242pub enum UserRequestSource {
2243    /// A message the user wrote, found in the conversation.
2244    Conversation,
2245    /// The run's own prompts: the conversation showed no usable user message
2246    /// (compacted away, or image-only).
2247    RunPrompts,
2248}
2249
2250impl UserRequestParts {
2251    /// The prose form [`ToolCallRequest::user_request`] returns. Not a
2252    /// stable format.
2253    fn to_prose(&self) -> String {
2254        if self.source == UserRequestSource::RunPrompts && self.run_prompts.len() > 1 {
2255            return self
2256                .run_prompts
2257                .iter()
2258                .map(|t| format!("User: {t}"))
2259                .collect::<Vec<_>>()
2260                .join("\n\n");
2261        }
2262        let Some(reply) = &self.reply else {
2263            return self.latest.clone();
2264        };
2265        let mut parts = Vec::new();
2266        if let Some(e) = &reply.earlier_request {
2267            parts.push(format!("Earlier user request: {e}"));
2268        }
2269        parts.push(format!("Assistant: {}", reply.question));
2270        parts.push(format!("Latest user message: {}", self.latest));
2271        parts.join("\n\n")
2272    }
2273}
2274
2275/// [`user_request_of`], in parts.
2276pub(crate) fn user_request_parts_of(
2277    messages: &[&Message],
2278    run_prompts: &[Message],
2279) -> Option<UserRequestParts> {
2280    let from_prompts = || {
2281        let texts: Vec<String> = run_prompts.iter().filter_map(real_user_text).collect();
2282        texts.last().cloned().map(|latest| UserRequestParts {
2283            latest,
2284            reply: None,
2285            source: UserRequestSource::RunPrompts,
2286            run_prompts: texts,
2287        })
2288    };
2289    let start = messages
2290        .iter()
2291        .rposition(|m| is_compaction_boundary(m))
2292        .map_or(0, |b| b + 1);
2293    let window = &messages[start..];
2294
2295    let Some(at) = window.iter().rposition(|m| is_real_user(m)) else {
2296        return from_prompts();
2297    };
2298    let Some(latest) = real_user_text(window[at]) else {
2299        return from_prompts();
2300    };
2301    let short = latest.chars().count() < SHORT_REPLY_CHARS;
2302    let mut parts = UserRequestParts {
2303        latest,
2304        reply: None,
2305        source: UserRequestSource::Conversation,
2306        run_prompts: Vec::new(),
2307    };
2308    if !short {
2309        return Some(parts);
2310    }
2311
2312    // The assistant text the reply answers: the nearest assistant message
2313    // with text, not looking past the user's previous message.
2314    let mut asked: Option<(usize, String)> = None;
2315    for (i, m) in window[..at].iter().enumerate().rev() {
2316        if is_real_user(m) {
2317            break;
2318        }
2319        if let Message::Assistant { content, .. } = m {
2320            if let Some(text) = text_blocks(content) {
2321                asked = Some((i, text));
2322                break;
2323            }
2324        }
2325    }
2326    let Some((asked_at, question)) = asked.filter(|(_, a)| ends_with_question(a)) else {
2327        return Some(parts);
2328    };
2329    let earlier_request = window[..asked_at]
2330        .iter()
2331        .rev()
2332        .find(|m| is_real_user(m))
2333        .and_then(|m| real_user_text(m));
2334    parts.reply = Some(ReplyContext {
2335        question,
2336        earlier_request,
2337    });
2338    Some(parts)
2339}
2340
2341// ---------------------------------------------------------------------------
2342// Tool middleware (permissions)
2343// ---------------------------------------------------------------------------
2344
2345/// Decision returned by a [`ToolMiddleware`] before a tool executes.
2346///
2347/// Deliberately NOT `#[non_exhaustive]` (same policy as [`StopReason`]):
2348/// this is a control-flow enum — a new variant should be a compile error for
2349/// matchers, not a silent wildcard fallthrough. Interactive flows like
2350/// "ask the user" need no variant: the hook is `async`, so prompt inside the
2351/// middleware and return `Allow`/`Deny`.
2352#[derive(Debug, Clone)]
2353pub enum ToolDecision {
2354    /// Execute the tool with the current arguments.
2355    Allow,
2356    /// Execute the tool with replacement arguments (e.g. a sandboxed path).
2357    Modify(serde_json::Value),
2358    /// Block the call. The reason is returned to the LLM as an error tool
2359    /// result so it can adapt (pick another tool, ask the user, ...); the
2360    /// loop itself continues.
2361    Deny(String),
2362}
2363
2364/// Borrowed view of a pending tool call, passed to
2365/// [`ToolMiddleware::before_tool`].
2366///
2367/// Marked `#[non_exhaustive]`: fields may be added in minor releases (turn
2368/// number, history access, ...) without breaking middleware implementations.
2369/// Constructed by the loop; middleware only reads it. To unit-test a
2370/// middleware, build one with [`ToolCallRequest::new`] plus
2371/// [`with_messages`](Self::with_messages) /
2372/// [`with_run_prompts`](Self::with_run_prompts).
2373#[derive(Debug)]
2374#[non_exhaustive]
2375pub struct ToolCallRequest<'a> {
2376    /// Provider-assigned id of this tool call.
2377    pub tool_call_id: &'a str,
2378    /// Name of the tool the model wants to run.
2379    pub tool_name: &'a str,
2380    /// Arguments as the model provided them (possibly rewritten by earlier
2381    /// middleware in the chain).
2382    pub args: &'a serde_json::Value,
2383    /// The loop's conversation context at the time of the call: history
2384    /// *before* `transform_context` / `convert_to_llm`, so it may differ from
2385    /// what the model saw, ending with the assistant message carrying this
2386    /// call. For policies that depend on what the user asked — see
2387    /// [`user_request`](Self::user_request).
2388    pub messages: &'a [AgentMessage],
2389    /// The user messages this run was given — its prompts, steering and
2390    /// follow-ups — which compaction cannot remove from here. Used by
2391    /// [`user_request`](Self::user_request) when the conversation no longer
2392    /// shows what the user asked.
2393    pub run_prompts: &'a [Message],
2394}
2395
2396impl<'a> ToolCallRequest<'a> {
2397    /// A request for one call with no conversation (see
2398    /// [`with_messages`](Self::with_messages) and
2399    /// [`with_run_prompts`](Self::with_run_prompts)) — for testing a
2400    /// [`ToolMiddleware`] outside the loop; the loop builds the real one.
2401    ///
2402    /// ```
2403    /// use yoagent::{Message, ToolCallRequest};
2404    /// let args = serde_json::json!({"path": "/tmp/x"});
2405    /// let prompts = [Message::user("delete /tmp/x")];
2406    /// let call = ToolCallRequest::new("call-1", "rm", &args).with_run_prompts(&prompts);
2407    /// assert_eq!(call.user_request().as_deref(), Some("delete /tmp/x"));
2408    /// ```
2409    pub fn new(tool_call_id: &'a str, tool_name: &'a str, args: &'a serde_json::Value) -> Self {
2410        Self {
2411            tool_call_id,
2412            tool_name,
2413            args,
2414            messages: &[],
2415            run_prompts: &[],
2416        }
2417    }
2418
2419    /// Set the conversation the call is made in (see
2420    /// [`messages`](Self::messages)).
2421    pub fn with_messages(mut self, messages: &'a [AgentMessage]) -> Self {
2422        self.messages = messages;
2423        self
2424    }
2425
2426    /// Set the run's prompts (see [`run_prompts`](Self::run_prompts)).
2427    pub fn with_run_prompts(mut self, run_prompts: &'a [Message]) -> Self {
2428        self.run_prompts = run_prompts;
2429        self
2430    }
2431
2432    fn llm_messages(&self) -> Vec<&Message> {
2433        self.messages
2434            .iter()
2435            .filter_map(AgentMessage::as_llm)
2436            .collect()
2437    }
2438
2439    /// The text of the most recent message the user wrote, not looking back
2440    /// past a compaction boundary. Loop-injected user-role messages
2441    /// (compaction summaries, limit notes, the loop-detection nudge) are
2442    /// skipped.
2443    pub fn latest_user_text(&self) -> Option<String> {
2444        latest_user_text_of(&self.llm_messages())
2445    }
2446
2447    /// What the user is asking for: the latest user message after the most
2448    /// recent compaction boundary — with, when it is a short reply to an
2449    /// assistant question, that question and the earlier request — else the
2450    /// run's own prompts ([`run_prompts`](Self::run_prompts)), else `None`.
2451    ///
2452    /// Including the assistant's question widens what counts as requested,
2453    /// and that text can itself be steered by injected content.
2454    ///
2455    /// The prose (its `Earlier user request:` / `Assistant:` / `Latest user
2456    /// message:` labels and separators) is **not a stable format**; parse
2457    /// [`user_request_parts`](Self::user_request_parts) instead.
2458    pub fn user_request(&self) -> Option<String> {
2459        user_request_of(&self.llm_messages(), self.run_prompts)
2460    }
2461
2462    /// [`user_request`](Self::user_request) as structured parts: the same
2463    /// selection, before it is joined into prose.
2464    pub fn user_request_parts(&self) -> Option<UserRequestParts> {
2465        user_request_parts_of(&self.llm_messages(), self.run_prompts)
2466    }
2467}
2468
2469/// Async hook that gates every tool call — the mechanism behind permission
2470/// prompts, policy engines, and argument rewriting.
2471///
2472/// yoagent ships the mechanism, not a policy: install middleware via
2473/// [`Agent::with_tool_middleware`](crate::Agent::with_tool_middleware) (or
2474/// [`AgentLoopConfig::tool_middleware`](crate::agent_loop::AgentLoopConfig))
2475/// and decide per call. Middleware run in a chain: each may rewrite the
2476/// arguments seen by later ones; the first `Deny` wins. With no middleware
2477/// installed, every call is allowed — behavior is unchanged.
2478///
2479/// The hook is `async` so an interactive app can prompt a human. Under the
2480/// default [`ToolExecutionStrategy::Parallel`], middleware for parallel tool
2481/// calls runs concurrently — serialize approval prompts inside your
2482/// implementation (or use `Sequential`) if you need one-at-a-time UX.
2483///
2484/// Middleware never sees a call whose arguments failed to resolve to a JSON
2485/// object (cut off mid-stream, or not an object — see
2486/// [`parse_tool_arguments`](crate::provider::parse_tool_arguments)). The loop
2487/// answers such a call with an error tool result *before* the chain runs, so
2488/// there is no real call to approve, deny or rewrite.
2489#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
2490#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
2491pub trait ToolMiddleware: crate::rt::MaybeSend + crate::rt::MaybeSync {
2492    async fn before_tool(&self, call: &ToolCallRequest<'_>) -> ToolDecision;
2493}
2494
2495// ---------------------------------------------------------------------------
2496// Turn hooks
2497// ---------------------------------------------------------------------------
2498
2499/// Borrowed view of the request about to be sent for one turn, passed to
2500/// [`TurnHook::before_turn`].
2501///
2502/// Marked `#[non_exhaustive]`: fields may be added in minor releases. Build
2503/// one with [`TurnContext::new`] to test a hook.
2504#[derive(Debug)]
2505#[non_exhaustive]
2506pub struct TurnContext<'a> {
2507    /// The system prompt of the request.
2508    pub system_prompt: &'a str,
2509    /// The messages being sent, after `transform_context`/`convert_to_llm`
2510    /// and before any hook's note.
2511    pub messages: &'a [Message],
2512    /// The tools offered this turn.
2513    pub tools: &'a [crate::provider::ToolDefinition],
2514    /// The model id.
2515    pub model: &'a str,
2516    /// The user messages this run was given (prompts, steering,
2517    /// follow-ups), which compaction cannot remove; see
2518    /// [`ToolCallRequest::run_prompts`].
2519    pub run_prompts: &'a [Message],
2520}
2521
2522impl<'a> TurnContext<'a> {
2523    /// A context with no run prompts (see [`with_run_prompts`](Self::with_run_prompts)),
2524    /// for testing a [`TurnHook`]; the loop builds the real one.
2525    pub fn new(
2526        system_prompt: &'a str,
2527        messages: &'a [Message],
2528        tools: &'a [crate::provider::ToolDefinition],
2529        model: &'a str,
2530    ) -> Self {
2531        Self {
2532            system_prompt,
2533            messages,
2534            tools,
2535            model,
2536            run_prompts: &[],
2537        }
2538    }
2539
2540    /// Set the run's prompts (the loop does this for you).
2541    pub fn with_run_prompts(mut self, run_prompts: &'a [Message]) -> Self {
2542        self.run_prompts = run_prompts;
2543        self
2544    }
2545
2546    /// The text of the most recent message the user wrote (loop-injected
2547    /// messages skipped, not looking back past a compaction boundary).
2548    pub fn latest_user_text(&self) -> Option<String> {
2549        latest_user_text_of(&self.messages.iter().collect::<Vec<_>>())
2550    }
2551
2552    /// What the user is asking for; see [`ToolCallRequest::user_request`]
2553    /// (the prose is not a stable format).
2554    pub fn user_request(&self) -> Option<String> {
2555        user_request_of(&self.messages.iter().collect::<Vec<_>>(), self.run_prompts)
2556    }
2557
2558    /// [`user_request`](Self::user_request) as structured parts; see
2559    /// [`ToolCallRequest::user_request_parts`].
2560    pub fn user_request_parts(&self) -> Option<UserRequestParts> {
2561        user_request_parts_of(&self.messages.iter().collect::<Vec<_>>(), self.run_prompts)
2562    }
2563}
2564
2565/// An async hook run before every LLM request, able to add one transient
2566/// note to that request's **latest user turn**.
2567///
2568/// The note is for **this request only**: it is appended as a text block to
2569/// the last user message being sent, never stored in history, and a hook
2570/// that returns `None` leaves the request byte-for-byte unchanged. Notes from
2571/// several hooks are joined in installation order.
2572///
2573/// **Prompt caching.** The note is on the latest user message; everything
2574/// before that message is unchanged, so the provider's cached prefix up to
2575/// it survives. (During tool turns, assistant and tool-result messages
2576/// follow the note.) On the next user prompt the previous user message is
2577/// sent without its note, so that last exchange is re-processed — a cache
2578/// miss from there. Keep the note stable within one request (derive it from
2579/// the user's request, not from every tool result) so its tool-calling
2580/// turns cache too.
2581///
2582/// Install via [`Agent::with_turn_hook`](crate::Agent::with_turn_hook), or
2583/// for a raw loop wrap the provider in
2584/// [`TurnHookProvider`](crate::provider::TurnHookProvider). The hook runs
2585/// once per provider call, so a retried request runs it again; memoize if it
2586/// is expensive. A panicking hook is contained and contributes nothing.
2587#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
2588#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
2589pub trait TurnHook: crate::rt::MaybeSend + crate::rt::MaybeSync {
2590    /// Return a note to append to this request's latest user turn, or `None`.
2591    async fn before_turn(&self, turn: &TurnContext<'_>) -> Option<String>;
2592}
2593
2594// ---------------------------------------------------------------------------
2595// Helpers
2596// ---------------------------------------------------------------------------
2597
2598pub fn now_ms() -> u64 {
2599    // web_time: std::time on native targets; the host clock on wasm32, where
2600    // std::time::SystemTime::now() panics.
2601    web_time::SystemTime::now()
2602        .duration_since(web_time::UNIX_EPOCH)
2603        .unwrap_or_default()
2604        .as_millis() as u64
2605}
2606
2607impl fmt::Display for StopReason {
2608    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2609        match self {
2610            Self::Stop => write!(f, "stop"),
2611            Self::Length => write!(f, "length"),
2612            Self::ToolUse => write!(f, "toolUse"),
2613            Self::Error => write!(f, "error"),
2614            Self::Aborted => write!(f, "aborted"),
2615            Self::Refusal => write!(f, "refusal"),
2616        }
2617    }
2618}
2619
2620/// Freezes the serde wire contract for `AgentEvent` and `StreamDelta`.
2621///
2622/// Both enums are documented as a stable wire format for websocket fanout
2623/// servers, TypeScript clients and JSONL pipes, so a changed tag or a variant
2624/// that quietly serializes however serde happens to derive it are breaking
2625/// changes for consumers who never rebuild against this crate.
2626///
2627/// **Scope.** This freezes the `"type"` tag of every variant, that every
2628/// variant has a sample, and that each sample round-trips. It does **not**
2629/// freeze payload shape: a `#[serde(rename)]` on a field, a field added or
2630/// removed, or a changed field type all pass here. Field names are pinned
2631/// only where `tests/serialization_test.rs` asserts them by literal.
2632///
2633/// This lives in the defining crate on purpose. Both enums are
2634/// `#[non_exhaustive]`, so an integration test *cannot* match them
2635/// exhaustively — its match needs a `_` arm, and a wildcard turns "adding a
2636/// variant fails to compile" into "adding a variant is silently untested".
2637/// Inside this crate exhaustiveness still applies.
2638#[cfg(test)]
2639mod wire_tag_freeze {
2640    use super::*;
2641    use std::collections::BTreeSet;
2642
2643    /// Declares the frozen tag **and** a sample value for every variant of a
2644    /// `#[serde(tag = "type")]` enum, from a single list.
2645    ///
2646    /// This is the fix for the class of bug that made the old guard useless: a
2647    /// hand-written sample list and a hand-written variant count could never
2648    /// notice a *new* variant, because a new variant appears in neither. Here
2649    /// the generated match has no wildcard, so adding a variant fails to
2650    /// compile — and the only way to fix that is to add a line below, which
2651    /// supplies the sample in the same breath.
2652    ///
2653    /// Three mistakes are caught by the compiler rather than by luck: a
2654    /// duplicated pattern makes the later arm `unreachable_patterns` (an error
2655    /// under CI's `-Dwarnings`), a sample of the wrong type does not compile,
2656    /// and a missing arm is a non-exhaustive match.
2657    ///
2658    /// The specifier is `pat_param`, not `pat`, and that is load-bearing: `pat`
2659    /// would accept an or-pattern, letting someone answer the compile error by
2660    /// widening an unrelated arm (`TurnStart | NewVariant => "turnStart"`)
2661    /// instead of adding a line — leaving the new variant with no sample and no
2662    /// coverage, which is exactly the hole this macro exists to close. No arm
2663    /// can legitimately need one, since two variants cannot share a tag.
2664    macro_rules! wire_freeze {
2665        ($ty:ty, $tag_of:ident, $samples:ident, $($pat:pat_param => $tag:literal = $sample:expr),+ $(,)?) => {
2666            /// The frozen `"type"` tag per variant. Changing one breaks every
2667            /// deployed wire client — do not edit casually.
2668            fn $tag_of(v: &$ty) -> &'static str {
2669                match v { $($pat => $tag,)+ }
2670            }
2671
2672            /// One sample per variant, in declaration order. The tests re-derive
2673            /// each tag from its sample rather than trusting that order — see
2674            /// [`assert_frozen`].
2675            fn $samples() -> Vec<$ty> { vec![$($sample,)+] }
2676        };
2677    }
2678
2679    /// A populated assistant message. Every field is deliberately non-default:
2680    /// a round-trip cannot detect a field that `#[serde(skip)]` drops if the
2681    /// value it reconstructs is the default anyway.
2682    fn msg() -> AgentMessage {
2683        AgentMessage::Llm(Message::Assistant {
2684            content: vec![Content::Text { text: "hi".into() }],
2685            stop_reason: StopReason::ToolUse,
2686            model: "mock-1".into(),
2687            provider: "mock".into(),
2688            usage: Usage {
2689                input: 11,
2690                output: 22,
2691                cache_read: 33,
2692                cache_write: 44,
2693                total_tokens: 110,
2694            },
2695            timestamp: 7,
2696            error_message: Some("boom".into()),
2697        })
2698    }
2699
2700    fn tool_result() -> ToolResult {
2701        ToolResult {
2702            content: vec![Content::Text { text: "ok".into() }],
2703            details: serde_json::json!({"exitCode": 0}),
2704        }
2705    }
2706
2707    fn tool_result_message() -> Message {
2708        Message::ToolResult {
2709            tool_call_id: "tc-1".into(),
2710            tool_name: "bash".into(),
2711            content: vec![Content::Text { text: "ok".into() }],
2712            is_error: false,
2713            timestamp: 9,
2714        }
2715    }
2716
2717    wire_freeze! {
2718        AgentEvent, expected_event_tag, event_samples,
2719        AgentEvent::AgentStart => "agentStart" = AgentEvent::AgentStart,
2720        AgentEvent::AgentEnd { .. } => "agentEnd" = AgentEvent::agent_end(
2721            vec![msg()],
2722            SessionStats {
2723                usage: Usage {
2724                    input: 5,
2725                    output: 6,
2726                    cache_read: 7,
2727                    cache_write: 8,
2728                    total_tokens: 26,
2729                },
2730                turns: 3,
2731                cost_usd: Some(0.02),
2732                compactions: 1,
2733                sub_agents: SubAgentSpend {
2734                    usage: Usage {
2735                        input: 50,
2736                        output: 60,
2737                        cache_read: 70,
2738                        cache_write: 80,
2739                        total_tokens: 0,
2740                    },
2741                    cost_usd: Some(0.2),
2742                    runs: 2,
2743                },
2744                decision: DecisionStats {
2745                    requests: 3,
2746                    failures: 1,
2747                    timeouts: 1,
2748                    usage: Usage {
2749                        input: 900,
2750                        ..Default::default()
2751                    },
2752                    cost_usd: Some(0.0000378),
2753                    unpriced: 0,
2754                },
2755                compaction: CompactionSpend {
2756                    usage: Usage {
2757                        input: 1_000,
2758                        output: 100,
2759                        cache_read: 10,
2760                        cache_write: 1,
2761                        total_tokens: 0,
2762                    },
2763                    cost_usd: Some(0.012),
2764                    requests: 2,
2765                },
2766            },
2767        ),
2768        AgentEvent::TurnStart => "turnStart" = AgentEvent::TurnStart,
2769        AgentEvent::TurnEnd { .. } => "turnEnd" = AgentEvent::TurnEnd {
2770            message: msg(),
2771            tool_results: vec![tool_result_message()],
2772        },
2773        AgentEvent::MessageStart { .. } => "messageStart"
2774            = AgentEvent::MessageStart { message: msg() },
2775        AgentEvent::MessageUpdate { .. } => "messageUpdate" = AgentEvent::MessageUpdate {
2776            message: msg(),
2777            delta: StreamDelta::Text { delta: "hi".into() },
2778        },
2779        AgentEvent::MessageEnd { .. } => "messageEnd"
2780            = AgentEvent::MessageEnd { message: msg() },
2781        AgentEvent::ToolExecutionStart { .. } => "toolExecutionStart"
2782            = AgentEvent::ToolExecutionStart {
2783                tool_call_id: "tc-1".into(),
2784                tool_name: "bash".into(),
2785                args: serde_json::json!({"command": "ls"}),
2786            },
2787        AgentEvent::ToolExecutionUpdate { .. } => "toolExecutionUpdate"
2788            = AgentEvent::ToolExecutionUpdate {
2789                tool_call_id: "tc-1".into(),
2790                tool_name: "bash".into(),
2791                partial_result: tool_result(),
2792            },
2793        AgentEvent::ToolExecutionEnd { .. } => "toolExecutionEnd"
2794            = AgentEvent::ToolExecutionEnd {
2795                tool_call_id: "tc-1".into(),
2796                tool_name: "bash".into(),
2797                result: tool_result(),
2798                is_error: false,
2799            },
2800        AgentEvent::ProgressMessage { .. } => "progressMessage"
2801            = AgentEvent::ProgressMessage {
2802                tool_call_id: "tc-1".into(),
2803                tool_name: "bash".into(),
2804                text: "50% done".into(),
2805            },
2806        AgentEvent::InputRejected { .. } => "inputRejected"
2807            = AgentEvent::InputRejected { reason: "injection detected".into() },
2808        AgentEvent::ProviderRetry { .. } => "providerRetry"
2809            = AgentEvent::provider_retry(1, 4, "rate limited", std::time::Duration::from_millis(1500)),
2810        AgentEvent::LoopDetected { .. } => "loopDetected"
2811            = AgentEvent::loop_detected("bash", 3, false),
2812        AgentEvent::ContextCompacted { .. } => "contextCompacted"
2813            = AgentEvent::ContextCompacted {
2814                method: CompactionMethod::Summarized,
2815                messages_before: 40,
2816                messages_after: 13,
2817                tokens_before: 96_500,
2818                tokens_after: 41_200,
2819                summary: Some(SummaryStats::new(
2820                    28,
2821                    Usage {
2822                        input: 54_000,
2823                        output: 900,
2824                        cache_read: 0,
2825                        cache_write: 0,
2826                        total_tokens: 54_900,
2827                    },
2828                    Some(0.17),
2829                )),
2830            },
2831    }
2832
2833    wire_freeze! {
2834        StreamDelta, expected_delta_tag, delta_samples,
2835        StreamDelta::Text { .. } => "text" = StreamDelta::Text { delta: "hi".into() },
2836        StreamDelta::Thinking { .. } => "thinking"
2837            = StreamDelta::Thinking { delta: "hmm".into() },
2838        StreamDelta::ToolCallDelta { .. } => "toolCallDelta"
2839            = StreamDelta::ToolCallDelta { delta: "{}".into() },
2840    }
2841
2842    /// Every key in `v`, recursively, as `path -> key` pairs.
2843    ///
2844    /// Recursive on purpose. Checking only the top level would pass a
2845    /// snake_case key one level down — `message.usage.total_tokens` reaches
2846    /// clients just as surely as `turnEnd.toolResults` does, and the nested
2847    /// payload structs carry their own `rename_all`, which nothing else here
2848    /// would notice going missing.
2849    fn all_keys(v: &serde_json::Value, path: &str, out: &mut Vec<(String, String)>) {
2850        match v {
2851            serde_json::Value::Object(map) => {
2852                for (k, child) in map {
2853                    out.push((path.to_string(), k.clone()));
2854                    all_keys(child, &format!("{path}.{k}"), out);
2855                }
2856            }
2857            serde_json::Value::Array(items) => {
2858                for (i, child) in items.iter().enumerate() {
2859                    all_keys(child, &format!("{path}[{i}]"), out);
2860                }
2861            }
2862            _ => {}
2863        }
2864    }
2865
2866    /// Asserts the frozen contract for one sample: the declared tag is the one
2867    /// serde emits, no payload key anywhere in the value contains an
2868    /// underscore, and the value survives a JSON round-trip.
2869    ///
2870    /// `seen` collects tags so a mis-paired sample is caught **when it
2871    /// duplicates another variant** — which is the case that costs coverage,
2872    /// since some variant is then left unsampled. Two samples swapped between
2873    /// lines is invisible here, and harmless: the set of samples is unchanged
2874    /// and every tag is still checked against serde.
2875    fn assert_frozen<T>(sample: &T, declared: &'static str, seen: &mut BTreeSet<&'static str>)
2876    where
2877        T: serde::Serialize + serde::de::DeserializeOwned + std::fmt::Debug + PartialEq,
2878    {
2879        let v = serde_json::to_value(sample).expect("serialize");
2880
2881        assert_eq!(
2882            v["type"], declared,
2883            "wire tag drifted: {sample:?} serializes as {} but wire_freeze! declares {declared}. \
2884             Changing a tag breaks every deployed client — if this is intentional it is a \
2885             breaking change, not a test fix",
2886            v["type"]
2887        );
2888
2889        let mut keys = Vec::new();
2890        all_keys(&v, declared, &mut keys);
2891        for (path, key) in &keys {
2892            assert!(
2893                !key.contains('_'),
2894                "payload key {key:?} at {path} is not camelCase. Every struct on this wire \
2895                 carries rename_all = \"camelCase\" and TS clients hardcode these names, so a \
2896                 snake_case key here means a rename attribute is missing"
2897            );
2898        }
2899
2900        let back: T = serde_json::from_value(v).expect("round-trip deserialize");
2901        assert_eq!(
2902            &back, sample,
2903            "{declared} did not survive a JSON round-trip"
2904        );
2905
2906        assert!(
2907            seen.insert(declared),
2908            "two samples serialize as {declared} — a sample in wire_freeze! does not match \
2909             the pattern on its own line, so some variant has no sample at all"
2910        );
2911    }
2912
2913    #[test]
2914    fn every_event_variant_is_frozen_tagged_and_round_trips() {
2915        let mut seen = BTreeSet::new();
2916        for sample in &event_samples() {
2917            assert_frozen(sample, expected_event_tag(sample), &mut seen);
2918        }
2919    }
2920
2921    #[test]
2922    fn every_delta_variant_is_frozen_tagged_and_round_trips() {
2923        let mut seen = BTreeSet::new();
2924        for sample in &delta_samples() {
2925            assert_frozen(sample, expected_delta_tag(sample), &mut seen);
2926        }
2927    }
2928}
2929
2930#[cfg(test)]
2931mod thinking_level_tests {
2932    use super::ThinkingLevel;
2933
2934    #[test]
2935    fn serde_names_are_lowercase_and_old_values_still_load() {
2936        for (level, name) in [
2937            (ThinkingLevel::Off, "off"),
2938            (ThinkingLevel::Minimal, "minimal"),
2939            (ThinkingLevel::Low, "low"),
2940            (ThinkingLevel::Medium, "medium"),
2941            (ThinkingLevel::High, "high"),
2942            (ThinkingLevel::XHigh, "xhigh"),
2943            (ThinkingLevel::Max, "max"),
2944        ] {
2945            let json = serde_json::to_string(&level).unwrap();
2946            assert_eq!(json, format!("\"{name}\""));
2947            let back: ThinkingLevel = serde_json::from_str(&json).unwrap();
2948            assert_eq!(back, level);
2949        }
2950    }
2951}