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
4 changes: 2 additions & 2 deletions crates/tinytools-agent/src/dialect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,

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 Re-export the parser from the crate root

The new public parser is exposed only through the nested render and dialect modules, so downstream users cannot access it from the crate's centralized public surface. Re-export parse_replayed_results from src/lib.rs alongside the crate's other public entry points.

AGENTS.md reference: AGENTS.md:L87-L88

Useful? React with 👍 / 👎.

render_json_catalogue, render_pformat_catalogue,
};
pub use code::CodeDialect;
pub use native::NativeDialect;
Expand Down
4 changes: 3 additions & 1 deletion crates/tinytools-agent/src/render/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
67 changes: 67 additions & 0 deletions crates/tinytools-agent/src/render/results.rs
Original file line number Diff line number Diff line change
Expand Up @@ -299,3 +299,70 @@ pub fn to_provider_messages(history: &[TranscriptEntry]) -> Vec<DialectMessage>
})
.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
/// `<tool_result id="…">\n…\n</tool_result>\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 (`&lt;`), which
/// cannot be reversed unambiguously and never changes a result's meaning.
#[must_use]
pub fn parse_replayed_results(content: &str) -> Option<Vec<ToolResultEntry>> {
const OPEN: &str = "<tool_result id=\"";
const OPEN_END: &str = "\">\n";
const CLOSE: &str = "\n</tool_result>\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 `</tool_result` (it is neutralized on render),
// so the first close is this block's own.
// An empty body renders as `\n\n</tool_result>\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`. `&amp;` is decoded last so an escaped
/// entity spelled in the original (`&amp;lt;`) round-trips to `&lt;`.
fn unescape_attribute(value: &str) -> String {
if !value.contains('&') {
return value.to_string();
}
value
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&amp;", "&")
}

#[cfg(test)]
#[path = "results_test.rs"]
mod test;
65 changes: 65 additions & 0 deletions crates/tinytools-agent/src/render/results_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]

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 Add module-level documentation to the test module

This new test module starts with a lint attribute and has no //! description, contrary to the repository requirement for every test module. Add a concise module-level comment describing the replay-parser tests.

AGENTS.md reference: AGENTS.md:L193-L196

Useful? React with 👍 / 👎.


use super::*;

fn replay(results: Vec<ToolResultEntry>) -> 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"<id>&"#, ""),
entry("c2", "body with </tool_result> and <div>code</div>\n"),
]);
let parsed = parse_replayed_results(&rendered).expect("parses");
assert_eq!(parsed.len(), 2);
assert_eq!(parsed[0].tool_call_id, r#"odd"<id>&"#);
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 &lt;/tool_result> and <div>code</div>\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}<tool_result name=\"echo\" status=\"ok\">\nhi\n</tool_result>\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}<tool_result id=\"a\">\nhi\n</tool_result>\nand more");
assert!(parse_replayed_results(&trailing).is_none());
}
Loading