Skip to main content

agent_sdk_foundation/
types.rs

1//! Core types for the agent SDK.
2//!
3//! This module contains the fundamental types used throughout the SDK:
4//!
5//! - [`ThreadId`]: Unique identifier for conversation threads
6//! - [`AgentConfig`]: Configuration for the agent loop
7//! - [`TokenUsage`]: Token consumption statistics
8//! - [`ToolResult`]: Result returned from tool execution
9//! - [`ToolTier`]: Permission tiers for tools
10//! - [`AgentRunState`]: Outcome of running the agent loop (looping mode)
11//! - [`TurnOutcome`]: Outcome of running a single turn (single-turn mode)
12//! - [`TurnSummary`]: Structured server-facing outcome metadata
13//! - [`AgentInput`]: Input to start or resume an agent run
14//! - [`AgentContinuation`]: Opaque state for resuming after confirmation
15//! - [`AgentState`]: Checkpointable agent state
16
17use crate::audit::AuditProvenance;
18use crate::llm::{ContentBlock, ContentSource};
19use serde::{Deserialize, Serialize};
20use std::collections::HashMap;
21use time::OffsetDateTime;
22use uuid::Uuid;
23
24/// Unique identifier for a conversation thread
25#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
26pub struct ThreadId(pub String);
27
28impl ThreadId {
29    #[must_use]
30    pub fn new() -> Self {
31        Self(Uuid::new_v4().to_string())
32    }
33
34    #[must_use]
35    pub fn from_string(s: impl Into<String>) -> Self {
36        Self(s.into())
37    }
38}
39
40impl Default for ThreadId {
41    fn default() -> Self {
42        Self::new()
43    }
44}
45
46impl std::fmt::Display for ThreadId {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        write!(f, "{}", self.0)
49    }
50}
51
52/// Configuration for the agent loop
53#[derive(Clone, Debug)]
54pub struct AgentConfig {
55    /// Maximum number of turns (LLM round-trips) before stopping
56    pub max_turns: Option<usize>,
57    /// Maximum tokens per response.
58    ///
59    /// If `None`, the SDK uses the provider/model-specific default.
60    pub max_tokens: Option<u32>,
61    /// System prompt for the agent
62    pub system_prompt: String,
63    /// Model identifier
64    pub model: String,
65    /// Retry configuration for transient errors
66    pub retry: RetryConfig,
67    /// Enable streaming responses from the LLM.
68    ///
69    /// When `true`, emits `TextDelta` and `ThinkingDelta` events as text arrives
70    /// in real-time. When `false` (default), waits for the complete response
71    /// before emitting `Text` and `Thinking` events.
72    pub streaming: bool,
73    /// Optional per-tool execution timeout in milliseconds.
74    ///
75    /// When set, the agent loop races each tool's `execute()` future
76    /// against this budget at the SDK boundary (mirroring
77    /// `SubagentConfig::timeout_ms`). A tool that exceeds the budget is
78    /// stopped and reported with a synthetic timeout `ToolResult`, keeping
79    /// the `tool_use` / `tool_result` history balanced even for
80    /// non-cooperative tools. `None` (default) disables the boundary
81    /// timeout entirely.
82    pub tool_timeout_ms: Option<u64>,
83    /// Optional run-level token / cost budgets.
84    ///
85    /// When set, the agent loop checks the cumulative token usage (and the
86    /// estimated USD cost, when the provider/model has pricing metadata)
87    /// at every turn-continuation boundary. If a configured limit is
88    /// exceeded the run stops with
89    /// [`AgentRunState::BudgetExceeded`] / [`TurnOutcome::BudgetExceeded`]
90    /// instead of starting another turn. `None` (default) disables
91    /// budgeting entirely.
92    pub usage_limits: Option<UsageLimits>,
93    /// Maximum number of read-only (`ToolTier::Observe`) tool calls the SDK
94    /// runs concurrently within a single parallel batch.
95    ///
96    /// `None` (default) keeps the historical unbounded behavior — every
97    /// adjacent observe-tier call in a turn is dispatched at once.
98    /// `Some(1)` forces strictly sequential execution; `Some(n)` caps the
99    /// in-flight count at `n`. `Some(0)` is not meaningful and is treated
100    /// as `Some(1)` (sequential). Result ordering is always preserved.
101    pub max_parallel_tools: Option<usize>,
102}
103
104impl Default for AgentConfig {
105    fn default() -> Self {
106        Self {
107            max_turns: None,
108            max_tokens: None,
109            system_prompt: String::new(),
110            model: String::from("claude-sonnet-4-5-20250929"),
111            retry: RetryConfig::default(),
112            streaming: false,
113            tool_timeout_ms: None,
114            usage_limits: None,
115            max_parallel_tools: None,
116        }
117    }
118}
119
120/// Run-level token / cost budgets applied by the agent loop.
121///
122/// Each limit is independent and optional. A `None` field imposes no
123/// constraint. When a limit is exceeded the run terminates with a
124/// [`BudgetLimitKind`] identifying which limit fired.
125///
126/// # Evaluation boundaries and bounded overshoot
127///
128/// Budgets are evaluated at loop boundaries — before a fresh prompt is
129/// ingested, before every LLM turn is dispatched, immediately after
130/// context-compaction spend is folded in, and before overflow-recovery
131/// summarization — never mid-call. Any single boundary may therefore
132/// overshoot by the calls already in flight: one turn call, or up to two
133/// compaction summarization calls (the second only when the first summary
134/// was truncated and retried with a doubled token budget). All such calls
135/// are folded into the cumulative usage and re-checked at the next
136/// boundary, so the overshoot is bounded and never compounds.
137///
138/// Three accounting gaps are known and deliberate: a streamed attempt
139/// that fails mid-response and is retried may have billed partial tokens
140/// the provider error channel does not carry, so those are not counted
141/// (tracked alongside the channel's other gaps); a compaction cancelled
142/// between its first (billed) summarization call and a truncation retry
143/// drops that first call's usage (the in-flight future is torn down with
144/// the accumulator inside); and a custom compactor installed via
145/// `with_custom_compactor` has its reported usage priced at the run's
146/// provider/model, so cost is approximate when the compactor uses a
147/// different backend.
148#[derive(Clone, Debug, Default)]
149pub struct UsageLimits {
150    /// Maximum cumulative tokens (input + output, summed across every
151    /// turn) before the run stops.
152    pub max_total_tokens: Option<u64>,
153    /// Maximum estimated cost in USD before the run stops.
154    ///
155    /// Only enforced when the run's provider/model has pricing metadata in
156    /// [`agent_sdk_providers`](https://docs.rs/agent-sdk-providers); models
157    /// without pricing never trip this limit.
158    ///
159    /// Cost tracking follows the loop's configured provider provenance (the
160    /// provider/model the top-level provider reports). Behind
161    /// fallback-provider or model-router wrappers the provenance may name a
162    /// different backend than the one that actually served a given call, so
163    /// the estimated cost — and therefore this limit — may be inaccurate
164    /// there.
165    pub max_cost_usd: Option<f64>,
166}
167
168/// Which run-level budget was exceeded.
169#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
170#[serde(rename_all = "snake_case")]
171pub enum BudgetLimitKind {
172    /// The cumulative token budget ([`UsageLimits::max_total_tokens`]) was hit.
173    TotalTokens,
174    /// The estimated-cost budget ([`UsageLimits::max_cost_usd`]) was hit.
175    CostUsd,
176}
177
178/// Configuration for retry behavior on transient errors.
179#[derive(Clone, Debug)]
180pub struct RetryConfig {
181    /// Maximum number of retry attempts
182    pub max_retries: u32,
183    /// Base delay in milliseconds for exponential backoff
184    pub base_delay_ms: u64,
185    /// Maximum delay cap in milliseconds
186    pub max_delay_ms: u64,
187}
188
189impl Default for RetryConfig {
190    fn default() -> Self {
191        Self {
192            max_retries: 5,
193            base_delay_ms: 1000,
194            max_delay_ms: 120_000,
195        }
196    }
197}
198
199impl RetryConfig {
200    /// Create a retry config with no retries (for testing)
201    #[must_use]
202    pub const fn no_retry() -> Self {
203        Self {
204            max_retries: 0,
205            base_delay_ms: 0,
206            max_delay_ms: 0,
207        }
208    }
209
210    /// Create a retry config with fast retries (for testing)
211    #[must_use]
212    pub const fn fast() -> Self {
213        Self {
214            max_retries: 5,
215            base_delay_ms: 10,
216            max_delay_ms: 100,
217        }
218    }
219}
220
221/// Token usage statistics
222#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
223pub struct TokenUsage {
224    pub input_tokens: u32,
225    pub output_tokens: u32,
226    #[serde(default)]
227    pub cached_input_tokens: u32,
228    #[serde(default)]
229    pub cache_creation_input_tokens: u32,
230}
231
232impl TokenUsage {
233    pub const fn add(&mut self, other: &Self) {
234        self.input_tokens = self.input_tokens.saturating_add(other.input_tokens);
235        self.output_tokens = self.output_tokens.saturating_add(other.output_tokens);
236        self.cached_input_tokens = self
237            .cached_input_tokens
238            .saturating_add(other.cached_input_tokens);
239        self.cache_creation_input_tokens = self
240            .cache_creation_input_tokens
241            .saturating_add(other.cache_creation_input_tokens);
242    }
243}
244
245/// Result of a tool execution
246#[derive(Clone, Debug, Serialize, Deserialize)]
247pub struct ToolResult {
248    /// Whether the tool execution succeeded
249    pub success: bool,
250    /// Output content (displayed to user and fed back to LLM)
251    pub output: String,
252    /// Optional structured data
253    pub data: Option<serde_json::Value>,
254    /// Optional documents (PDFs, images) to pass back to the LLM as native content blocks.
255    /// The agent appends these as `ContentBlock::Document` / `ContentBlock::Image` blocks
256    /// in the same user message as the tool result, so the model can read them directly.
257    #[serde(default, skip_serializing_if = "Vec::is_empty")]
258    pub documents: Vec<ContentSource>,
259    /// Duration of the tool execution in milliseconds
260    pub duration_ms: Option<u64>,
261}
262
263impl ToolResult {
264    #[must_use]
265    pub fn success(output: impl Into<String>) -> Self {
266        Self {
267            success: true,
268            output: output.into(),
269            data: None,
270            documents: Vec::new(),
271            duration_ms: None,
272        }
273    }
274
275    #[must_use]
276    pub fn success_with_data(output: impl Into<String>, data: serde_json::Value) -> Self {
277        Self {
278            success: true,
279            output: output.into(),
280            data: Some(data),
281            documents: Vec::new(),
282            duration_ms: None,
283        }
284    }
285
286    #[must_use]
287    pub fn error(message: impl Into<String>) -> Self {
288        Self {
289            success: false,
290            output: message.into(),
291            data: None,
292            documents: Vec::new(),
293            duration_ms: None,
294        }
295    }
296
297    #[must_use]
298    pub const fn with_duration(mut self, duration_ms: u64) -> Self {
299        self.duration_ms = Some(duration_ms);
300        self
301    }
302
303    /// Attach documents (PDFs, images) to be sent back to the LLM as native content blocks.
304    ///
305    /// Use this when a tool produces a binary document that the model should read directly,
306    /// e.g. a decrypted PDF that Anthropic can parse natively via its document API.
307    ///
308    /// # Example
309    /// ```rust,ignore
310    /// use agent_sdk::{ToolResult, ContentSource};
311    ///
312    /// Ok(ToolResult::success("PDF decrypted.").with_documents(vec![
313    ///     ContentSource::new("application/pdf", base64_data),
314    /// ]))
315    /// ```
316    #[must_use]
317    pub fn with_documents(mut self, documents: Vec<ContentSource>) -> Self {
318        self.documents = documents;
319        self
320    }
321}
322
323/// Permission tier for tools
324#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
325pub enum ToolTier {
326    /// Read-only, always allowed (e.g., `get_balance`)
327    Observe,
328    /// Requires confirmation before execution.
329    /// The application determines the confirmation type (normal, PIN, biometric).
330    Confirm,
331}
332
333/// Snapshot of agent state for checkpointing
334#[derive(Clone, Debug, Serialize, Deserialize)]
335pub struct AgentState {
336    pub thread_id: ThreadId,
337    pub turn_count: usize,
338    pub total_usage: TokenUsage,
339    pub metadata: HashMap<String, serde_json::Value>,
340    #[serde(with = "time::serde::rfc3339")]
341    pub created_at: OffsetDateTime,
342    /// Number of consecutive `on_llm_response` guardrail rejections
343    /// (`RetryWithFeedback`) across the thread's turns.
344    ///
345    /// Persisted so the loop's consecutive-rejection cap also binds
346    /// host-driven single-turn orchestration, where each `run_turn` rebuilds
347    /// its in-memory context from this state. Reset to zero whenever the
348    /// hook accepts a response. Additive and wire-compatible: absent in
349    /// older snapshots, defaulting to zero.
350    #[serde(default)]
351    pub guardrail_retries: usize,
352    /// Estimated USD cost accumulated across the thread's LLM calls.
353    ///
354    /// Each call's usage is priced at the provider/model that served it and
355    /// added here, so a thread that rotates models keeps the true sum
356    /// instead of repricing its whole history at the newest model's rates.
357    /// `None` means no priced usage has been tracked yet: a fresh thread
358    /// before its first priced call, a thread whose models have no pricing
359    /// metadata, or a snapshot predating this field. Legacy snapshots are
360    /// seeded once (best-effort) by repricing the aggregate usage at the
361    /// rates current when the thread next runs, then accumulate normally.
362    /// Additive and wire-compatible via `#[serde(default)]`.
363    #[serde(default)]
364    pub accumulated_cost_usd: Option<f64>,
365}
366
367impl AgentState {
368    #[must_use]
369    pub fn new(thread_id: ThreadId) -> Self {
370        Self {
371            thread_id,
372            turn_count: 0,
373            total_usage: TokenUsage::default(),
374            metadata: HashMap::new(),
375            created_at: OffsetDateTime::now_utc(),
376            guardrail_retries: 0,
377            accumulated_cost_usd: None,
378        }
379    }
380}
381
382/// Error from the agent loop.
383#[derive(Debug, Clone)]
384pub struct AgentError {
385    /// Error message
386    pub message: String,
387    /// Whether the error is potentially recoverable
388    pub recoverable: bool,
389}
390
391impl AgentError {
392    #[must_use]
393    pub fn new(message: impl Into<String>, recoverable: bool) -> Self {
394        Self {
395            message: message.into(),
396            recoverable,
397        }
398    }
399}
400
401impl std::fmt::Display for AgentError {
402    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
403        write!(f, "{}", self.message)
404    }
405}
406
407impl std::error::Error for AgentError {}
408
409/// Outcome of running the agent loop.
410#[derive(Debug)]
411#[non_exhaustive]
412pub enum AgentRunState {
413    /// Agent completed successfully.
414    Done {
415        total_turns: u32,
416        total_usage: TokenUsage,
417        /// Estimated cost of the run in USD, when the provider/model has
418        /// pricing metadata; `None` otherwise.
419        estimated_cost_usd: Option<f64>,
420    },
421
422    /// Agent was stopped because a run-level usage budget was exceeded.
423    BudgetExceeded {
424        total_turns: u32,
425        total_usage: TokenUsage,
426        /// Estimated cost of the run in USD at the moment the budget was
427        /// hit, when pricing metadata is available.
428        estimated_cost_usd: Option<f64>,
429        /// Which budget limit was exceeded.
430        limit: BudgetLimitKind,
431    },
432
433    /// Agent was refused by the model (safety/policy).
434    Refusal {
435        total_turns: u32,
436        total_usage: TokenUsage,
437    },
438
439    /// Agent encountered an error.
440    Error(AgentError),
441
442    /// Agent is awaiting confirmation for a tool call.
443    /// The application should present this to the user and call resume.
444    AwaitingConfirmation {
445        /// ID of the pending tool call (from LLM)
446        tool_call_id: String,
447        /// Tool name string (for LLM protocol)
448        tool_name: String,
449        /// Human-readable display name
450        display_name: String,
451        /// Tool input parameters
452        input: serde_json::Value,
453        /// Description of what confirmation is needed
454        description: String,
455        /// Versioned continuation envelope for resuming.
456        continuation: Box<ContinuationEnvelope>,
457    },
458
459    /// Agent run was cancelled via a cancellation token.
460    Cancelled {
461        total_turns: u32,
462        total_usage: TokenUsage,
463    },
464}
465
466/// Information about a pending tool call that was extracted from the LLM response.
467#[derive(Clone, Debug, Serialize, Deserialize)]
468pub struct PendingToolCallInfo {
469    /// Unique ID for this tool call (from LLM)
470    pub id: String,
471    /// Tool name string (for LLM protocol)
472    pub name: String,
473    /// Human-readable display name
474    pub display_name: String,
475    /// Permission tier of the tool, captured at the moment the LLM
476    /// requested the call.
477    ///
478    /// Persisted on the continuation so that authoritative audit records
479    /// on the externalized tool-runtime path can attribute the correct
480    /// tier even though the registry is no longer reachable at resume
481    /// time. Defaults to [`ToolTier::Confirm`] (the strictest default)
482    /// when deserialized from a continuation that predates this field.
483    #[serde(default = "default_pending_tier")]
484    pub tier: ToolTier,
485    /// Tool input parameters as requested by the LLM.
486    pub input: serde_json::Value,
487    /// Effective input after SDK preparation (e.g. listen-context enrichment).
488    ///
489    /// For most tools this equals `input`.  The server persists this for
490    /// execution while `input` stays as the audit trail.
491    #[serde(default)]
492    pub effective_input: serde_json::Value,
493    /// Optional context for tools that prepare asynchronously and execute later.
494    #[serde(default, skip_serializing_if = "Option::is_none")]
495    pub listen_context: Option<ListenExecutionContext>,
496}
497
498/// Default tier used when deserializing a continuation that predates
499/// the `tier` field — the strictest default so legacy continuations
500/// surface as confirm-tier rather than silently observe-tier.
501const fn default_pending_tier() -> ToolTier {
502    ToolTier::Confirm
503}
504
505// ── Structured policy input ──────────────────────────────────────────
506
507/// Structured input passed to the `pre_tool_use` hook for policy
508/// evaluation.
509///
510/// Bundles every datum that a server-side policy engine needs to make an
511/// allow / block / confirm decision, replacing the earlier loose
512/// `(tool_name, input, tier)` triple.
513///
514/// The `AgentHooks` trait itself lives in `agent-sdk-tools` to avoid a
515/// dependency cycle; this struct is the stable contract they share.
516#[derive(Clone, Debug)]
517pub struct ToolInvocation {
518    /// Unique ID for this tool call (from LLM).
519    pub tool_call_id: String,
520    /// Tool name string (for LLM protocol).
521    pub tool_name: String,
522    /// Human-readable display name.
523    pub display_name: String,
524    /// Permission tier of the tool.
525    pub tier: ToolTier,
526    /// Input parameters as requested by the LLM (the audit trail).
527    pub requested_input: serde_json::Value,
528    /// Input after SDK preparation — may differ from `requested_input`
529    /// for listen-tools that enrich input during the ready phase.
530    pub effective_input: serde_json::Value,
531    /// Optional listen-execution context, present when the tool uses
532    /// the listen/execute pattern.
533    pub listen_context: Option<ListenExecutionContext>,
534}
535
536/// Context captured for listen/execute tools while awaiting confirmation.
537#[derive(Clone, Debug, Serialize, Deserialize)]
538pub struct ListenExecutionContext {
539    /// Opaque operation identifier used to execute/cancel.
540    pub operation_id: String,
541    /// Revision used for optimistic concurrency checks.
542    pub revision: u64,
543    /// Snapshot shown to the user during confirmation.
544    pub snapshot: serde_json::Value,
545    /// Optional expiration timestamp (RFC3339).
546    #[serde(
547        default,
548        skip_serializing_if = "Option::is_none",
549        with = "time::serde::rfc3339::option"
550    )]
551    pub expires_at: Option<OffsetDateTime>,
552}
553
554/// Continuation state that allows resuming the agent loop.
555///
556/// This contains all the internal state needed to continue execution
557/// after receiving a confirmation decision. Pass this back when resuming.
558///
559/// # Turn-summary fields
560///
561/// `response_id` and `stop_reason` capture the **turn-closing** LLM call
562/// that produced [`AgentContinuation::pending_tool_calls`] before the
563/// pause. They are carried across the pause boundary so the
564/// [`TurnSummary`] emitted on the resume path can report the same LLM
565/// metadata as the pre-pause summary for the same turn.
566///
567/// Both are `Option` and default to `None` for forward compatibility
568/// with continuations persisted before these fields existed.
569#[derive(Clone, Debug, Serialize, Deserialize)]
570pub struct AgentContinuation {
571    /// Thread ID (used for validation on resume)
572    pub thread_id: ThreadId,
573    /// Current turn number
574    pub turn: usize,
575    /// Total token usage so far
576    pub total_usage: TokenUsage,
577    /// Token usage for this specific turn (from the LLM call that generated tool calls)
578    pub turn_usage: TokenUsage,
579    /// All pending tool calls from this turn
580    pub pending_tool_calls: Vec<PendingToolCallInfo>,
581    /// Index of the tool call awaiting confirmation
582    pub awaiting_index: usize,
583    /// Tool results already collected (for tools before the awaiting one)
584    pub completed_results: Vec<(String, ToolResult)>,
585    /// Agent state snapshot
586    pub state: AgentState,
587    /// Provider response ID from the LLM call that produced this turn's
588    /// pending tool calls.
589    ///
590    /// `None` for continuations persisted before this field was added,
591    /// or when the provider did not return an ID.
592    #[serde(default, skip_serializing_if = "Option::is_none")]
593    pub response_id: Option<String>,
594    /// Stop reason from the LLM call that produced this turn's pending
595    /// tool calls.
596    ///
597    /// `None` for continuations persisted before this field was added.
598    #[serde(default, skip_serializing_if = "Option::is_none")]
599    pub stop_reason: Option<crate::llm::StopReason>,
600    /// Full content blocks from the LLM response that produced this
601    /// turn's pending tool calls (text, thinking, and tool-use blocks).
602    ///
603    /// When the LLM emits text before tool calls (e.g. "I will run
604    /// that." followed by a `tool_use` block), those text blocks must be
605    /// preserved so Phase 5 can reconstruct the complete assistant
606    /// message in the conversation history.
607    ///
608    /// Empty for continuations persisted before this field was added.
609    #[serde(default, skip_serializing_if = "Vec::is_empty")]
610    pub response_content: Vec<crate::llm::ContentBlock>,
611}
612
613// ── Versioned continuation envelope ──────────────────────────────────
614
615/// Current envelope version.
616pub const CONTINUATION_VERSION: u32 = 1;
617
618/// Versioned wrapper around [`AgentContinuation`].
619///
620/// This is the **public durable boundary** for server persistence.
621/// Servers serialise this envelope (not the raw `AgentContinuation`)
622/// so future SDK versions can evolve the inner payload while keeping
623/// a stable wire format.
624///
625/// Unknown versions are rejected at resume time, giving servers a
626/// clear upgrade signal instead of silent data corruption.
627#[derive(Clone, Debug, Serialize, Deserialize)]
628pub struct ContinuationEnvelope {
629    /// Schema version — currently [`CONTINUATION_VERSION`].
630    pub version: u32,
631    /// The continuation payload.
632    pub payload: AgentContinuation,
633}
634
635impl ContinuationEnvelope {
636    /// Wrap a continuation in the current version envelope.
637    #[must_use]
638    pub const fn wrap(payload: AgentContinuation) -> Self {
639        Self {
640            version: CONTINUATION_VERSION,
641            payload,
642        }
643    }
644
645    /// Validate the envelope version, returning the inner continuation
646    /// or an error if the version is unknown.
647    ///
648    /// # Errors
649    ///
650    /// Returns an error string if `version` does not match
651    /// [`CONTINUATION_VERSION`].
652    pub fn unwrap_validated(self) -> Result<AgentContinuation, String> {
653        if self.version != CONTINUATION_VERSION {
654            return Err(format!(
655                "Unsupported continuation version {}: expected {}",
656                self.version, CONTINUATION_VERSION,
657            ));
658        }
659        Ok(self.payload)
660    }
661}
662
663/// A tool result provided by the external runtime for a specific tool call.
664///
665/// This is the durable handoff payload: a root worker serialises these
666/// alongside the [`AgentContinuation`] and provides them on resume via
667/// [`AgentInput::SubmitToolResults`].
668#[derive(Clone, Debug, Serialize, Deserialize)]
669pub struct ExternalToolResult {
670    /// The tool call ID this result corresponds to (must match a
671    /// [`PendingToolCallInfo::id`] from the original
672    /// [`TurnOutcome::PendingToolCalls`]).
673    pub tool_call_id: String,
674    /// The execution result.
675    pub result: ToolResult,
676}
677
678/// Input to start or resume an agent run.
679#[derive(Debug)]
680pub enum AgentInput {
681    /// Start a new conversation with user text.
682    Text(String),
683
684    /// Start a new conversation with rich content (text, images, documents).
685    Message(Vec<ContentBlock>),
686
687    /// Resume after a confirmation decision.
688    Resume {
689        /// The versioned continuation envelope from `AwaitingConfirmation`.
690        continuation: Box<ContinuationEnvelope>,
691        /// ID of the tool call being confirmed/rejected.
692        tool_call_id: String,
693        /// Whether the user confirmed the action.
694        confirmed: bool,
695        /// Optional reason if rejected.
696        rejection_reason: Option<String>,
697    },
698
699    /// Resume after external tool execution.
700    ///
701    /// Use this after [`TurnOutcome::PendingToolCalls`] when
702    /// [`ToolRuntime::External`] is set.  The caller must provide a result
703    /// for **every** pending tool call listed in the continuation.
704    ///
705    /// The SDK validates the continuation envelope version, appends the
706    /// tool results to the message store, and continues to the next LLM turn.
707    SubmitToolResults {
708        /// The versioned continuation from [`TurnOutcome::PendingToolCalls`].
709        continuation: Box<ContinuationEnvelope>,
710        /// One result per pending tool call.  The order does not matter,
711        /// but every `tool_call_id` from the continuation must be covered.
712        results: Vec<ExternalToolResult>,
713    },
714
715    /// Continue to the next turn (for single-turn mode).
716    ///
717    /// Use this after `TurnOutcome::NeedsMoreTurns` to execute the next turn.
718    /// The message history already contains tool results from the previous turn.
719    Continue,
720}
721
722/// Result of tool execution - may indicate async operation in progress.
723#[derive(Clone, Debug, Serialize, Deserialize)]
724pub enum ToolOutcome {
725    /// Tool completed synchronously with success
726    Success(ToolResult),
727
728    /// Tool completed synchronously with failure
729    Failed(ToolResult),
730
731    /// Tool started an async operation - must stream status to completion
732    InProgress {
733        /// Identifier for the operation (to query status)
734        operation_id: String,
735        /// Initial message for the user
736        message: String,
737    },
738}
739
740impl ToolOutcome {
741    #[must_use]
742    pub fn success(output: impl Into<String>) -> Self {
743        Self::Success(ToolResult::success(output))
744    }
745
746    #[must_use]
747    pub fn failed(message: impl Into<String>) -> Self {
748        Self::Failed(ToolResult::error(message))
749    }
750
751    #[must_use]
752    pub fn in_progress(operation_id: impl Into<String>, message: impl Into<String>) -> Self {
753        Self::InProgress {
754            operation_id: operation_id.into(),
755            message: message.into(),
756        }
757    }
758
759    /// Returns true if operation is still in progress
760    #[must_use]
761    pub const fn is_in_progress(&self) -> bool {
762        matches!(self, Self::InProgress { .. })
763    }
764}
765
766// ============================================================================
767// Tool Execution Idempotency Types
768// ============================================================================
769
770/// Status of a tool execution for idempotency tracking.
771#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
772pub enum ExecutionStatus {
773    /// Execution started but not yet completed
774    InFlight,
775    /// Execution completed (success or failure)
776    Completed,
777}
778
779/// Record of a tool execution for idempotency.
780///
781/// This struct tracks tool executions to prevent duplicate execution when
782/// the agent loop retries after a failure. The write-ahead pattern ensures
783/// that execution intent is recorded BEFORE calling the tool, and updated
784/// with results AFTER completion.
785#[derive(Clone, Debug, Serialize, Deserialize)]
786pub struct ToolExecution {
787    /// The tool call ID from the LLM (unique per invocation)
788    pub tool_call_id: String,
789    /// Thread this execution belongs to
790    pub thread_id: ThreadId,
791    /// Tool name
792    pub tool_name: String,
793    /// Display name
794    pub display_name: String,
795    /// Input parameters (for verification)
796    pub input: serde_json::Value,
797    /// Current status
798    pub status: ExecutionStatus,
799    /// Result if completed
800    pub result: Option<ToolResult>,
801    /// For async tools: the operation ID returned by `execute()`
802    pub operation_id: Option<String>,
803    /// Timestamp when execution started
804    #[serde(with = "time::serde::rfc3339")]
805    pub started_at: OffsetDateTime,
806    /// Timestamp when execution completed
807    #[serde(with = "time::serde::rfc3339::option")]
808    pub completed_at: Option<OffsetDateTime>,
809}
810
811impl ToolExecution {
812    /// Create a new in-flight execution record.
813    #[must_use]
814    pub fn new_in_flight(
815        tool_call_id: impl Into<String>,
816        thread_id: ThreadId,
817        tool_name: impl Into<String>,
818        display_name: impl Into<String>,
819        input: serde_json::Value,
820        started_at: OffsetDateTime,
821    ) -> Self {
822        Self {
823            tool_call_id: tool_call_id.into(),
824            thread_id,
825            tool_name: tool_name.into(),
826            display_name: display_name.into(),
827            input,
828            status: ExecutionStatus::InFlight,
829            result: None,
830            operation_id: None,
831            started_at,
832            completed_at: None,
833        }
834    }
835
836    /// Mark this execution as completed with a result.
837    pub fn complete(&mut self, result: ToolResult) {
838        self.status = ExecutionStatus::Completed;
839        self.result = Some(result);
840        self.completed_at = Some(OffsetDateTime::now_utc());
841    }
842
843    /// Set the operation ID for async tool tracking.
844    pub fn set_operation_id(&mut self, operation_id: impl Into<String>) {
845        self.operation_id = Some(operation_id.into());
846    }
847
848    /// Returns true if this execution is still in flight.
849    #[must_use]
850    pub fn is_in_flight(&self) -> bool {
851        self.status == ExecutionStatus::InFlight
852    }
853
854    /// Returns true if this execution has completed.
855    #[must_use]
856    pub fn is_completed(&self) -> bool {
857        self.status == ExecutionStatus::Completed
858    }
859}
860
861/// Outcome of running a single turn.
862///
863/// This is returned by `run_turn` to indicate what happened and what to do next.
864///
865/// # Server-facing contract
866///
867/// Every terminal variant (everything except [`TurnOutcome::Error`]) carries
868/// a [`TurnSummary`] with the provider/model/stop-reason/response-id/usage
869/// provenance that later server phases need to durably persist. Matching by
870/// field name continues to work because the legacy variant fields are
871/// preserved alongside the new `summary` field.
872#[derive(Debug)]
873pub enum TurnOutcome {
874    /// Turn completed successfully, but more turns are needed.
875    ///
876    /// Tools were executed and their results are stored in the message history.
877    /// Call `run_turn` again with `AgentInput::Continue` to proceed.
878    NeedsMoreTurns {
879        /// The turn number that just completed
880        turn: usize,
881        /// Token usage for this turn
882        turn_usage: TokenUsage,
883        /// Cumulative token usage so far
884        total_usage: TokenUsage,
885        /// Structured server-facing outcome metadata.
886        summary: TurnSummary,
887    },
888
889    /// Agent completed successfully (no more tool calls).
890    Done {
891        /// Total turns executed
892        total_turns: u32,
893        /// Cumulative token usage
894        total_usage: TokenUsage,
895        /// Structured server-facing outcome metadata.
896        summary: TurnSummary,
897    },
898
899    /// A run-level usage budget was exceeded; the turn stops instead of
900    /// continuing to the next LLM round-trip.
901    BudgetExceeded {
902        /// Total turns executed
903        total_turns: u32,
904        /// Cumulative token usage
905        total_usage: TokenUsage,
906        /// Estimated cost of the run in USD, when pricing is available.
907        estimated_cost_usd: Option<f64>,
908        /// Which budget limit was exceeded.
909        limit: BudgetLimitKind,
910        /// Structured server-facing outcome metadata.
911        summary: TurnSummary,
912    },
913
914    /// A tool requires user confirmation.
915    ///
916    /// Present this to the user and call `run_turn` with `AgentInput::Resume`
917    /// to continue.
918    AwaitingConfirmation {
919        /// ID of the pending tool call (from LLM)
920        tool_call_id: String,
921        /// Tool name string (for LLM protocol)
922        tool_name: String,
923        /// Human-readable display name
924        display_name: String,
925        /// Tool input parameters
926        input: serde_json::Value,
927        /// Description of what confirmation is needed
928        description: String,
929        /// Versioned continuation envelope for resuming.
930        continuation: Box<ContinuationEnvelope>,
931        /// Structured server-facing outcome metadata.
932        summary: TurnSummary,
933    },
934
935    /// Model refused the request (safety/policy).
936    Refusal {
937        /// Total turns executed
938        total_turns: u32,
939        /// Cumulative token usage
940        total_usage: TokenUsage,
941        /// Structured server-facing outcome metadata.
942        summary: TurnSummary,
943    },
944
945    /// The turn was cancelled via a cancellation token.
946    Cancelled {
947        /// Total turns executed before cancellation
948        total_turns: u32,
949        /// Cumulative token usage
950        total_usage: TokenUsage,
951        /// Structured server-facing outcome metadata.
952        summary: TurnSummary,
953    },
954
955    /// An error occurred.
956    ///
957    /// No [`TurnSummary`] is attached because the error may have occurred
958    /// before the turn produced any durable LLM provenance.
959    Error(AgentError),
960
961    /// Tool calls are ready for external execution.
962    ///
963    /// Only returned when [`ToolRuntime::External`] is set in [`TurnOptions`].
964    /// The caller is responsible for executing the tool calls and resuming
965    /// with [`AgentInput::SubmitToolResults`], providing one
966    /// [`ExternalToolResult`] for each pending tool call.
967    ///
968    /// The `continuation` must be passed back unmodified — it carries the
969    /// turn identity, token usage, and agent state needed to validate and
970    /// apply the results.
971    PendingToolCalls {
972        /// The turn number that produced these tool calls
973        turn: usize,
974        /// Token usage for this turn's LLM call
975        turn_usage: TokenUsage,
976        /// Cumulative token usage so far
977        total_usage: TokenUsage,
978        /// Tool calls to execute externally
979        tool_calls: Vec<PendingToolCallInfo>,
980        /// Versioned continuation envelope for resuming after external tool execution.
981        continuation: Box<ContinuationEnvelope>,
982        /// Structured server-facing outcome metadata.
983        summary: TurnSummary,
984    },
985}
986
987impl TurnOutcome {
988    /// Returns the attached [`TurnSummary`], if the variant carries one.
989    ///
990    /// Present on every variant except [`TurnOutcome::Error`].
991    #[must_use]
992    pub const fn summary(&self) -> Option<&TurnSummary> {
993        match self {
994            Self::NeedsMoreTurns { summary, .. }
995            | Self::Done { summary, .. }
996            | Self::BudgetExceeded { summary, .. }
997            | Self::AwaitingConfirmation { summary, .. }
998            | Self::Refusal { summary, .. }
999            | Self::Cancelled { summary, .. }
1000            | Self::PendingToolCalls { summary, .. } => Some(summary),
1001            Self::Error(_) => None,
1002        }
1003    }
1004}
1005
1006// ── Turn summary ─────────────────────────────────────────────────────
1007
1008/// Structured server-facing outcome metadata for a single turn.
1009///
1010/// Captures everything the server needs to durably persist about a
1011/// turn's LLM-level provenance: thread/turn identity, provider and model
1012/// identifiers, response ID and stop reason from the turn-closing LLM
1013/// call, token usage, tool-call count, wall-clock duration, and the
1014/// [`TurnOptions`] the caller requested.
1015///
1016/// # Why this exists
1017///
1018/// The original [`TurnOutcome`] only exposed token counts and turn
1019/// numbers. Later server phases need:
1020///
1021/// - **Provider / model** — to correlate rows across provider rotations
1022///   and to route audit streams by provider.
1023/// - **Response ID** — to join durable turn rows against the raw
1024///   provider response stored externally (observability pipelines,
1025///   replay, support escalations).
1026/// - **Stop reason** — to branch on `end_turn` vs `tool_use` vs
1027///   `refusal` without re-parsing message history.
1028/// - **Tool-call count** — to bill tool execution and detect runaway
1029///   turns without walking the tool registry.
1030/// - **Duration** — to feed SLO dashboards and auto-tune retry budgets.
1031/// - **Tool runtime / strict durability flags** — to record which
1032///   execution profile was in effect, so later replay can reconstruct
1033///   the same decisions.
1034///
1035/// # Serialization
1036///
1037/// `TurnSummary` is fully serializable. Servers are expected to persist
1038/// it alongside (or inside) their turn rows. Duration is exposed as
1039/// `duration_ms` (milliseconds) to avoid a serde dance around
1040/// [`std::time::Duration`].
1041///
1042/// # Authoritative vs convenience
1043///
1044/// Fields in `TurnSummary` are **authoritative** for server execution:
1045/// they are produced by the same code path that writes the durable
1046/// event store and are guaranteed to be consistent with the events the
1047/// server observed on the wire. Convenience accessors on [`TurnOutcome`]
1048/// (e.g. the legacy `input_tokens` / `output_tokens` fields on `Done`)
1049/// are kept only so local callers do not have to break; new code should
1050/// read from `summary` instead.
1051#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1052pub struct TurnSummary {
1053    /// Thread this turn belongs to.
1054    ///
1055    /// Duplicated from the call site so the summary is self-describing
1056    /// when persisted alone (for durable audit rows).
1057    pub thread_id: ThreadId,
1058    /// Turn number that produced this outcome (1-indexed).
1059    pub turn: usize,
1060    /// Total number of turns executed in this run so far.
1061    ///
1062    /// For mid-run outcomes like `NeedsMoreTurns` / `PendingToolCalls`
1063    /// this equals `turn`. For terminal outcomes (`Done`, `Refusal`,
1064    /// `Cancelled`) it reflects the final total.
1065    pub total_turns: u32,
1066    /// Token usage for the LLM call(s) that produced this turn.
1067    pub turn_usage: TokenUsage,
1068    /// Cumulative token usage across every turn in this run so far.
1069    pub total_usage: TokenUsage,
1070    /// Provider / model provenance captured from the turn-closing
1071    /// LLM call — identical shape to [`AuditProvenance`] so durable
1072    /// audit rows stay consistent with turn rows.
1073    pub provenance: AuditProvenance,
1074    /// Provider response ID from the turn-closing LLM call.
1075    ///
1076    /// `None` when the provider did not return an ID or the turn
1077    /// terminated before the LLM responded (e.g. cancelled before the
1078    /// first call).
1079    pub response_id: Option<String>,
1080    /// Stop reason reported by the turn-closing LLM call.
1081    ///
1082    /// `None` when no response was produced for this turn (e.g. the
1083    /// turn was cancelled before the LLM replied, or the turn was
1084    /// resumed purely from external tool results without calling the
1085    /// LLM again).
1086    pub stop_reason: Option<crate::llm::StopReason>,
1087    /// Number of tool calls the LLM requested in this turn.
1088    ///
1089    /// Zero for pure text turns.
1090    pub tool_call_count: usize,
1091    /// Wall-clock duration of this turn, in milliseconds.
1092    ///
1093    /// Measured from the start of `run_turn` to the moment the outcome
1094    /// is returned. Clamped to `u64::MAX` on the unlikely overflow.
1095    pub duration_ms: u64,
1096    /// The [`ToolRuntime`] selected for this turn.
1097    pub tool_runtime: ToolRuntime,
1098    /// Whether strict durability was requested for this turn.
1099    pub strict_durability: bool,
1100}
1101
1102impl TurnSummary {
1103    /// Construct an empty summary for a thread / provider / model.
1104    ///
1105    /// Used by the runtime as a starting point; it then updates
1106    /// specific fields as the turn progresses. Tests and downstream
1107    /// consumers should generally pattern-match on the outcome and
1108    /// read fields from the populated summary rather than construct
1109    /// one from scratch.
1110    #[must_use]
1111    pub fn new(
1112        thread_id: ThreadId,
1113        turn: usize,
1114        provenance: AuditProvenance,
1115        options: &TurnOptions,
1116    ) -> Self {
1117        Self {
1118            thread_id,
1119            turn,
1120            total_turns: 0,
1121            turn_usage: TokenUsage::default(),
1122            total_usage: TokenUsage::default(),
1123            provenance,
1124            response_id: None,
1125            stop_reason: None,
1126            tool_call_count: 0,
1127            duration_ms: 0,
1128            tool_runtime: options.tool_runtime.clone(),
1129            strict_durability: options.strict_durability,
1130        }
1131    }
1132}
1133
1134// ── Execution options ────────────────────────────────────────────────
1135
1136/// How tool calls should be handled during a turn.
1137#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1138#[serde(rename_all = "snake_case")]
1139pub enum ToolRuntime {
1140    /// Tools are executed inline by the SDK (the default local-agent behavior).
1141    #[default]
1142    Inline,
1143    /// Tool calls are returned to the caller for external execution.
1144    ///
1145    /// When set, `run_turn` yields [`TurnOutcome::PendingToolCalls`] instead
1146    /// of executing tools itself. The server is responsible for running
1147    /// tools and calling `run_turn` again.
1148    External,
1149}
1150
1151/// Options that control how a single `run_turn` invocation behaves.
1152///
1153/// The default is suitable for local/CLI usage (inline tools, no extra
1154/// durability). Server mode should set `tool_runtime: External` and
1155/// `strict_durability: true`.
1156#[derive(Debug, Clone, Default)]
1157pub struct TurnOptions {
1158    /// How tool calls should be handled.
1159    pub tool_runtime: ToolRuntime,
1160    /// When true, state is checkpointed at every critical boundary
1161    /// (before LLM call, after LLM response, after tool execution).
1162    /// Provides crash-safe server semantics at the cost of extra writes.
1163    pub strict_durability: bool,
1164}
1165
1166// ── RunOptions ───────────────────────────────────────────────────────
1167
1168/// Per-run trace metadata applied to every span emitted by the agent
1169/// loop.
1170///
1171/// Passed to [`run_with_options`](#method.run_with_options) /
1172/// [`run_turn_with_options`](#method.run_turn_with_options) /
1173/// [`run_persistent_with_options`](#method.run_persistent_with_options)
1174/// so a consumer can configure session / user / Langfuse trace
1175/// metadata once and have it land on every emitted span — without
1176/// writing manual span code or pre-installing baggage on the `OTel`
1177/// context.
1178///
1179/// The SDK applies the contents of `RunOptions` at the root
1180/// `invoke_agent` span:
1181///
1182/// * `session_id` / `user_id` — copied to W3C baggage so Langfuse
1183///   `session.id` / `user.id` filters fire on every child span (the
1184///   baggage propagation path lives in `agent_sdk::observability::baggage`).
1185/// * `trace_name` — set as `langfuse.trace.name`.
1186/// * `trace_tags` — set as `langfuse.trace.tags`.
1187/// * `trace_metadata` — each entry stamped under `langfuse.trace.metadata.<key>`.
1188/// * `release` — set as `langfuse.release`.
1189/// * `environment` — set as `langfuse.environment`.
1190/// * `trace_text_max_chars` — overrides the default ceiling
1191///   (`agent_sdk::observability::langfuse::DEFAULT_TRACE_TEXT_MAX_CHARS`)
1192///   for `langfuse.trace.input` / `langfuse.trace.output`.
1193///
1194/// The SDK also computes `langfuse.trace.input` from the supplied
1195/// [`AgentInput`] (after PII redaction) and
1196/// streams `langfuse.trace.output` as the agent emits text, tool, and
1197/// error events.
1198///
1199/// `RunOptions` is `Clone + Debug + Default`; it carries only display
1200/// strings and opaque metadata values (no secrets) so the standard
1201/// `Debug` derive is safe to expose in error contexts.
1202///
1203/// # Example
1204///
1205/// ```no_run
1206/// use agent_sdk_foundation::types::RunOptions;
1207/// use serde_json::json;
1208///
1209/// let opts = RunOptions {
1210///     session_id: Some("thread-42".to_string()),
1211///     user_id: Some("user-7".to_string()),
1212///     trace_name: Some("myapp.assistant.mobile".to_string()),
1213///     trace_tags: vec!["mobile.android".to_string()],
1214///     trace_metadata: json!({"version": "1.2.3"})
1215///         .as_object()
1216///         .cloned()
1217///         .unwrap_or_default(),
1218///     ..Default::default()
1219/// };
1220/// # let _ = opts;
1221/// ```
1222#[derive(Clone, Debug, Default)]
1223pub struct RunOptions {
1224    /// Langfuse `session.id` / W3C `session.id` baggage entry.
1225    pub session_id: Option<String>,
1226    /// Langfuse `user.id` / W3C `user.id` baggage entry.
1227    pub user_id: Option<String>,
1228    /// Display name of the trace in the Langfuse UI.
1229    pub trace_name: Option<String>,
1230    /// Free-form labels attached to the trace.
1231    pub trace_tags: Vec<String>,
1232    /// Trace-level metadata stamped as `langfuse.trace.metadata.<key>`.
1233    pub trace_metadata: serde_json::Map<String, serde_json::Value>,
1234    /// Release identifier for the trace's build.
1235    pub release: Option<String>,
1236    /// Langfuse environment slug (`prod`, `staging`, …).
1237    pub environment: Option<String>,
1238    /// Override the default character ceiling for trace-level free-text
1239    /// attributes. `None` falls back to
1240    /// `agent_sdk::observability::langfuse::DEFAULT_TRACE_TEXT_MAX_CHARS`.
1241    pub trace_text_max_chars: Option<usize>,
1242}
1243
1244#[cfg(test)]
1245mod tests {
1246    use super::*;
1247    use crate::llm::StopReason;
1248
1249    fn sample_summary() -> TurnSummary {
1250        TurnSummary {
1251            thread_id: ThreadId::from_string("t-summary"),
1252            turn: 2,
1253            total_turns: 2,
1254            turn_usage: TokenUsage {
1255                input_tokens: 100,
1256                output_tokens: 50,
1257                ..Default::default()
1258            },
1259            total_usage: TokenUsage {
1260                input_tokens: 200,
1261                output_tokens: 75,
1262                ..Default::default()
1263            },
1264            provenance: AuditProvenance::new("anthropic", "claude-sonnet-4-5-20250929"),
1265            response_id: Some("resp_123".into()),
1266            stop_reason: Some(StopReason::ToolUse),
1267            tool_call_count: 3,
1268            duration_ms: 1_234,
1269            tool_runtime: ToolRuntime::External,
1270            strict_durability: true,
1271        }
1272    }
1273
1274    #[test]
1275    fn turn_summary_round_trips_through_json() {
1276        let original = sample_summary();
1277        let json = serde_json::to_string(&original).expect("serialize");
1278        let recovered: TurnSummary = serde_json::from_str(&json).expect("deserialize");
1279        assert_eq!(recovered, original);
1280    }
1281
1282    #[test]
1283    fn turn_summary_json_has_expected_keys() {
1284        let summary = sample_summary();
1285        let value = serde_json::to_value(&summary).unwrap();
1286
1287        // The wire format is the durable server contract — assert
1288        // every field is present so accidental renames break this
1289        // test rather than silently corrupting persisted rows.
1290        for key in [
1291            "thread_id",
1292            "turn",
1293            "total_turns",
1294            "turn_usage",
1295            "total_usage",
1296            "provenance",
1297            "response_id",
1298            "stop_reason",
1299            "tool_call_count",
1300            "duration_ms",
1301            "tool_runtime",
1302            "strict_durability",
1303        ] {
1304            assert!(value.get(key).is_some(), "missing key {key}");
1305        }
1306
1307        // Snake-case tool-runtime variant is stable for server rows.
1308        assert_eq!(value["tool_runtime"], serde_json::json!("external"));
1309        // Snake-case stop-reason variant matches the provider wire format.
1310        assert_eq!(value["stop_reason"], serde_json::json!("tool_use"));
1311    }
1312
1313    #[test]
1314    fn turn_outcome_summary_accessor_works_for_every_variant() {
1315        let summary = sample_summary();
1316
1317        let outcomes = vec![
1318            TurnOutcome::NeedsMoreTurns {
1319                turn: 1,
1320                turn_usage: TokenUsage::default(),
1321                total_usage: TokenUsage::default(),
1322                summary: summary.clone(),
1323            },
1324            TurnOutcome::Done {
1325                total_turns: 1,
1326                total_usage: TokenUsage::default(),
1327                summary: summary.clone(),
1328            },
1329            TurnOutcome::Refusal {
1330                total_turns: 1,
1331                total_usage: TokenUsage::default(),
1332                summary: summary.clone(),
1333            },
1334            TurnOutcome::Cancelled {
1335                total_turns: 1,
1336                total_usage: TokenUsage::default(),
1337                summary: summary.clone(),
1338            },
1339        ];
1340
1341        for outcome in &outcomes {
1342            let got = outcome.summary().expect("summary must be present");
1343            assert_eq!(got, &summary);
1344        }
1345
1346        // Error variant has no summary.
1347        let error_outcome =
1348            TurnOutcome::Error(AgentError::new("boom", /* recoverable */ false));
1349        assert!(error_outcome.summary().is_none());
1350    }
1351
1352    #[test]
1353    fn empty_turn_summary_new_captures_options_and_provenance() {
1354        let opts = TurnOptions {
1355            tool_runtime: ToolRuntime::External,
1356            strict_durability: true,
1357        };
1358        let provenance = AuditProvenance::new("openai", "gpt-5");
1359        let summary =
1360            TurnSummary::new(ThreadId::from_string("t-new"), 7, provenance.clone(), &opts);
1361
1362        assert_eq!(summary.thread_id, ThreadId::from_string("t-new"));
1363        assert_eq!(summary.turn, 7);
1364        assert_eq!(summary.total_turns, 0);
1365        assert_eq!(summary.provenance, provenance);
1366        assert_eq!(summary.tool_runtime, ToolRuntime::External);
1367        assert!(summary.strict_durability);
1368        assert!(summary.response_id.is_none());
1369        assert!(summary.stop_reason.is_none());
1370        assert_eq!(summary.tool_call_count, 0);
1371        assert_eq!(summary.duration_ms, 0);
1372    }
1373
1374    #[test]
1375    fn stop_reason_as_str_matches_serde_representation() {
1376        // The durable stop_reason discriminant used in TurnSummary and
1377        // audit rows must match the serde wire format exactly.
1378        let cases = [
1379            (StopReason::EndTurn, "end_turn"),
1380            (StopReason::ToolUse, "tool_use"),
1381            (StopReason::MaxTokens, "max_tokens"),
1382            (StopReason::StopSequence, "stop_sequence"),
1383            (StopReason::Refusal, "refusal"),
1384            (
1385                StopReason::ModelContextWindowExceeded,
1386                "model_context_window_exceeded",
1387            ),
1388        ];
1389        for (variant, expected) in cases {
1390            assert_eq!(variant.as_str(), expected);
1391            let json = serde_json::to_value(variant).unwrap();
1392            assert_eq!(json, serde_json::json!(expected));
1393        }
1394    }
1395
1396    fn sample_continuation() -> AgentContinuation {
1397        let thread = ThreadId::from_string("t-continuation");
1398        AgentContinuation {
1399            thread_id: thread.clone(),
1400            turn: 4,
1401            total_usage: TokenUsage {
1402                input_tokens: 200,
1403                output_tokens: 80,
1404                ..Default::default()
1405            },
1406            turn_usage: TokenUsage {
1407                input_tokens: 50,
1408                output_tokens: 40,
1409                ..Default::default()
1410            },
1411            pending_tool_calls: vec![PendingToolCallInfo {
1412                id: "call_1".into(),
1413                name: "echo".into(),
1414                display_name: "Echo".into(),
1415                tier: ToolTier::Confirm,
1416                input: serde_json::json!({"message": "hi"}),
1417                effective_input: serde_json::json!({"message": "hi"}),
1418                listen_context: None,
1419            }],
1420            awaiting_index: 0,
1421            completed_results: Vec::new(),
1422            state: AgentState::new(thread),
1423            response_id: Some("resp_7914".into()),
1424            stop_reason: Some(StopReason::ToolUse),
1425            response_content: Vec::new(),
1426        }
1427    }
1428
1429    #[test]
1430    fn agent_continuation_round_trips_llm_metadata() {
1431        // `response_id` and `stop_reason` travel through
1432        // durable persistence so the resume-side `TurnSummary` reports
1433        // the same LLM metadata as the pre-pause summary for the same
1434        // turn. Guard the wire format so future renames break here
1435        // rather than silently dropping the fields.
1436        let original = sample_continuation();
1437        let json = serde_json::to_string(&original).expect("serialize");
1438
1439        let value: serde_json::Value = serde_json::from_str(&json).expect("to value");
1440        assert_eq!(value["response_id"], serde_json::json!("resp_7914"));
1441        assert_eq!(value["stop_reason"], serde_json::json!("tool_use"));
1442
1443        let recovered: AgentContinuation = serde_json::from_str(&json).expect("deserialize");
1444        assert_eq!(recovered.response_id.as_deref(), Some("resp_7914"));
1445        assert_eq!(recovered.stop_reason, Some(StopReason::ToolUse));
1446    }
1447
1448    #[test]
1449    fn agent_continuation_deserializes_legacy_payload_without_llm_metadata() {
1450        // Servers that persisted continuations before this contract
1451        // landed don't have `response_id` / `stop_reason` fields on
1452        // disk. Those
1453        // payloads must still deserialise so running servers do not
1454        // break on SDK upgrade — the fields default to `None`.
1455        let thread = ThreadId::from_string("t-legacy");
1456        let legacy_json = serde_json::json!({
1457            "thread_id": thread,
1458            "turn": 1,
1459            "total_usage": { "input_tokens": 10, "output_tokens": 5 },
1460            "turn_usage": { "input_tokens": 10, "output_tokens": 5 },
1461            "pending_tool_calls": [],
1462            "awaiting_index": 0,
1463            "completed_results": [],
1464            "state": AgentState::new(thread.clone()),
1465        });
1466
1467        let recovered: AgentContinuation =
1468            serde_json::from_value(legacy_json).expect("legacy payload deserialises");
1469        assert_eq!(recovered.thread_id, thread);
1470        assert_eq!(recovered.turn, 1);
1471        assert!(
1472            recovered.response_id.is_none(),
1473            "legacy payloads default to None",
1474        );
1475        assert!(
1476            recovered.stop_reason.is_none(),
1477            "legacy payloads default to None",
1478        );
1479    }
1480
1481    #[test]
1482    fn agent_continuation_omits_llm_metadata_when_none() {
1483        // `response_id` / `stop_reason` are `skip_serializing_if = None`
1484        // so that payloads where the provider did not return IDs stay
1485        // compact and look identical to the legacy wire format. This
1486        // protects any downstream consumer that matches exact keys.
1487        let thread = ThreadId::from_string("t-omit");
1488        let cont = AgentContinuation {
1489            thread_id: thread.clone(),
1490            turn: 1,
1491            total_usage: TokenUsage::default(),
1492            turn_usage: TokenUsage::default(),
1493            pending_tool_calls: Vec::new(),
1494            awaiting_index: 0,
1495            completed_results: Vec::new(),
1496            state: AgentState::new(thread),
1497            response_id: None,
1498            stop_reason: None,
1499            response_content: Vec::new(),
1500        };
1501        let value = serde_json::to_value(&cont).unwrap();
1502        assert!(value.get("response_id").is_none());
1503        assert!(value.get("stop_reason").is_none());
1504        assert!(value.get("response_content").is_none());
1505    }
1506}