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
10 changes: 10 additions & 0 deletions crates/tinytools-agent/src/dialect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,16 @@ pub trait ToolDialect: Send + Sync {
/// Replay a transcript as flat provider messages.
fn to_provider_messages(&self, history: &[TranscriptEntry]) -> Vec<DialectMessage>;

/// 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<DialectMessage> {
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
Expand Down
33 changes: 33 additions & 0 deletions crates/tinytools-agent/src/dialect/native.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,39 @@ impl ToolDialect for NativeDialect {
.collect()
}

fn to_typed_messages(&self, history: &[TranscriptEntry]) -> Vec<DialectMessage> {
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
}
Expand Down
80 changes: 80 additions & 0 deletions crates/tinytools-agent/src/dialect/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority medium tests likely

Verify that the native dialect's packed path also drops unpaired cycles

The test asserts that to_typed_messages and to_provider_messages return the same result for an unpaired tool call, but it does not independently verify that the packed path (to_provider_messages) actually drops the cycle. If the packed path were changed to keep unpaired cycles (or if it never dropped them in the first place), this test would still pass because both paths return the same (wrong) output. The test should either assert an expected length (e.g., assert_eq!(typed.len(), 1)) or explicitly verify that the assistant turn is absent, to pin the dropping behavior independently of the equivalence assertion.

[RULE] unverified-assumption ·

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![
Expand Down
49 changes: 46 additions & 3 deletions crates/tinytools-agent/src/dialect/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority medium tests confident

Pin the serde representation of the new DialectMessage fields

DialectMessage is a public payload type that is serialized/deserialized. The diff adds three new fields (tool_calls, tool_call_id, reasoning_content) with #[serde(default, skip_serializing_if = ...)] to maintain backward compatibility, but there is no test that asserts the JSON shape of a DialectMessage with and without these fields remains stable. The repository's coding rules require payload types to pin their serde representation in a unit test. Without such a test, a future change that accidentally alters the wire format (e.g., changes the skip_serializing_if condition or adds a rename) would go undetected.

[RULE] missing-serde-pin-test ·

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<Value>,
/// 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<NativeToolCall>,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bump the minor version for the breaking fields

Adding fields to the public, non-non_exhaustive DialectMessage is source-incompatible because every downstream struct literal must now initialize three additional fields. The workspace still advertises version 0.5.0, so this breaking public-surface change needs a 0.6.0 version bump or a design that does not extend the existing public struct.

AGENTS.md reference: AGENTS.md:L264-L266

Useful? React with 👍 / 👎.

Comment on lines +75 to +76

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Pin the expanded message wire representation

Add a serialization regression test for DialectMessage covering the three new field names, their emitted values, and their default omission during serialization. The new replay test only examines Rust fields and manually repacks envelopes, so it would not catch an accidental serde rename or removal of a skip_serializing_if rule even though this type is a persisted/provider-facing payload whose wire representation must be pinned.

AGENTS.md reference: AGENTS.md:L175-L177

Useful? React with 👍 / 👎.

/// 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<String>,
/// The thinking output replayed with a typed native assistant turn.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>,
}

impl DialectMessage {
Expand Down Expand Up @@ -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<String>,
tool_calls: Vec<NativeToolCall>,
reasoning_content: Option<String>,
) -> 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<String>, content: impl Into<String>) -> Self {
Self {
tool_call_id: Some(tool_call_id.into()),
..Self::tool(content)
}
}

Expand Down
Loading