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}