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