diff --git a/crates/tinytools-agent/src/dialect/mod.rs b/crates/tinytools-agent/src/dialect/mod.rs index 08b85b7..d7e4046 100644 --- a/crates/tinytools-agent/src/dialect/mod.rs +++ b/crates/tinytools-agent/src/dialect/mod.rs @@ -93,6 +93,16 @@ pub trait ToolDialect: Send + Sync { /// Replay a transcript as flat provider messages. fn to_provider_messages(&self, history: &[TranscriptEntry]) -> Vec; + /// Replay a transcript as **typed** messages: the same rows as + /// [`Self::to_provider_messages`], but a native tool round keeps its + /// structure in [`DialectMessage::tool_calls`] / [`DialectMessage::tool_call_id`] + /// with plain-text `content`, instead of a JSON envelope packed into the + /// body. Text dialects have no such structure, so the default is the packed + /// form unchanged. + fn to_typed_messages(&self, history: &[TranscriptEntry]) -> Vec { + self.to_provider_messages(history) + } + /// Whether structured tool specs belong in the API request. /// /// `false` for the text dialects: sending specs a dialect cannot read back diff --git a/crates/tinytools-agent/src/dialect/native.rs b/crates/tinytools-agent/src/dialect/native.rs index a65050d..42a9e02 100644 --- a/crates/tinytools-agent/src/dialect/native.rs +++ b/crates/tinytools-agent/src/dialect/native.rs @@ -181,6 +181,39 @@ impl ToolDialect for NativeDialect { .collect() } + fn to_typed_messages(&self, history: &[TranscriptEntry]) -> Vec { + pair_tool_cycles(history) + .into_iter() + .flat_map(|entry| match entry { + TranscriptEntry::Chat(chat) => vec![chat.clone()], + TranscriptEntry::AssistantToolCalls { + text, + tool_calls, + reasoning_content, + extra_metadata, + } => { + vec![ + DialectMessage::assistant_with_calls( + text.clone().unwrap_or_default(), + tool_calls.clone(), + reasoning_content.clone(), + ) + .with_metadata(extra_metadata.clone()), + ] + } + TranscriptEntry::ToolResults(results) => results + .iter() + .map(|result| { + DialectMessage::tool_result( + result.tool_call_id.clone(), + result.content.clone(), + ) + }) + .collect(), + }) + .collect() + } + fn should_send_tool_specs(&self) -> bool { true } diff --git a/crates/tinytools-agent/src/dialect/test.rs b/crates/tinytools-agent/src/dialect/test.rs index d538096..500dc6a 100644 --- a/crates/tinytools-agent/src/dialect/test.rs +++ b/crates/tinytools-agent/src/dialect/test.rs @@ -563,6 +563,86 @@ fn native_replay_carries_reasoning_and_pairs_the_cycle() { assert!(messages[2].content.contains("\"tool_call_id\":\"call_1\"")); } +/// The typed replay is the packed replay with the envelope taken apart: same +/// pairing, same order, same metadata, structure in fields. +#[test] +fn native_typed_replay_matches_the_packed_replay() { + let history = vec![ + TranscriptEntry::Chat(DialectMessage::user("weather?")), + TranscriptEntry::AssistantToolCalls { + text: Some("checking".to_string()), + tool_calls: vec![native_call("call_1", "get_weather", "{}")], + reasoning_content: Some("thinking".to_string()), + extra_metadata: Some(json!({"reasoning_content": "thinking"})), + }, + TranscriptEntry::ToolResults(vec![ToolResultEntry::new( + "call_1".to_string(), + "18C".to_string(), + )]), + TranscriptEntry::Chat(DialectMessage::assistant("sunny")), + ]; + + let packed = NativeDialect.to_provider_messages(&history); + let typed = NativeDialect.to_typed_messages(&history); + + assert_eq!(typed.len(), packed.len()); + assert_eq!(typed[0], packed[0]); + assert_eq!(typed[3], packed[3]); + assert_eq!(typed[1].role, DialectRole::Assistant); + assert_eq!(typed[1].content, "checking"); + assert_eq!( + typed[1].tool_calls, + vec![native_call("call_1", "get_weather", "{}")] + ); + assert_eq!(typed[1].reasoning_content.as_deref(), Some("thinking")); + assert_eq!(typed[1].extra_metadata, packed[1].extra_metadata); + assert_eq!(typed[2].role, DialectRole::Tool); + assert_eq!(typed[2].content, "18C"); + assert_eq!(typed[2].tool_call_id.as_deref(), Some("call_1")); + // Re-packing the typed message gives exactly the packed row. + assert_eq!( + encode_assistant_envelope( + Some(&typed[1].content), + &typed[1].tool_calls, + typed[1].reasoning_content.as_deref() + ), + packed[1].content + ); + assert_eq!( + encode_tool_envelope(typed[2].tool_call_id.as_deref().unwrap(), &typed[2].content), + packed[2].content + ); +} + +#[test] +fn native_typed_replay_drops_unpaired_cycles_like_the_packed_replay() { + let history = vec![ + TranscriptEntry::AssistantToolCalls { + text: None, + tool_calls: vec![native_call("call_1", "a", "{}")], + reasoning_content: None, + extra_metadata: None, + }, + TranscriptEntry::Chat(DialectMessage::user("still there?")), + ]; + assert_eq!( + NativeDialect.to_typed_messages(&history), + NativeDialect.to_provider_messages(&history) + ); +} + +#[test] +fn text_dialects_keep_the_packed_form_as_their_typed_form() { + let history = vec![TranscriptEntry::ToolResults(vec![ToolResultEntry::new( + "c".to_string(), + "out".to_string(), + )])]; + assert_eq!( + XmlDialect.to_typed_messages(&history), + XmlDialect.to_provider_messages(&history) + ); +} + #[test] fn native_replay_drops_an_assistant_turn_whose_results_never_landed() { let history = vec![ diff --git a/crates/tinytools-agent/src/dialect/types.rs b/crates/tinytools-agent/src/dialect/types.rs index b2ca7bf..6b0d300 100644 --- a/crates/tinytools-agent/src/dialect/types.rs +++ b/crates/tinytools-agent/src/dialect/types.rs @@ -51,18 +51,36 @@ impl DialectRole { } /// One flat chat message, as a dialect emits it toward a provider. +/// +/// [`ToolDialect::to_provider_messages`](super::ToolDialect::to_provider_messages) +/// packs a native tool round into `content` (the replay envelopes); +/// [`ToolDialect::to_typed_messages`](super::ToolDialect::to_typed_messages) +/// emits the same round with the structure in the typed fields below and +/// `content` holding plain text only. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct DialectMessage { /// Which turn this is. pub role: DialectRole, - /// The message body. For dialects that pack structure into the body (the - /// native dialect's assistant turns, for instance) this is a JSON string - /// the host's provider adapter parses back out. + /// The message body. For a typed message this is the visible text (an + /// assistant turn's prose, a tool result's output). For the packed form of + /// the native dialect it is a JSON string the host's provider adapter + /// parses back out. pub content: String, /// Host passthrough metadata carried verbatim from the transcript record. /// The dialect never reads it; it only makes sure it survives. #[serde(default, skip_serializing_if = "Option::is_none")] pub extra_metadata: Option, + /// The native tool calls an assistant message made. Empty everywhere except + /// a typed native assistant turn. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tool_calls: Vec, + /// The call a native tool-result message answers. `None` everywhere except + /// a typed native tool message. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_call_id: Option, + /// The thinking output replayed with a typed native assistant turn. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_content: Option, } impl DialectMessage { @@ -92,6 +110,31 @@ impl DialectMessage { role, content: content.into(), extra_metadata: None, + tool_calls: Vec::new(), + tool_call_id: None, + reasoning_content: None, + } + } + + /// A typed native assistant turn: `text` is the visible prose, the calls + /// ride in [`Self::tool_calls`]. + pub fn assistant_with_calls( + text: impl Into, + tool_calls: Vec, + reasoning_content: Option, + ) -> Self { + Self { + tool_calls, + reasoning_content, + ..Self::assistant(text) + } + } + + /// A typed native tool result answering `tool_call_id`. + pub fn tool_result(tool_call_id: impl Into, content: impl Into) -> Self { + Self { + tool_call_id: Some(tool_call_id.into()), + ..Self::tool(content) } }