Skip to main content

yapi_types/
message.rs

1//! Messages stored in sessions and sent to providers.
2//!
3//! Mirrors `packages/ai/src/types.ts` and `packages/coding-agent/src/core/messages.ts`
4//! in pi `v1.0.0`. Fields are declared in the order pi's providers and agent loop
5//! build them; see the crate docs for why existing files are not re-serialized from
6//! these types.
7#![allow(
8    missing_docs,
9    reason = "fields mirror pi's TypeScript types; contracts are noted where they differ from the name"
10)]
11
12use indexmap::IndexMap;
13use serde::{Deserialize, Serialize};
14use serde_json::{Map, Value};
15
16use crate::present;
17
18/// A message in a session or agent context, tagged by `role`.
19#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
20#[serde(tag = "role", rename_all = "camelCase")]
21pub enum Message {
22    System(SystemMessage),
23    User(UserMessage),
24    Assistant(Box<AssistantMessage>),
25    ToolResult(ToolResultMessage),
26    /// Output of a user `!command`.
27    BashExecution(BashExecutionMessage),
28    /// Extension message; in message entries only for sessions migrated from v2.
29    Custom(CustomMessage),
30    /// Built from a `branch_summary` entry when assembling context; not stored.
31    BranchSummary(BranchSummaryMessage),
32    /// Built from a `compaction` entry when assembling context; not stored.
33    CompactionSummary(CompactionSummaryMessage),
34}
35
36/// System prompt state at one point in the transcript. The first system message is
37/// the prompt; later ones patch it.
38#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
39#[serde(rename_all = "camelCase")]
40pub struct SystemMessage {
41    pub content: Content,
42    /// Named prompt sections in render order; `None` removes a section.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub sections: Option<IndexMap<String, Option<String>>>,
45    /// Unix time in milliseconds.
46    pub timestamp: u64,
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub tools_added: Option<Vec<ToolDeclaration>>,
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub tools_removed: Option<Vec<ToolReference>>,
51}
52
53/// A tool as declared to the model.
54#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
55#[serde(rename_all = "camelCase")]
56pub struct ToolDeclaration {
57    pub name: String,
58    pub description: String,
59    /// JSON Schema of the arguments.
60    pub parameters: Value,
61    /// `false` or a provider-specific configuration.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub constrained_sampling: Option<Value>,
64}
65
66/// A tool referenced by name.
67#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
68pub struct ToolReference {
69    pub name: String,
70}
71
72#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
73pub struct UserMessage {
74    pub content: Content,
75    /// Unix time in milliseconds.
76    pub timestamp: u64,
77}
78
79/// A model response. Providers append `responseId` and the fields after it once the
80/// response arrives, so they follow `timestamp`.
81#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
82#[serde(rename_all = "camelCase")]
83pub struct AssistantMessage {
84    pub content: Vec<ContentBlock>,
85    pub api: String,
86    pub provider: String,
87    pub model: String,
88    /// Provider-native effort level used for this response.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub provider_thinking_level: Option<String>,
91    pub usage: Usage,
92    pub stop_reason: StopReason,
93    /// Unix time in milliseconds.
94    pub timestamp: u64,
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub response_id: Option<String>,
97    /// Concrete model reported by the provider when it differs from `model`.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub response_model: Option<String>,
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub raw_stop_reason: Option<String>,
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub deferred: Option<DeferredHandle>,
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub error_message: Option<String>,
106    /// Redacted provider and runtime diagnostics.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub diagnostics: Option<Vec<Value>>,
109    /// Whether the provider reported an explicit end of turn.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub end_turn: Option<bool>,
112    /// pi thinking level the agent loop requested; set last by the loop.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub thinking_level: Option<ThinkingLevel>,
115}
116
117/// Handle to a response the provider finishes later.
118#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
119#[serde(rename_all = "camelCase")]
120pub struct DeferredHandle {
121    pub provider: String,
122    pub model_id: String,
123    pub api: String,
124    pub id: String,
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub expires_at: Option<u64>,
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub poll_after_ms: Option<u64>,
129    #[serde(
130        default,
131        deserialize_with = "present",
132        skip_serializing_if = "Option::is_none"
133    )]
134    pub data: Option<Value>,
135}
136
137/// Result of one tool call. `nestedCalls` is attached after the result is built, so it
138/// follows `timestamp`.
139#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
140#[serde(rename_all = "camelCase")]
141pub struct ToolResultMessage {
142    pub tool_call_id: String,
143    pub tool_name: String,
144    pub content: Vec<ContentBlock>,
145    #[serde(
146        default,
147        deserialize_with = "present",
148        skip_serializing_if = "Option::is_none"
149    )]
150    pub details: Option<Value>,
151    /// Usage of the tool itself, outside the main context accounting.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub usage: Option<Usage>,
154    pub is_error: bool,
155    /// Unix time in milliseconds.
156    pub timestamp: u64,
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub nested_calls: Option<NestedToolCalls>,
159}
160
161/// Bounded record of the tool calls a tool made, for example from a codemode script.
162#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
163pub struct NestedToolCalls {
164    pub calls: Vec<NestedToolCall>,
165    /// False when calls were dropped, arguments omitted, or calls were unfinished.
166    pub complete: bool,
167}
168
169#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
170#[serde(rename_all = "camelCase")]
171pub struct NestedToolCall {
172    pub id: String,
173    pub name: String,
174    /// Omitted when over the size limit; `argumentsBytes` then gives the size.
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub arguments: Option<Map<String, Value>>,
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub arguments_bytes: Option<u64>,
179    pub status: NestedToolCallStatus,
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub duration_ms: Option<u64>,
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub error: Option<String>,
184}
185
186#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
187#[serde(rename_all = "lowercase")]
188pub enum NestedToolCallStatus {
189    Ok,
190    Error,
191    Unfinished,
192}
193
194/// Output of a user `!command`.
195#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
196#[serde(rename_all = "camelCase")]
197pub struct BashExecutionMessage {
198    pub command: String,
199    pub output: String,
200    /// Absent when the command was cancelled.
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub exit_code: Option<i64>,
203    pub cancelled: bool,
204    pub truncated: bool,
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub full_output_path: Option<String>,
207    /// Unix time in milliseconds.
208    pub timestamp: u64,
209    /// Set for `!!command`: shown to the user, not sent to the model.
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub exclude_from_context: Option<bool>,
212}
213
214#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
215#[serde(rename_all = "camelCase")]
216pub struct CustomMessage {
217    pub custom_type: String,
218    pub content: Content,
219    pub display: bool,
220    #[serde(
221        default,
222        deserialize_with = "present",
223        skip_serializing_if = "Option::is_none"
224    )]
225    pub details: Option<Value>,
226    /// Unix time in milliseconds.
227    pub timestamp: u64,
228}
229
230#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
231#[serde(rename_all = "camelCase")]
232pub struct BranchSummaryMessage {
233    pub summary: String,
234    pub from_id: Option<String>,
235    /// Unix time in milliseconds.
236    pub timestamp: u64,
237}
238
239#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
240#[serde(rename_all = "camelCase")]
241pub struct CompactionSummaryMessage {
242    pub summary: String,
243    pub tokens_before: u64,
244    /// Unix time in milliseconds.
245    pub timestamp: u64,
246}
247
248/// Message content: plain text or a list of blocks.
249#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
250#[serde(untagged)]
251pub enum Content {
252    Text(String),
253    Blocks(Vec<ContentBlock>),
254}
255
256/// A content block, tagged by `type`. Which kinds a role allows follows pi's types:
257/// assistants use text, thinking and tool calls; users and tool results use text
258/// and images.
259#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
260#[serde(tag = "type", rename_all = "camelCase")]
261pub enum ContentBlock {
262    Text(TextContent),
263    Thinking(ThinkingContent),
264    ToolCall(ToolCall),
265    Image(ImageContent),
266}
267
268#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
269#[serde(rename_all = "camelCase")]
270pub struct TextContent {
271    pub text: String,
272    /// OpenAI Responses and Google message metadata.
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub text_signature: Option<String>,
275}
276
277#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
278#[serde(rename_all = "camelCase")]
279pub struct ThinkingContent {
280    pub thinking: String,
281    /// Opaque reasoning replay data; may be empty.
282    #[serde(default, skip_serializing_if = "Option::is_none")]
283    pub thinking_signature: Option<String>,
284    /// Content redacted by provider safety filters; the payload is in the signature.
285    #[serde(default, skip_serializing_if = "Option::is_none")]
286    pub redacted: Option<bool>,
287}
288
289#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
290#[serde(rename_all = "camelCase")]
291pub struct ToolCall {
292    pub id: String,
293    pub name: String,
294    pub arguments: Map<String, Value>,
295    /// Google thought signature.
296    #[serde(default, skip_serializing_if = "Option::is_none")]
297    pub thought_signature: Option<String>,
298    /// OpenAI Responses tool namespace.
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub namespace: Option<String>,
301}
302
303#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
304#[serde(rename_all = "camelCase")]
305pub struct ImageContent {
306    /// Base64-encoded image data.
307    pub data: String,
308    pub mime_type: String,
309}
310
311/// Token usage and cost of one request.
312#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
313#[serde(rename_all = "camelCase")]
314pub struct Usage {
315    pub input: u64,
316    pub output: u64,
317    pub cache_read: u64,
318    pub cache_write: u64,
319    /// Reasoning tokens, already included in `output`.
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub reasoning: Option<u64>,
322    /// Absent in sessions written by early pi versions.
323    #[serde(default, skip_serializing_if = "Option::is_none")]
324    pub total_tokens: Option<u64>,
325    pub cost: Cost,
326    /// Part of `cacheWrite` with one-hour retention; Anthropic only.
327    #[serde(
328        default,
329        rename = "cacheWrite1h",
330        skip_serializing_if = "Option::is_none"
331    )]
332    pub cache_write_1h: Option<u64>,
333}
334
335/// Cost in US dollars.
336#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
337#[serde(rename_all = "camelCase")]
338pub struct Cost {
339    pub input: f64,
340    pub output: f64,
341    pub cache_read: f64,
342    pub cache_write: f64,
343    pub total: f64,
344}
345
346#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
347#[serde(rename_all = "camelCase")]
348pub enum StopReason {
349    Pending,
350    Stop,
351    Length,
352    ToolUse,
353    Error,
354    Aborted,
355    Deferred,
356}
357
358/// pi thinking level, from `off` to `max`, ordered by effort.
359#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
360#[serde(rename_all = "lowercase")]
361pub enum ThinkingLevel {
362    Off,
363    Minimal,
364    Low,
365    Medium,
366    High,
367    Xhigh,
368    Max,
369}
370
371impl ThinkingLevel {
372    /// Every level, in order.
373    pub const ALL: [ThinkingLevel; 7] = [
374        ThinkingLevel::Off,
375        ThinkingLevel::Minimal,
376        ThinkingLevel::Low,
377        ThinkingLevel::Medium,
378        ThinkingLevel::High,
379        ThinkingLevel::Xhigh,
380        ThinkingLevel::Max,
381    ];
382
383    /// The name pi uses in JSON and on the command line.
384    pub fn as_str(self) -> &'static str {
385        match self {
386            ThinkingLevel::Off => "off",
387            ThinkingLevel::Minimal => "minimal",
388            ThinkingLevel::Low => "low",
389            ThinkingLevel::Medium => "medium",
390            ThinkingLevel::High => "high",
391            ThinkingLevel::Xhigh => "xhigh",
392            ThinkingLevel::Max => "max",
393        }
394    }
395
396    /// Parses a name from [`ThinkingLevel::as_str`].
397    pub fn parse(name: &str) -> Option<ThinkingLevel> {
398        ThinkingLevel::ALL
399            .into_iter()
400            .find(|level| level.as_str() == name)
401    }
402}
403
404impl Message {
405    /// Unix time in milliseconds.
406    pub fn timestamp(&self) -> u64 {
407        match self {
408            Message::System(message) => message.timestamp,
409            Message::User(message) => message.timestamp,
410            Message::Assistant(message) => message.timestamp,
411            Message::ToolResult(message) => message.timestamp,
412            Message::BashExecution(message) => message.timestamp,
413            Message::Custom(message) => message.timestamp,
414            Message::BranchSummary(message) => message.timestamp,
415            Message::CompactionSummary(message) => message.timestamp,
416        }
417    }
418}
419
420impl Usage {
421    /// The sum of two usages, as pi's `combineUsage`: the optional counts are
422    /// present when either side has them.
423    pub fn combine(&self, other: &Usage) -> Usage {
424        let optional = |a: Option<u64>, b: Option<u64>| {
425            (a.is_some() || b.is_some()).then(|| a.unwrap_or(0) + b.unwrap_or(0))
426        };
427        Usage {
428            input: self.input + other.input,
429            output: self.output + other.output,
430            cache_read: self.cache_read + other.cache_read,
431            cache_write: self.cache_write + other.cache_write,
432            cache_write_1h: optional(self.cache_write_1h, other.cache_write_1h),
433            reasoning: optional(self.reasoning, other.reasoning),
434            total_tokens: Some(self.total_tokens.unwrap_or(0) + other.total_tokens.unwrap_or(0)),
435            cost: Cost {
436                input: self.cost.input + other.cost.input,
437                output: self.cost.output + other.cost.output,
438                cache_read: self.cost.cache_read + other.cost.cache_read,
439                cache_write: self.cost.cache_write + other.cost.cache_write,
440                total: self.cost.total + other.cost.total,
441            },
442        }
443    }
444}
445
446impl Content {
447    /// The text blocks joined by `separator`; images and other blocks are skipped.
448    pub fn text(&self, separator: &str) -> String {
449        match self {
450            Content::Text(text) => text.clone(),
451            Content::Blocks(blocks) => blocks_text(blocks, separator),
452        }
453    }
454
455    /// The content as blocks; a string becomes one text block.
456    pub fn into_blocks(self) -> Vec<ContentBlock> {
457        match self {
458            Content::Text(text) => vec![ContentBlock::text(text)],
459            Content::Blocks(blocks) => blocks,
460        }
461    }
462}
463
464impl ContentBlock {
465    /// A text block without a signature.
466    pub fn text(text: impl Into<String>) -> ContentBlock {
467        ContentBlock::Text(TextContent {
468            text: text.into(),
469            text_signature: None,
470        })
471    }
472
473    /// A thinking block that is not redacted.
474    pub fn thinking(thinking: impl Into<String>, signature: Option<String>) -> ContentBlock {
475        ContentBlock::Thinking(ThinkingContent {
476            thinking: thinking.into(),
477            thinking_signature: signature,
478            redacted: None,
479        })
480    }
481
482    /// A tool call without a thought signature or namespace.
483    pub fn tool_call(
484        id: impl Into<String>,
485        name: impl Into<String>,
486        arguments: Map<String, Value>,
487    ) -> ContentBlock {
488        ContentBlock::ToolCall(ToolCall {
489            id: id.into(),
490            name: name.into(),
491            arguments,
492            thought_signature: None,
493            namespace: None,
494        })
495    }
496}
497
498/// The text blocks of `blocks` joined by `separator`.
499pub fn blocks_text(blocks: &[ContentBlock], separator: &str) -> String {
500    blocks
501        .iter()
502        .filter_map(|block| match block {
503            ContentBlock::Text(text) => Some(text.text.as_str()),
504            _ => None,
505        })
506        .collect::<Vec<_>>()
507        .join(separator)
508}
509
510impl SystemMessage {
511    /// The prompt text: content, then each present section, separated by blank lines.
512    pub fn text(&self) -> String {
513        let mut parts = vec![self.content.text("\n")];
514        for text in self
515            .sections
516            .iter()
517            .flatten()
518            .filter_map(|(_, text)| text.clone())
519        {
520            parts.push(text);
521        }
522        parts.retain(|part| !part.is_empty());
523        parts.join("\n\n")
524    }
525
526    /// How a later system message reads as a mid-conversation update.
527    pub fn render_update(&self) -> String {
528        let mut parts = Vec::new();
529        let text = self.content.text("\n");
530        if !text.is_empty() {
531            parts.push(text);
532        }
533        for (name, value) in self.sections.iter().flatten() {
534            parts.push(match value {
535                None => format!("Removed system prompt section \"{name}\"."),
536                Some(value) => format!("Updated system prompt section \"{name}\":\n\n{value}"),
537            });
538        }
539        parts.join("\n\n")
540    }
541}