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