diff --git a/crates/tinytools-agent/src/lib.rs b/crates/tinytools-agent/src/lib.rs
index 624aee0..18e0989 100644
--- a/crates/tinytools-agent/src/lib.rs
+++ b/crates/tinytools-agent/src/lib.rs
@@ -44,8 +44,8 @@ pub use tinytools;
pub use codecall::{CodeStyle, parse_calls as parse_code_calls, render_code_signature};
pub use parse::{
- extract_json_values, parse_arguments_value, parse_glm_style_tool_calls, parse_text,
- parse_tool_call_value, parse_tool_calls, parse_tool_calls_from_json_value,
+ contains_call_markup, extract_json_values, parse_arguments_value, parse_glm_style_tool_calls,
+ parse_text, parse_tool_call_value, parse_tool_calls, parse_tool_calls_from_json_value,
parse_tool_calls_with_pformat,
};
pub use pformat::{
diff --git a/crates/tinytools-agent/src/parse/mod.rs b/crates/tinytools-agent/src/parse/mod.rs
index ef5e2fc..05196b0 100644
--- a/crates/tinytools-agent/src/parse/mod.rs
+++ b/crates/tinytools-agent/src/parse/mod.rs
@@ -274,6 +274,28 @@ pub fn parse_tool_calls_with_pformat(
parse_text(response, &options).into_parts()
}
+/// Whether `text` contains a tool call written as markup in any grammar this
+/// crate recognises: a complete call, or a recognised block that did not
+/// decode (malformed, or opened and never closed).
+///
+/// For a caller that must refuse text which is really a tool call rather
+/// than read calls out of it — a summarizer whose request declared no tools,
+/// say, where `DeepSeek` V4 can answer with `<|DSML|invoke name="shell">…`
+/// instead of a summary. A response that is only a bare JSON object does
+/// not count, since an answer may legitimately be one, and markup quoted
+/// inside a language-tagged fence is protected as usual.
+#[must_use]
+pub fn contains_call_markup(text: &str) -> bool {
+ let outcome = parse_text(text, &ParseOptions::new().without_bare_json());
+ !outcome.calls.is_empty()
+ || outcome.diagnostics.iter().any(|diagnostic| {
+ matches!(
+ diagnostic,
+ ParseDiagnostic::MalformedBlock { .. } | ParseDiagnostic::UnterminatedBlock { .. }
+ )
+ })
+}
+
/// Normalizes an argument value, decoding stringified JSON when possible.
#[must_use]
pub fn parse_arguments_value(raw: Option<&serde_json::Value>) -> serde_json::Value {
diff --git a/crates/tinytools-agent/src/parse/test/markup.rs b/crates/tinytools-agent/src/parse/test/markup.rs
new file mode 100644
index 0000000..dca5d3e
--- /dev/null
+++ b/crates/tinytools-agent/src/parse/test/markup.rs
@@ -0,0 +1,50 @@
+//! `contains_call_markup`: telling text that is really a tool call apart from
+//! an answer.
+
+use crate::parse::contains_call_markup;
+
+/// What `DeepSeek` V4 returned in place of a summary when its request declared
+/// no tools (captured from the `OpenHuman` harness benchmark).
+const DSML: &str = "<||DSML|| calls>\n<||DSML|| invoke name=\"shell\">\n\
+<||DSML|| parameter name=\"command\" string=\"true\">cd /app && cat src/lib.rs||DSML|| parameter>\n\
+||DSML|| invoke>\n||DSML|| calls>";
+
+#[test]
+fn a_dsml_call_is_markup() {
+ assert!(contains_call_markup(DSML));
+ assert!(contains_call_markup(&format!(
+ "Let me check first.\n\n{DSML}"
+ )));
+}
+
+#[test]
+fn tagged_and_invoke_calls_are_markup() {
+ assert!(contains_call_markup(
+ "{\"name\":\"shell\",\"arguments\":{\"command\":\"ls\"}}"
+ ));
+ assert!(contains_call_markup(
+ "a.rs"
+ ));
+}
+
+#[test]
+fn a_block_that_does_not_decode_still_counts() {
+ // Opened and never closed: truncated mid-call.
+ assert!(contains_call_markup(
+ "<||DSML|| calls>\n<||DSML|| invoke name=\"shell\">\n<||DSML|| parameter name=\"command\""
+ ));
+}
+
+#[test]
+fn prose_a_bare_json_answer_and_a_quoted_example_are_not_markup() {
+ assert!(!contains_call_markup(
+ "## Goal\nShip the parser.\n\n## Active State\nConfig is {\"retries\": 3}."
+ ));
+ assert!(!contains_call_markup(
+ "{\"name\": \"shell\", \"arguments\": {\"command\": \"ls\"}}"
+ ));
+ assert!(!contains_call_markup(
+ "The format is:\n```xml\nls\n```"
+ ));
+ assert!(!contains_call_markup(""));
+}
diff --git a/crates/tinytools-agent/src/parse/test/mod.rs b/crates/tinytools-agent/src/parse/test/mod.rs
index 3705ce3..09bacd2 100644
--- a/crates/tinytools-agent/src/parse/test/mod.rs
+++ b/crates/tinytools-agent/src/parse/test/mod.rs
@@ -8,6 +8,7 @@ mod function_call;
mod glm;
mod harmony_mistral;
mod invoke_xml;
+mod markup;
mod regressions;
mod sentinel;
mod tagged;