Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 76 additions & 2 deletions v2/src/value/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,27 +23,54 @@ pub enum Role {
Tool,
}

/// A chat message generated by a user, model, or tool.
///
/// `Message` is the concrete, non-streaming container used by the application to store, transmit, or feed structured content into models or tools.
/// It can represent various kinds of messages, including user input, assistant responses, tool-call outputs, or signed *thinking* metadata.
///
/// Note that many different kinds of messages can be produced.
/// For example, a language model may internally generate a `thinking` trace before emitting its final output, in order to improve reasoning accuracy.
/// In other cases, a model may produce *function calls* — structured outputs that instruct external tools to perform specific actions.
///
/// This struct is designed to handle all of these situations in a unified way.
///
/// # Example
///
/// ## Rust
/// ```rust
/// let msg = Message::new(Role::User).with_contents([Part::text("hello")]);
/// assert_eq!(msg.role, Role::User);
/// assert_eq!(msg.contents.len(), 1);
/// ```
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "python", pyo3_stub_gen_derive::gen_stub_pyclass)]
#[cfg_attr(feature = "python", pyo3::pyclass(get_all, set_all))]
#[cfg_attr(feature = "nodejs", napi_derive::napi(object))]
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
pub struct Message {
/// Author of the message.
pub role: Role,

/// Primary message parts (e.g., text, image, value, or function).
pub contents: Vec<Part>,

/// Optional stable identifier for deduplication or threading.
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,

/// Internal “thinking” text used by some models before producing final output.
#[serde(skip_serializing_if = "Option::is_none")]
pub thinking: Option<String>,

/// Tool-call parts emitted alongside the main contents.
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "nodejs", napi_derive::napi(js_name = "tool_calls"))]
pub tool_calls: Option<Vec<Part>>,

/// Optional signature for the `thinking` field.
///
/// This is only applicable to certain LLM APIs that require a signature as part of the `thinking` payload.
#[serde(skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
}
Expand Down Expand Up @@ -94,6 +121,30 @@ impl Message {
}
}

/// A streaming, incremental update to a [`Message`].
///
/// `MessageDelta` accumulates partial outputs (text chunks, tool-call fragments, IDs, signatures, etc.) until they can be materialized as a full [`Message`].
/// It implements [`Delta`] to support associative aggregation.
///
/// # Aggregation Rules
/// - `role`: merging two distinct roles fails.
/// - `thinking`: concatenated in arrival order.
/// - `contents`/`tool_calls`: last element is aggregated with the incoming delta when both are compatible (e.g., Text+Text, Function+Function with matching ID policy), otherwise appended as a new fragment.
/// - `id`/`signature`: last-writer-wins.
///
/// # Finalization
/// - `finish()` converts the accumulated deltas into a fully-formed [`Message`].
/// Fails if required fields (e.g., `role`) are missing or inner deltas cannot be finalized.
///
/// # Examples
/// ```rust
/// let d1 = MessageDelta::new().with_role(Role::Assistant).with_contents([PartDelta::Text { text: "Hel".into() }]);
/// let d2 = MessageDelta::new().with_contents([PartDelta::Text { text: "lo".into() }]);
///
/// let merged = d1.aggregate(d2).unwrap();
/// let msg = merged.finish().unwrap();
/// assert_eq!(msg.contents[0].as_text().unwrap(), "Hello");
/// ```
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "python", pyo3_stub_gen_derive::gen_stub_pyclass)]
#[cfg_attr(feature = "python", pyo3::pyclass(get_all, set_all))]
Expand Down Expand Up @@ -301,6 +352,7 @@ impl Delta for MessageDelta {
}
}

/// Explains why a language model's streamed generation finished.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[cfg_attr(feature = "python", pyo3_stub_gen_derive::gen_stub_pyclass_enum)]
Expand All @@ -312,12 +364,34 @@ impl Delta for MessageDelta {
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
pub enum FinishReason {
/// The model stopped naturally (e.g., EOS token or stop sequence).
Stop {},
Length {}, // max_output_tokens

/// Hit the maximum token/length limit.
Length {},

/// Stopped because a tool call was produced, waiting for it's execution.
ToolCall {},
Refusal { reason: String }, // content_filter, refusal

/// Content was refused/filtered; string provides reason.
Refusal { reason: String },
}

/// A container for a streamed message delta and its termination signal.
///
/// During streaming, `delta` carries the incremental payload; once a terminal
/// condition is reached, `finish_reason` may be populated to explain why.
///
/// # Examples
/// ```rust
/// let mut out = MessageOutput::new();
/// out.delta = MessageDelta::new().with_role(Role::Assistant).with_contents([PartDelta::Text { text: "Hi".into() }]);
/// assert!(out.finish_reason.is_none());
/// ```
///
/// # Lifecycle
/// - While streaming: `finish_reason` is typically `None`.
/// - On completion: `finish_reason` is set; callers can then `finish()` the delta to obtain a concrete [`Message`].
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "python", pyo3_stub_gen_derive::gen_stub_pyclass)]
#[cfg_attr(feature = "python", pyo3::pyclass(get_all, set_all))]
Expand Down
Loading