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