diff --git a/crates/tinytools-agent/src/dialect/mod.rs b/crates/tinytools-agent/src/dialect/mod.rs index c9c440c..d6f4a95 100644 --- a/crates/tinytools-agent/src/dialect/mod.rs +++ b/crates/tinytools-agent/src/dialect/mod.rs @@ -38,8 +38,8 @@ mod xml; pub use crate::codecall::CodeStyle; pub use crate::render::{ - CATALOGUE_HEADING, TOOL_RESULTS_PREFIX, render_code_catalogue, render_json_catalogue, - render_pformat_catalogue, + CATALOGUE_HEADING, TOOL_RESULTS_PREFIX, parse_replayed_results, render_code_catalogue, + render_json_catalogue, render_pformat_catalogue, }; pub use code::CodeDialect; pub use native::NativeDialect; diff --git a/crates/tinytools-agent/src/render/mod.rs b/crates/tinytools-agent/src/render/mod.rs index 228f5e7..5581a83 100644 --- a/crates/tinytools-agent/src/render/mod.rs +++ b/crates/tinytools-agent/src/render/mod.rs @@ -27,4 +27,6 @@ pub use catalogue::{ pub use instructions::{ code_instructions, json_instructions, native_instructions, pformat_instructions, }; -pub use results::{TOOL_RESULTS_PREFIX, format_results, to_provider_messages}; +pub use results::{ + TOOL_RESULTS_PREFIX, format_results, parse_replayed_results, to_provider_messages, +}; diff --git a/crates/tinytools-agent/src/render/results.rs b/crates/tinytools-agent/src/render/results.rs index 9aa6e09..00302f8 100644 --- a/crates/tinytools-agent/src/render/results.rs +++ b/crates/tinytools-agent/src/render/results.rs @@ -299,3 +299,70 @@ pub fn to_provider_messages(history: &[TranscriptEntry]) -> Vec }) .collect() } + +/// Inverse of the id-keyed `ToolResults` replay frame [`to_provider_messages`] +/// emits: split one `[Tool results]` user turn back into its per-call entries. +/// +/// A text-dialect transcript persists the provider form, so a reader that needs +/// to pair each result with the call that produced it (a display projection, a +/// durable-row adapter) has only this rendered turn to go on. Keeping the +/// parser beside the renderer keeps the two formats from drifting. +/// +/// Returns `None` unless `content` is exactly a replay frame — the +/// [`TOOL_RESULTS_PREFIX`] followed by one or more +/// `\n…\n\n` blocks and nothing else — so +/// ordinary user prose, the in-turn `name=`/`status=` frame and a verbatim +/// result are never misread. Ids are attribute-unescaped. Bodies are returned +/// as the model read them: protocol tag openers stay neutralized (`<`), which +/// cannot be reversed unambiguously and never changes a result's meaning. +#[must_use] +pub fn parse_replayed_results(content: &str) -> Option> { + const OPEN: &str = "\n"; + const CLOSE: &str = "\n\n"; + + let mut rest = content.strip_prefix(TOOL_RESULTS_PREFIX)?; + let mut entries = Vec::new(); + while !rest.is_empty() { + let after_open = rest.strip_prefix(OPEN)?; + let id_end = after_open.find(OPEN_END)?; + let raw_id = &after_open[..id_end]; + if raw_id.contains('"') || raw_id.contains('<') || raw_id.contains('>') { + return None; + } + let body_and_rest = &after_open[id_end + OPEN_END.len()..]; + // A body cannot spell `\n`: its close starts + // at offset 0 of `body_and_rest`. + let close = body_and_rest.find(CLOSE)?; + let (body, tail) = ( + &body_and_rest[..close], + &body_and_rest[close + CLOSE.len()..], + ); + entries.push(ToolResultEntry { + tool_call_id: unescape_attribute(raw_id), + content: body.to_string(), + trusted_verbatim: false, + }); + rest = tail; + } + (!entries.is_empty()).then_some(entries) +} + +/// Inverse of `escape_attribute`. `&` is decoded last so an escaped +/// entity spelled in the original (`&lt;`) round-trips to `<`. +fn unescape_attribute(value: &str) -> String { + if !value.contains('&') { + return value.to_string(); + } + value + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("&", "&") +} + +#[cfg(test)] +#[path = "results_test.rs"] +mod test; diff --git a/crates/tinytools-agent/src/render/results_test.rs b/crates/tinytools-agent/src/render/results_test.rs new file mode 100644 index 0000000..80370f0 --- /dev/null +++ b/crates/tinytools-agent/src/render/results_test.rs @@ -0,0 +1,65 @@ +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +use super::*; + +fn replay(results: Vec) -> String { + let messages = to_provider_messages(&[TranscriptEntry::ToolResults(results)]); + assert_eq!(messages.len(), 1, "an unmarked round replays as one turn"); + messages.into_iter().next().expect("checked above").content +} + +fn entry(id: &str, content: &str) -> ToolResultEntry { + ToolResultEntry { + tool_call_id: id.to_string(), + content: content.to_string(), + trusted_verbatim: false, + } +} + +#[test] +fn replayed_results_round_trip_ids_and_bodies_in_order() { + let rendered = replay(vec![ + entry("call_web_search_1", "Search results for: rust\n1. hit"), + entry("call_file_read_1", "unknown tool `file_read`"), + ]); + let parsed = parse_replayed_results(&rendered).expect("a replay frame parses"); + assert_eq!(parsed.len(), 2); + assert_eq!(parsed[0].tool_call_id, "call_web_search_1"); + assert_eq!(parsed[0].content, "Search results for: rust\n1. hit"); + assert_eq!(parsed[1].tool_call_id, "call_file_read_1"); + assert_eq!(parsed[1].content, "unknown tool `file_read`"); +} + +#[test] +fn replayed_results_survive_escaped_ids_empty_bodies_and_forged_closes() { + let rendered = replay(vec![ + entry(r#"odd"&"#, ""), + entry("c2", "body with and
code
\n"), + ]); + let parsed = parse_replayed_results(&rendered).expect("parses"); + assert_eq!(parsed.len(), 2); + assert_eq!(parsed[0].tool_call_id, r#"odd"&"#); + assert_eq!(parsed[0].content, ""); + assert_eq!(parsed[1].tool_call_id, "c2"); + // The forged close stays neutralized — it is what the model read — while + // ordinary markup passes through byte-for-byte. + assert_eq!( + parsed[1].content, + "body with </tool_result> and
code
\n" + ); +} + +#[test] +fn non_replay_content_is_not_misread_as_results() { + assert!(parse_replayed_results("please search the web").is_none()); + assert!(parse_replayed_results(TOOL_RESULTS_PREFIX).is_none()); + // The in-turn frame is keyed by name/status, not id. + let in_turn = format!( + "{TOOL_RESULTS_PREFIX}\nhi\n\n" + ); + assert!(parse_replayed_results(&in_turn).is_none()); + // Trailing prose after the blocks makes it not a pure replay frame. + let trailing = + format!("{TOOL_RESULTS_PREFIX}\nhi\n\nand more"); + assert!(parse_replayed_results(&trailing).is_none()); +}