Skip to main content

ferrin_core/generate_text/
step.rs

1//! Step results and the core content enum.
2
3use std::time::Duration;
4
5use chrono::DateTime;
6use chrono::Utc;
7use ferrin_message::Message;
8use ferrin_spec::ApprovalId;
9use ferrin_spec::CustomKind;
10use ferrin_spec::FileData;
11use ferrin_spec::FinishReason;
12use ferrin_spec::Headers;
13use ferrin_spec::JsonObject;
14use ferrin_spec::JsonValue;
15use ferrin_spec::MediaType;
16use ferrin_spec::ModelId;
17use ferrin_spec::ProviderMetadata;
18use ferrin_spec::ToolCallId;
19use ferrin_spec::ToolName;
20use ferrin_spec::Usage;
21use ferrin_spec::Warning;
22use ferrin_spec::language_model::Source;
23use ferrin_tool::ToolError;
24use serde::Deserialize;
25use serde::Serialize;
26use serde::de::DeserializeOwned;
27
28use crate::telemetry::ModelIdentity;
29
30/// The result of one step: a model call plus the tool executions it caused.
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32pub struct StepResult {
33    /// Zero-based step index.
34    pub step_number: u32,
35    /// Application state used for this step (never sent to the provider).
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub runtime_context: Option<JsonValue>,
38    /// Shared tool context used for this step, before per-tool validation.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub tools_context: Option<JsonValue>,
41    /// Model that produced the step.
42    pub model: ModelIdentity,
43    /// Content parts in order.
44    pub content: Vec<StepContent>,
45    /// Why the model stopped.
46    pub finish_reason: FinishReason,
47    /// Token usage of the model call.
48    pub usage: Usage,
49    /// Warnings produced by the adapter.
50    pub warnings: Vec<Warning>,
51    /// Request metadata (body and messages when included).
52    pub request: StepRequest,
53    /// Response metadata and the messages to append to the history.
54    pub response: StepResponse,
55    /// Provider-specific metadata.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub provider_metadata: Option<ProviderMetadata>,
58    /// Timing and throughput.
59    pub performance: StepPerformance,
60}
61
62impl StepResult {
63    /// Concatenates all text parts.
64    #[must_use]
65    pub fn text(&self) -> String {
66        self.content
67            .iter()
68            .filter_map(|part| match part {
69                StepContent::Text { text, .. } => Some(text.as_str()),
70                _ => None,
71            })
72            .collect()
73    }
74
75    /// Concatenates all reasoning parts, or `None` when there is none.
76    #[must_use]
77    pub fn reasoning_text(&self) -> Option<String> {
78        let mut found = false;
79        let text: String = self
80            .content
81            .iter()
82            .filter_map(|part| match part {
83                StepContent::Reasoning { text, .. } => {
84                    found = true;
85                    Some(text.as_str())
86                }
87                _ => None,
88            })
89            .collect();
90        found.then_some(text)
91    }
92
93    /// Iterates over the tool calls of the step.
94    pub fn tool_calls(&self) -> impl Iterator<Item = &ParsedToolCall> + '_ {
95        self.content.iter().filter_map(|part| match part {
96            StepContent::ToolCall(call) => Some(call),
97            _ => None,
98        })
99    }
100
101    /// Tool calls to tools of the static tool set.
102    pub fn static_tool_calls(&self) -> impl Iterator<Item = &ParsedToolCall> + '_ {
103        self.tool_calls().filter(|call| !call.dynamic)
104    }
105
106    /// Tool calls to dynamic tools (including invalid calls).
107    pub fn dynamic_tool_calls(&self) -> impl Iterator<Item = &ParsedToolCall> + '_ {
108        self.tool_calls().filter(|call| call.dynamic)
109    }
110
111    /// Iterates over the (final) tool results of the step.
112    pub fn tool_results(&self) -> impl Iterator<Item = &ToolResult> + '_ {
113        self.content.iter().filter_map(|part| match part {
114            StepContent::ToolResult(result) => Some(result),
115            _ => None,
116        })
117    }
118
119    /// Iterates over the tool errors of the step.
120    pub fn tool_errors(&self) -> impl Iterator<Item = &ToolExecutionError> + '_ {
121        self.content.iter().filter_map(|part| match part {
122            StepContent::ToolError(error) => Some(error),
123            _ => None,
124        })
125    }
126
127    /// Iterates over the approval requests of the step.
128    pub fn tool_approval_requests(&self) -> impl Iterator<Item = &ToolApprovalRequestContent> + '_ {
129        self.content.iter().filter_map(|part| match part {
130            StepContent::ToolApprovalRequest(request) => Some(request),
131            _ => None,
132        })
133    }
134
135    /// Iterates over generated files.
136    pub fn files(&self) -> impl Iterator<Item = &GeneratedFile> + '_ {
137        self.content.iter().filter_map(|part| match part {
138            StepContent::File(file) => Some(file),
139            _ => None,
140        })
141    }
142
143    /// Iterates over citation sources.
144    pub fn sources(&self) -> impl Iterator<Item = &Source> + '_ {
145        self.content.iter().filter_map(|part| match part {
146            StepContent::Source(source) => Some(source),
147            _ => None,
148        })
149    }
150
151    /// The messages to append to the conversation history.
152    #[must_use]
153    pub fn response_messages(&self) -> Vec<Message> {
154        self.response.messages.clone()
155    }
156
157    /// Deserializes the output of the first final result of `tool_name`.
158    ///
159    /// # Errors
160    ///
161    /// Returns the deserialization error; `Ok(None)` when no such result
162    /// exists.
163    pub fn tool_result_as<T: DeserializeOwned>(
164        &self,
165        tool_name: &str,
166    ) -> Result<Option<T>, serde_json::Error> {
167        self.tool_results()
168            .find(|result| result.tool_name == tool_name)
169            .map(|result| serde_json::from_value(result.output.clone()))
170            .transpose()
171    }
172}
173
174/// Request metadata of a step.
175#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
176pub struct StepRequest {
177    /// The JSON request body (only when `Include::request_body`).
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub body: Option<JsonValue>,
180    /// The messages sent to the model (only when `Include::request_messages`).
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub messages: Option<Vec<Message>>,
183}
184
185/// Response metadata of a step.
186#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
187pub struct StepResponse {
188    /// Provider response id.
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub id: Option<String>,
191    /// Response timestamp.
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub timestamp: Option<DateTime<Utc>>,
194    /// Model that produced the response.
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub model_id: Option<ModelId>,
197    /// Response headers.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub headers: Option<Headers>,
200    /// Raw response body (only when `Include::response_body`).
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub body: Option<JsonValue>,
203    /// Messages produced by the step (assistant and tool messages).
204    #[serde(default)]
205    pub messages: Vec<Message>,
206}
207
208/// Timing and throughput of a step.
209#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
210pub struct StepPerformance {
211    /// Wall time of the whole step (model call and tool executions).
212    #[serde(default)]
213    pub step_time: Duration,
214    /// Time from the request until the response (or stream end).
215    #[serde(default)]
216    pub response_time: Duration,
217    /// Streaming: time until the first content part.
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub time_to_first_output: Option<Duration>,
220    /// Streaming: output tokens per second after the first output.
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub output_tokens_per_second: Option<f64>,
223    /// Output tokens divided by the response time.
224    #[serde(default)]
225    pub effective_output_tokens_per_second: f64,
226    /// Streaming: input tokens divided by the time to first output.
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub input_tokens_per_second: Option<f64>,
229    /// Input plus output tokens divided by the response time.
230    #[serde(default)]
231    pub effective_total_tokens_per_second: f64,
232    /// Streaming: statistics of the gaps between content parts.
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub time_between_output_chunks: Option<ChunkTimingStats>,
235}
236
237impl StepPerformance {
238    /// Tokens per second, `0.0` when the duration is zero.
239    #[must_use]
240    pub fn tokens_per_second(tokens: Option<u64>, duration: Duration) -> f64 {
241        let seconds = duration.as_secs_f64();
242        if seconds <= 0.0 {
243            return 0.0;
244        }
245        #[allow(
246            clippy::cast_precision_loss,
247            reason = "token counts fit in f64 for rates"
248        )]
249        let rate = tokens.unwrap_or(0) as f64 / seconds;
250        if rate.is_finite() { rate } else { 0.0 }
251    }
252}
253
254/// Statistics over the gaps between consecutive content parts.
255#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
256pub struct ChunkTimingStats {
257    /// Smallest gap.
258    pub min: Duration,
259    /// Largest gap.
260    pub max: Duration,
261    /// Mean gap.
262    pub mean: Duration,
263    /// Median gap.
264    pub p50: Duration,
265    /// 90th percentile.
266    pub p90: Duration,
267    /// 99th percentile.
268    pub p99: Duration,
269    /// Number of gaps.
270    pub count: u64,
271}
272
273impl ChunkTimingStats {
274    /// Computes the statistics of `gaps`; `None` when empty.
275    #[must_use]
276    pub fn from_gaps(gaps: &[Duration]) -> Option<Self> {
277        if gaps.is_empty() {
278            return None;
279        }
280        let mut sorted = gaps.to_vec();
281        sorted.sort_unstable();
282        let total: Duration = sorted.iter().sum();
283        let count = sorted.len();
284        let percentile = |p: f64| {
285            #[allow(
286                clippy::cast_possible_truncation,
287                clippy::cast_sign_loss,
288                clippy::cast_precision_loss,
289                reason = "index arithmetic on a small vector"
290            )]
291            let index = ((p / 100.0) * (count as f64 - 1.0)).round() as usize;
292            sorted[index.min(count - 1)]
293        };
294        Some(Self {
295            min: sorted[0],
296            max: sorted[count - 1],
297            mean: total / u32::try_from(count).unwrap_or(u32::MAX),
298            p50: percentile(50.0),
299            p90: percentile(90.0),
300            p99: percentile(99.0),
301            count: count as u64,
302        })
303    }
304}
305
306/// A content part of a step.
307#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
308#[serde(tag = "type", rename_all = "kebab-case")]
309#[non_exhaustive]
310pub enum StepContent {
311    /// Generated text.
312    Text {
313        /// The text.
314        text: String,
315        /// Provider-specific metadata.
316        #[serde(default, skip_serializing_if = "Option::is_none")]
317        provider_metadata: Option<ProviderMetadata>,
318    },
319    /// Reasoning text.
320    Reasoning {
321        /// The text.
322        text: String,
323        /// Provider-specific metadata.
324        #[serde(default, skip_serializing_if = "Option::is_none")]
325        provider_metadata: Option<ProviderMetadata>,
326    },
327    /// A reasoning artifact stored as a file.
328    ReasoningFile(GeneratedFile),
329    /// A generated file.
330    File(GeneratedFile),
331    /// Provider-specific content.
332    Custom {
333        /// Kind of the content.
334        kind: CustomKind,
335        /// Provider-specific metadata.
336        #[serde(default, skip_serializing_if = "Option::is_none")]
337        provider_metadata: Option<ProviderMetadata>,
338    },
339    /// A citation source.
340    Source(Source),
341    /// A parsed tool call.
342    ToolCall(ParsedToolCall),
343    /// A tool result (client- or provider-executed).
344    ToolResult(ToolResult),
345    /// A tool error (client- or provider-executed).
346    ToolError(ToolExecutionError),
347    /// A request to approve a tool call.
348    ToolApprovalRequest(ToolApprovalRequestContent),
349    /// An automatic approval decision made by the approval policy.
350    ToolApprovalResponse(ToolApprovalResponseContent),
351    /// A tool call whose execution was denied.
352    ToolOutputDenied(ToolOutputDenied),
353}
354
355impl StepContent {
356    /// Creates a text part.
357    #[must_use]
358    pub fn text(text: impl Into<String>) -> Self {
359        Self::Text {
360            text: text.into(),
361            provider_metadata: None,
362        }
363    }
364
365    /// Returns the wire name of the variant.
366    #[must_use]
367    pub fn kind_name(&self) -> &'static str {
368        match self {
369            Self::Text { .. } => "text",
370            Self::Reasoning { .. } => "reasoning",
371            Self::ReasoningFile(_) => "reasoning-file",
372            Self::File(_) => "file",
373            Self::Custom { .. } => "custom",
374            Self::Source(_) => "source",
375            Self::ToolCall(_) => "tool-call",
376            Self::ToolResult(_) => "tool-result",
377            Self::ToolError(_) => "tool-error",
378            Self::ToolApprovalRequest(_) => "tool-approval-request",
379            Self::ToolApprovalResponse(_) => "tool-approval-response",
380            Self::ToolOutputDenied(_) => "tool-output-denied",
381        }
382    }
383}
384
385/// A generated file.
386#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
387pub struct GeneratedFile {
388    /// File payload (inline bytes or URL).
389    pub data: FileData,
390    /// Media type.
391    pub media_type: MediaType,
392    /// Optional file name.
393    #[serde(default, skip_serializing_if = "Option::is_none")]
394    pub filename: Option<String>,
395    /// Provider-specific metadata.
396    #[serde(default, skip_serializing_if = "Option::is_none")]
397    pub provider_metadata: Option<ProviderMetadata>,
398}
399
400impl GeneratedFile {
401    /// Returns the inline bytes when the payload is inline.
402    #[must_use]
403    pub fn bytes(&self) -> Option<&bytes::Bytes> {
404        self.data.as_bytes()
405    }
406
407    /// Returns the payload as base64 when it is inline.
408    #[must_use]
409    pub fn base64(&self) -> Option<String> {
410        self.data.to_base64()
411    }
412}
413
414/// A tool call after parsing and validation.
415#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
416pub struct ParsedToolCall {
417    /// Identifier of the tool call.
418    pub tool_call_id: ToolCallId,
419    /// Name of the tool.
420    pub tool_name: ToolName,
421    /// Parsed input (the raw text when it was not valid JSON).
422    pub input: JsonValue,
423    /// Whether the provider executes the tool itself.
424    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
425    pub provider_executed: bool,
426    /// Whether the call targets a dynamic tool (or could not be validated).
427    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
428    pub dynamic: bool,
429    /// Whether parsing or validation failed.
430    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
431    pub invalid: bool,
432    /// The parse or validation error message for invalid calls.
433    #[serde(default, skip_serializing_if = "Option::is_none")]
434    pub error: Option<String>,
435    /// Display title of the tool, if defined.
436    #[serde(default, skip_serializing_if = "Option::is_none")]
437    pub title: Option<String>,
438    /// Metadata from the tool definition, such as its MCP origin.
439    #[serde(default, skip_serializing_if = "Option::is_none")]
440    pub tool_metadata: Option<JsonObject>,
441    /// Provider-specific metadata.
442    #[serde(default, skip_serializing_if = "Option::is_none")]
443    pub provider_metadata: Option<ProviderMetadata>,
444}
445
446impl ParsedToolCall {
447    /// Creates a valid, client-executed, static call.
448    #[must_use]
449    pub fn new(
450        tool_call_id: impl Into<ToolCallId>,
451        tool_name: impl Into<ToolName>,
452        input: JsonValue,
453    ) -> Self {
454        Self {
455            tool_call_id: tool_call_id.into(),
456            tool_name: tool_name.into(),
457            input,
458            provider_executed: false,
459            dynamic: false,
460            invalid: false,
461            error: None,
462            title: None,
463            tool_metadata: None,
464            provider_metadata: None,
465        }
466    }
467}
468
469/// A tool result.
470#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
471pub struct ToolResult {
472    /// Identifier of the tool call.
473    pub tool_call_id: ToolCallId,
474    /// Name of the tool.
475    pub tool_name: ToolName,
476    /// The validated input.
477    pub input: JsonValue,
478    /// The output value.
479    pub output: JsonValue,
480    /// Whether the provider executed the tool.
481    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
482    pub provider_executed: bool,
483    /// Whether the tool is dynamic.
484    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
485    pub dynamic: bool,
486    /// Whether this result will be superseded by a final one.
487    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
488    pub preliminary: bool,
489    /// Execution time in milliseconds (client-executed tools).
490    #[serde(default, skip_serializing_if = "Option::is_none")]
491    pub execution_ms: Option<u64>,
492    /// Metadata from the tool definition.
493    #[serde(default, skip_serializing_if = "Option::is_none")]
494    pub tool_metadata: Option<JsonObject>,
495    /// Provider-specific metadata.
496    #[serde(default, skip_serializing_if = "Option::is_none")]
497    pub provider_metadata: Option<ProviderMetadata>,
498}
499
500/// A failed tool execution (non-fatal: reported back to the model).
501#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
502pub struct ToolExecutionError {
503    /// Identifier of the tool call.
504    pub tool_call_id: ToolCallId,
505    /// Name of the tool.
506    pub tool_name: ToolName,
507    /// The input.
508    pub input: JsonValue,
509    /// The error.
510    pub error: ToolErrorInfo,
511    /// Whether the provider executed the tool.
512    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
513    pub provider_executed: bool,
514    /// Whether the tool is dynamic.
515    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
516    pub dynamic: bool,
517    /// Metadata from the tool definition.
518    #[serde(default, skip_serializing_if = "Option::is_none")]
519    pub tool_metadata: Option<JsonObject>,
520    /// Provider-specific metadata.
521    #[serde(default, skip_serializing_if = "Option::is_none")]
522    pub provider_metadata: Option<ProviderMetadata>,
523}
524
525/// Serializable description of a tool error.
526#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
527#[serde(tag = "type", rename_all = "kebab-case")]
528#[non_exhaustive]
529pub enum ToolErrorInfo {
530    /// A textual error.
531    Text {
532        /// The message.
533        message: String,
534    },
535    /// A structured error payload.
536    Json {
537        /// The payload.
538        value: JsonValue,
539    },
540}
541
542impl ToolErrorInfo {
543    /// Creates a textual error.
544    #[must_use]
545    pub fn text(message: impl Into<String>) -> Self {
546        Self::Text {
547            message: message.into(),
548        }
549    }
550
551    /// The error as a JSON value (text becomes a JSON string).
552    #[must_use]
553    pub fn to_json_value(&self) -> JsonValue {
554        match self {
555            Self::Text { message } => JsonValue::String(message.clone()),
556            Self::Json { value } => value.clone(),
557        }
558    }
559
560    /// A human-readable message.
561    #[must_use]
562    pub fn message(&self) -> String {
563        match self {
564            Self::Text { message } => message.clone(),
565            Self::Json { value } => ferrin_tool::model_output::error_message(value),
566        }
567    }
568}
569
570impl From<&ToolError> for ToolErrorInfo {
571    fn from(error: &ToolError) -> Self {
572        match error {
573            ToolError::Json { value } => Self::Json {
574                value: value.clone(),
575            },
576            other => Self::text(other.to_string()),
577        }
578    }
579}
580
581impl std::fmt::Display for ToolErrorInfo {
582    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
583        f.write_str(&self.message())
584    }
585}
586
587/// A request to approve a tool call before it executes.
588#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
589pub struct ToolApprovalRequestContent {
590    /// Identifier of the approval request.
591    pub approval_id: ApprovalId,
592    /// The tool call awaiting approval.
593    pub tool_call: ParsedToolCall,
594    /// Reason shown to the approver.
595    #[serde(default, skip_serializing_if = "Option::is_none")]
596    pub reason: Option<String>,
597    /// Whether the decision was made automatically by the approval policy.
598    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
599    pub is_automatic: bool,
600    /// HMAC signature over the request (when a secret is configured).
601    #[serde(default, skip_serializing_if = "Option::is_none")]
602    pub signature: Option<String>,
603    /// Provider-specific metadata (provider-issued requests).
604    #[serde(default, skip_serializing_if = "Option::is_none")]
605    pub provider_metadata: Option<ProviderMetadata>,
606}
607
608/// An approval decision made automatically by the approval policy.
609#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
610pub struct ToolApprovalResponseContent {
611    /// Identifier of the approval request.
612    pub approval_id: ApprovalId,
613    /// The tool call the decision concerns.
614    pub tool_call: ParsedToolCall,
615    /// Whether execution was approved.
616    pub approved: bool,
617    /// Reason given by the policy.
618    #[serde(default, skip_serializing_if = "Option::is_none")]
619    pub reason: Option<String>,
620    /// Whether the provider executes the tool.
621    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
622    pub provider_executed: bool,
623}
624
625/// A tool call whose execution was denied.
626#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
627pub struct ToolOutputDenied {
628    /// Identifier of the tool call.
629    pub tool_call_id: ToolCallId,
630    /// Name of the tool.
631    pub tool_name: ToolName,
632    /// The input.
633    pub input: JsonValue,
634    /// Why execution was denied.
635    #[serde(default, skip_serializing_if = "Option::is_none")]
636    pub reason: Option<String>,
637    /// Whether the provider would have executed the tool.
638    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
639    pub provider_executed: bool,
640    /// Whether the tool is dynamic.
641    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
642    pub dynamic: bool,
643    /// Metadata from the current tool definition when approval was replayed.
644    #[serde(default, skip_serializing_if = "Option::is_none")]
645    pub tool_metadata: Option<JsonObject>,
646    /// Provider routing metadata from the original tool call.
647    #[serde(default, skip_serializing_if = "Option::is_none")]
648    pub provider_metadata: Option<ProviderMetadata>,
649}