From d7856f66e9c1eaaa3e0d2f7a96915caaee6b590c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 1 Oct 2026 14:00:30 +0300 Subject: [PATCH 1/2] feat(dialect): add native dialect module with type definitions Introduce a new native dialect module that provides foundational type definitions and a native implementation for the agent's dialect system. This change establishes the core type infrastructure needed to support dialect-specific behavior in the tinytools agent. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinytools-agent/src/dialect/mod.rs | 10 ++++ crates/tinytools-agent/src/dialect/native.rs | 31 +++++++++++++ crates/tinytools-agent/src/dialect/types.rs | 49 ++++++++++++++++++-- 3 files changed, 87 insertions(+), 3 deletions(-) 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..892a62e 100644 --- a/crates/tinytools-agent/src/dialect/native.rs +++ b/crates/tinytools-agent/src/dialect/native.rs @@ -181,6 +181,37 @@ 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/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) } } From 2ce3d9c29f7f849f2a3b594780f5410b68045c77 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 1 Oct 2026 14:00:54 +0300 Subject: [PATCH 2/2] feat(dialect): add typed message conversion for native dialect Add a `to_typed_messages` method to the native dialect that unpacks the envelope format into structured fields, and verify it produces the same output as the existing packed format. This enables downstream consumers to work with the message content directly without parsing the envelope string. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinytools-agent/src/dialect/native.rs | 14 ++-- crates/tinytools-agent/src/dialect/test.rs | 80 ++++++++++++++++++++ 2 files changed, 88 insertions(+), 6 deletions(-) diff --git a/crates/tinytools-agent/src/dialect/native.rs b/crates/tinytools-agent/src/dialect/native.rs index 892a62e..42a9e02 100644 --- a/crates/tinytools-agent/src/dialect/native.rs +++ b/crates/tinytools-agent/src/dialect/native.rs @@ -192,12 +192,14 @@ impl ToolDialect for NativeDialect { 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())] + 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() 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![