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