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