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