From 9cc7d705d211cf852141e4b586b7d2086e5c8c20 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 20 Sep 2026 11:00:04 +0300 Subject: [PATCH 1/6] chore(deps): update submodule pointers for vendor and wiki Updated the pinned commits for the tinyinference, tinytools, and wiki submodules to incorporate upstream changes. Auto-committed-on: dragonfly Co-authored-by: Medulla --- vendor/tinyinference | 2 +- vendor/tinytools | 2 +- wiki | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/vendor/tinyinference b/vendor/tinyinference index 2c3ff818f..aeaecda26 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit 2c3ff818f55f0644970eeaeee51b60cb9fa10100 +Subproject commit aeaecda26677161713b3a993872813e2a3594c21 diff --git a/vendor/tinytools b/vendor/tinytools index e347becd7..9ae1d44de 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit e347becd7b83bd9a07e65b69b4294c485fc86e26 +Subproject commit 9ae1d44de00863bfff5e3549063d14d57db15c21 diff --git a/wiki b/wiki index 6c8cf71e1..5b8f5927c 160000 --- a/wiki +++ b/wiki @@ -1 +1 @@ -Subproject commit 6c8cf71e1312d350439dc243ac6e09273ec90d25 +Subproject commit 5b8f5927caa2ba0730985e686bf5ef5550e53e6d From 57d73875916496dbee473f975251302091241c7d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 20 Sep 2026 11:03:36 +0300 Subject: [PATCH 2/6] feat(tool): add prompt test module for tool harness Introduce a new prompt test module within the tool harness crate to support testing tool prompts in isolation. This module provides the necessary infrastructure for validating prompt behavior without requiring full integration tests. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/tool/prompt_test.rs | 679 ++++++++++++++++++ 1 file changed, 679 insertions(+) create mode 100644 crates/tinyagents-harness/src/tool/prompt_test.rs diff --git a/crates/tinyagents-harness/src/tool/prompt_test.rs b/crates/tinyagents-harness/src/tool/prompt_test.rs new file mode 100644 index 000000000..e9c069598 --- /dev/null +++ b/crates/tinyagents-harness/src/tool/prompt_test.rs @@ -0,0 +1,679 @@ +//! Tests for the prompt-guided tool-call protocol. + +use super::*; +use tinyinference_llm::message::{ContentBlock, ImageRef, Message}; +use tinyinference_llm::model::ModelResponse; + +fn schema(name: &str) -> ToolSchema { + ToolSchema { + name: name.to_string(), + description: format!("{name} description"), + parameters: serde_json::json!({"type": "object"}), + format: Default::default(), + } +} + +#[test] +fn prompt_instructions_list_each_tool() { + let text = prompt_tool_instructions(&[schema("read_file"), schema("write_file")]); + assert!(text.contains("## Tool Use Protocol")); + assert!(text.contains("")); + assert!(text.contains("**read_file**")); + assert!(text.contains("**write_file**")); +} + +#[test] +fn prompt_instructions_append_to_system() { + let msgs = vec![Message::system("You are helpful."), Message::user("hi")]; + let out = with_prompt_tool_instructions(&msgs, &[schema("read_file")]); + assert_eq!(out.len(), 2); + let Message::System(system) = &out[0] else { + panic!("first message should stay system") + }; + let joined: String = system + .content + .iter() + .filter_map(|block| match block { + ContentBlock::Text(text) => Some(text.as_str()), + _ => None, + }) + .collect(); + assert!(joined.contains("You are helpful.")); + assert!(joined.contains("Tool Use Protocol")); +} + +#[test] +fn prompt_instructions_insert_system_when_absent() { + let msgs = vec![Message::user("hi")]; + let out = with_prompt_tool_instructions(&msgs, &[schema("read_file")]); + assert_eq!(out.len(), 2); + assert!(matches!(out[0], Message::System(_))); +} + +#[test] +fn empty_tools_leave_messages_unchanged() { + let msgs = vec![Message::user("hi")]; + assert_eq!(with_prompt_tool_instructions(&msgs, &[]), msgs); +} + +#[test] +fn prompt_results_coalesce_consecutive_tool_messages() { + let messages = vec![ + Message::user("question"), + Message::assistant("calling tools"), + Message::tool("call-1", "first"), + Message::tool("call-2", "second"), + Message::assistant("done"), + ]; + + let out = coalesce_prompt_tool_results(&messages); + + assert_eq!(out.len(), 4); + assert!(matches!(out[0], Message::User(_))); + assert!(matches!(out[1], Message::Assistant(_))); + assert!(matches!(out[2], Message::User(_))); + assert_eq!( + out[2].text(), + "[Tool results]\n\nfirst\n\n\nsecond\n" + ); + assert!(matches!(out[3], Message::Assistant(_))); +} + +#[test] +fn prompt_result_coalescing_without_tools_is_identity() { + let messages = vec![Message::system("system"), Message::user("question")]; + assert_eq!(coalesce_prompt_tool_results(&messages), messages); +} + +#[test] +fn user_turn_normalization_leaves_a_real_query_alone() { + let messages = vec![ + Message::system("system"), + Message::user("question"), + Message::assistant("answer"), + ]; + assert_eq!(ensure_resolvable_user_turn(&messages), messages); +} + +#[test] +fn user_turn_normalization_inserts_after_leading_system_turns() { + // openhuman#5291: the real user turn aged out of the window, leaving a + // system prompt and an assistant continuation. Qwen 3's template raises + // `No user query found in messages.` on exactly this shape. + let messages = vec![ + Message::system("system"), + Message::system("tool protocol"), + Message::assistant("continuing"), + ]; + + let out = ensure_resolvable_user_turn(&messages); + + assert_eq!(out.len(), 4); + assert!(matches!(out[0], Message::System(_))); + assert!(matches!(out[1], Message::System(_))); + assert!(matches!(out[2], Message::User(_)), "user turn is inserted"); + assert!(matches!(out[3], Message::Assistant(_))); +} + +#[test] +fn user_turn_normalization_does_not_count_folded_tool_results() { + // The only user-role turns are coalesced tool results, which is not a query + // the template can answer — the model asked for those itself. + let coalesced = coalesce_prompt_tool_results(&[ + Message::system("system"), + Message::assistant("calling"), + Message::tool("call-1", "result"), + ]); + assert!( + coalesced.iter().any(|m| matches!(m, Message::User(_))), + "coalescing produces a user-role turn" + ); + + let out = ensure_resolvable_user_turn(&coalesced); + + assert_eq!(out.len(), coalesced.len() + 1); + assert!(matches!(out[1], Message::User(_))); + assert!(!out[1].text().starts_with("[Tool results]")); +} + +#[test] +fn user_turn_normalization_ignores_a_blank_user_turn() { + let messages = vec![Message::system("system"), Message::user(" ")]; + let out = ensure_resolvable_user_turn(&messages); + assert_eq!(out.len(), 3); + assert!(!out[1].text().trim().is_empty()); +} + +#[test] +fn user_turn_normalization_accepts_a_non_text_user_turn() { + // An image-only turn carries no text but is still a real user input. + let mut messages = vec![Message::system("system"), Message::user("")]; + let Message::User(user) = &mut messages[1] else { + unreachable!() + }; + user.content = vec![ContentBlock::Image(ImageRef { + url: "https://example.invalid/a.png".to_string(), + mime_type: None, + })]; + + assert_eq!(ensure_resolvable_user_turn(&messages), messages); +} + +#[test] +fn user_turn_normalization_inserts_first_when_there_is_no_system_turn() { + let messages = vec![Message::assistant("continuing")]; + let out = ensure_resolvable_user_turn(&messages); + assert_eq!(out.len(), 2); + assert!(matches!(out[0], Message::User(_))); +} + +#[test] +fn prompt_replay_renders_assistant_calls_before_results() { + let mut assistant = Message::assistant("I will inspect both files."); + let Message::Assistant(message) = &mut assistant else { + unreachable!() + }; + message.tool_calls = vec![ + ToolCall::new("call-1", "read_file", serde_json::json!({"path":"a.txt"})), + ToolCall::new("call-2", "read_file", serde_json::json!({"path":"b.txt"})), + ]; + let messages = vec![ + Message::user("compare them"), + assistant, + Message::tool("call-1", "first"), + Message::tool("call-2", "second"), + ]; + + let out = coalesce_prompt_tool_results(&messages); + + let Message::Assistant(replayed) = &out[1] else { + panic!("assistant call turn should remain an assistant turn") + }; + assert!(replayed.tool_calls.is_empty()); + assert!(out[1].text().contains("I will inspect both files.")); + assert!( + out[1].text().contains( + r#"{"arguments":{"path":"a.txt"},"name":"read_file"}"# + ) + ); + assert!( + out[1].text().contains( + r#"{"arguments":{"path":"b.txt"},"name":"read_file"}"# + ) + ); + assert!( + out[2] + .text() + .contains("\nfirst\n") + ); + assert!( + out[2] + .text() + .contains("\nsecond\n") + ); +} + +#[test] +fn prompt_parser_extracts_single_tool_call() { + let text = r#"Let me read it. + +{"name": "read_file", "arguments": {"path": "a.txt"}} +"#; + let (cleaned, calls) = parse_prompt_tool_calls_from_text(text); + assert_eq!(cleaned, "Let me read it."); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "read_file"); + // Ids are process-unique, not a per-response index: only the shape and the + // slot suffix are stable. See `next_synthetic_call_id`. + assert!( + calls[0] + .id + .starts_with(&format!("{SYNTHETIC_CALL_ID_PREFIX}_")), + "unexpected synthetic id {}", + calls[0].id + ); + assert!( + calls[0].id.ends_with("_1"), + "slot suffix lost: {}", + calls[0].id + ); + assert_eq!(calls[0].arguments, serde_json::json!({"path": "a.txt"})); +} + +#[test] +fn prompt_parser_extracts_multiple_calls_and_keeps_prose() { + let text = r#"a{"name":"one","arguments":{}}b{"name":"two","arguments":{"x":1}}c"#; + let (cleaned, calls) = parse_prompt_tool_calls_from_text(text); + assert_eq!(cleaned, "abc"); + assert_eq!(calls.len(), 2); + assert_eq!(calls[0].name, "one"); + assert_eq!(calls[1].name, "two"); + assert!( + calls[1].id.ends_with("_2"), + "slot suffix lost: {}", + calls[1].id + ); + assert_ne!(calls[0].id, calls[1].id); +} + +#[test] +fn prompt_parser_defaults_missing_arguments_to_empty_object() { + let (_, calls) = + parse_prompt_tool_calls_from_text(r#"{"name":"noargs"}"#); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].arguments, serde_json::json!({})); +} + +#[test] +fn prompt_parser_drops_malformed_block() { + let (cleaned, calls) = parse_prompt_tool_calls_from_text("not jsondone"); + assert!(calls.is_empty()); + assert_eq!(cleaned, "done"); +} + +#[test] +fn prompt_parser_keeps_unterminated_block_as_text() { + let text = "text {\"name\":\"x\"}"; + let (cleaned, calls) = parse_prompt_tool_calls_from_text(text); + assert!(calls.is_empty()); + assert_eq!(cleaned, "text {\"name\":\"x\"}"); +} + +#[test] +fn prompt_parser_returns_plain_text_verbatim() { + let (cleaned, calls) = parse_prompt_tool_calls_from_text("just a normal answer"); + assert!(calls.is_empty()); + assert_eq!(cleaned, "just a normal answer"); +} + +// --- Attribute / variant-tolerant matching (Hermes / DeepSeek templates) --- + +#[test] +fn prompt_parser_matches_attribute_form_open_tag() { + // Regression for the exact-literal miss: `` must match so a + // native model that leaks the call as text doesn't dump raw markup. + let text = r#"{"name":"foo","arguments":{"a":1}}"#; + let (cleaned, calls) = parse_prompt_tool_calls_from_text(text); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "foo"); + assert_eq!(calls[0].arguments, serde_json::json!({"a": 1})); + assert!(cleaned.is_empty()); + assert!(!cleaned.contains("{"name":"foo","arguments":{}}"#; + let (_, calls) = parse_prompt_tool_calls_from_text(text); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "foo"); +} + +#[test] +fn prompt_parser_matches_deepseek_delimiters() { + let text = "<|tool▁call▁begin|>{\"name\":\"foo\",\"arguments\":{}}<|tool▁call▁end|>"; + let (cleaned, calls) = parse_prompt_tool_calls_from_text(text); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "foo"); + assert!(cleaned.is_empty()); +} + +#[test] +fn prompt_parser_drops_attribute_form_no_name_body_without_leak() { + // The reported bug: `{}` has no `name`. + // It must be dropped — never echoed back as assistant content. + let text = "prefix \n{}\n suffix"; + let (cleaned, calls) = parse_prompt_tool_calls_from_text(text); + assert!(calls.is_empty()); + assert!(!cleaned.contains("` must not be mistaken for an opening ``. + let text = "see the list"; + let (cleaned, calls) = parse_prompt_tool_calls_from_text(text); + assert!(calls.is_empty()); + assert_eq!(cleaned, "see the list"); +} + +#[test] +fn prompt_parser_does_not_misparse_prose_open_tag_without_close() { + let text = "You can emit a block to call a tool."; + let (cleaned, calls) = parse_prompt_tool_calls_from_text(text); + assert!(calls.is_empty()); + assert_eq!(cleaned, text); +} + +// --- apply_prompt_tool_calls recovery + native-mode fallback gating --- + +#[test] +fn apply_prompt_tool_calls_recovers_attribute_markup() { + // A native model emitted the call as text with EMPTY structured tool_calls: + // recovery yields a structured call and the raw markup does NOT survive. + let resp = tinyinference_llm::model::ModelResponse::assistant( + r#"{"name":"foo","arguments":{}}"#, + ); + let out = apply_prompt_tool_calls(resp); + assert_eq!(out.message.tool_calls.len(), 1); + assert_eq!(out.message.tool_calls[0].name, "foo"); + assert!(!out.text().contains(" String { + let mut s = ToolCallStreamScrubber::new(); + let mut out = String::new(); + for f in fragments { + out.push_str(&s.feed(f)); + } + out.push_str(&s.flush()); + out +} + +#[test] +fn scrubber_passes_plain_text_through_unchanged() { + // No markup: the concatenated emissions equal the input exactly. + assert_eq!(scrub_all(&["hello ", "world", " done"]), "hello world done"); +} + +#[test] +fn scrubber_drops_a_complete_block_in_one_fragment() { + let out = scrub_all(&[r#"before {"name":"x","arguments":{}} after"#]); + assert_eq!(out, "before after"); +} + +#[test] +fn scrubber_suppresses_markup_split_across_fragments() { + // The open tag, body, and close arrive in separate fragments — no raw markup + // may appear in any emission, and the surrounding prose survives. + let out = scrub_all(&[ + "answer: ", + "{\"name\":\"x\",", + "\"arguments\":{}} end", + ]); + assert_eq!(out, "answer: end"); + assert!(!out.contains("{\"name\":\"x\",\"arguments\":{}}!"); + assert_eq!(second, "!"); + assert_eq!(s.flush(), ""); +} + +#[test] +fn scrubber_handles_attribute_open_form_split() { + // The Hermes/DeepSeek attribute form `` split mid-tag. + let out = scrub_all(&[ + "ok ", + "{\"name\":\"x\",\"arguments\":{}}", + ]); + assert_eq!(out, "ok "); +} + +#[test] +fn scrubber_handles_deepseek_delimiters_split() { + let out = scrub_all(&[ + "r ", + "<|tool▁call▁be", + "gin|>{\"name\":\"x\",\"arguments\":{}}<|tool▁call▁end|>", + " s", + ]); + assert_eq!(out, "r s"); + assert!(!out.contains("tool▁call")); +} + +#[test] +fn scrubber_does_not_hold_plural_tool_calls_prose() { + // `` (name not delimiter-terminated) is prose, not an open tag. + assert_eq!( + scrub_all(&["see below"]), + "see below" + ); +} + +#[test] +fn scrubber_flush_surfaces_a_dangling_open_verbatim_untrimmed() { + // A `{"name":"a","arguments":{}} mid {"name":"b","arguments":{"k":1}} tail"#; + let (batch, calls) = parse_prompt_tool_calls_from_text(full); + assert_eq!(calls.len(), 2); + // Fragment the input into single-byte-ish chunks at char boundaries. + let frags: Vec = full.chars().map(|c| c.to_string()).collect(); + let refs: Vec<&str> = frags.iter().map(String::as_str).collect(); + assert_eq!(scrub_all(&refs).trim(), batch); +} + +#[test] +fn apply_prompt_tool_calls_preserves_a_leading_thinking_block() { + // A prompt-guided reasoning model emits a `Thinking` block followed by the + // `` text. Recovering the call must not discard the reasoning. + let mut response = ModelResponse::assistant( + r#"reply {"name":"search","arguments":{"q":"x"}}"#, + ); + response.message.content.insert( + 0, + ContentBlock::Thinking { + text: "chain of thought".to_string(), + signature: None, + }, + ); + + let out = apply_prompt_tool_calls(response); + + assert_eq!(out.message.tool_calls.len(), 1); + assert_eq!(out.message.tool_calls[0].name, "search"); + assert_eq!( + out.message.content[0], + ContentBlock::Thinking { + text: "chain of thought".to_string(), + signature: None, + }, + "the thinking block must survive the content rebuild" + ); + assert_eq!( + out.message.content[1], + ContentBlock::Text("reply".to_string()) + ); +} +// --------------------------------------------------------------------------- +// Bare (undelimited) tool calls +// +// Captured from `llama3.2:3b` via Ollama with `tool_choice: "required"`: the +// model puts the call in `content` instead of the wire's `tool_calls` array, +// with no `` markup and frequently with malformed JSON. +// --------------------------------------------------------------------------- + +#[test] +fn apply_prompt_tool_calls_recovers_a_bare_object_with_relaxed_json() { + // The exact capture: `parameters'` and `{'city'` use mismatched quotes, so + // strict JSON rejects it outright. + let resp = tinyinference_llm::model::ModelResponse::assistant( + r#"{"name":"get_weather","parameters':{'city':"Paris"}}"#, + ); + let out = apply_prompt_tool_calls(resp); + + assert_eq!(out.message.tool_calls.len(), 1); + assert_eq!(out.message.tool_calls[0].name, "get_weather"); + assert_eq!( + out.message.tool_calls[0].arguments, + serde_json::json!({ "city": "Paris" }) + ); + // The raw markup must not also survive as prose, or the user sees the JSON. + assert!( + out.text().is_empty(), + "the consumed object should not remain as text: {}", + out.text() + ); +} + +#[test] +fn apply_prompt_tool_calls_recovers_a_bare_object_inside_a_code_fence() { + let resp = tinyinference_llm::model::ModelResponse::assistant( + "```json\n{\"name\":\"get_weather\",\"arguments\":{\"city\":\"Paris\"}}\n```", + ); + let out = apply_prompt_tool_calls(resp); + + assert_eq!(out.message.tool_calls.len(), 1); + assert_eq!(out.message.tool_calls[0].name, "get_weather"); +} + +#[test] +fn a_tool_call_object_may_name_its_arguments_parameters() { + let resp = tinyinference_llm::model::ModelResponse::assistant( + r#"{"name":"get_weather","parameters":{"city":"Paris"}}"#, + ); + let out = apply_prompt_tool_calls(resp); + + assert_eq!(out.message.tool_calls.len(), 1); + assert_eq!( + out.message.tool_calls[0].arguments, + serde_json::json!({ "city": "Paris" }) + ); +} + +#[test] +fn bare_object_recovery_never_swallows_a_genuine_text_answer() { + // Prose, prose that merely quotes JSON, a JSON object that names no tool, + // and a bare JSON scalar must all pass through untouched. + for text in [ + "The weather in Paris is mild today.", + r#"You could send {"name":"get_weather"} to that endpoint."#, + r#"{"city":"Paris","temperature":17}"#, + r#"{"name":42}"#, + r#""just a string""#, + "[1, 2, 3]", + ] { + let out = apply_prompt_tool_calls(tinyinference_llm::model::ModelResponse::assistant(text)); + assert!( + out.message.tool_calls.is_empty(), + "{text:?} must not be recovered as a tool call" + ); + assert_eq!(out.text(), text, "{text:?} must survive as text"); + } +} + +#[test] +fn bare_tool_call_recovery_preserves_a_thinking_block() { + // A local *reasoning* model emits its chain of thought and then the bare + // call object as the whole visible text. Consuming the object must not take + // the reasoning with it. + let mut response = ModelResponse::assistant(r#"{"name":"search","arguments":{"q":"x"}}"#); + response.message.content.insert( + 0, + ContentBlock::Thinking { + text: "chain of thought".to_string(), + signature: None, + }, + ); + + let out = apply_prompt_tool_calls(response); + + assert_eq!(out.message.tool_calls.len(), 1); + assert_eq!(out.message.tool_calls[0].name, "search"); + assert_eq!( + out.message.content, + vec![ContentBlock::Thinking { + text: "chain of thought".to_string(), + signature: None, + }], + "the reasoning must survive while the consumed object does not" + ); +} + +/// TOOL-2: two turns of the same run must not both mint `call_1`. +/// +/// The recovered id used to be the call's index *within one response*, which +/// resets every turn. A two-turn run therefore produced a transcript with two +/// assistant messages declaring the same tool-call id and two tool messages +/// answering it — a pairing no provider (and no pairing repair) can resolve. +#[test] +fn synthetic_call_ids_are_unique_across_responses() { + let text = r#"{"name":"one","arguments":{}}"#; + let (_, first) = parse_prompt_tool_calls_from_text(text); + let (_, second) = parse_prompt_tool_calls_from_text(text); + + assert_eq!(first.len(), 1); + assert_eq!(second.len(), 1); + assert_ne!( + first[0].id, second[0].id, + "a second turn reused the first turn's synthetic tool-call id" + ); +} + +/// The synthetic scheme must be visibly distinct from real provider ids and +/// from the OpenAI adapter's own positional fallback (`tool-{slot}`), so the +/// two can never collide. +#[test] +fn synthetic_call_ids_do_not_look_like_provider_ids() { + let id = next_synthetic_call_id(1); + assert!(id.starts_with("ptc_"), "{id}"); + assert!(!id.starts_with("call_"), "{id}"); + assert!(!id.starts_with("tool-"), "{id}"); +} + +/// The bare-object recovery path mints ids from the same counter, so a model +/// that alternates between markup and bare objects still cannot collide. +#[test] +fn bare_object_recovery_also_mints_unique_ids() { + let body = r#"{"name":"one","arguments":{}}"#; + let first = apply_prompt_tool_calls(ModelResponse::assistant(body)); + let second = apply_prompt_tool_calls(ModelResponse::assistant(body)); + + let first_id = &first.message.tool_calls[0].id; + let second_id = &second.message.tool_calls[0].id; + assert_ne!(first_id, second_id); +} + From 3ce714e3cd4f93dba3e86122dac5e704f3e2114f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 01:14:49 +0300 Subject: [PATCH 3/6] Bump TinyTools for DSML parser fixes --- vendor/tinytools | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinytools b/vendor/tinytools index 9ae1d44de..8ed823b07 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit 9ae1d44de00863bfff5e3549063d14d57db15c21 +Subproject commit 8ed823b0704e9456d14ea59be532fb3ce5a18c1c From 8f6fb8001d751a3d97f4f29a0819c7a0b8ee6dbb Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 01:29:32 +0300 Subject: [PATCH 4/6] Bump TinyTools dependency requirements --- Cargo.lock | 38 +++++++++++++++---- crates/tinyagents-graph/Cargo.toml | 2 +- crates/tinyagents-harness/Cargo.toml | 4 +- .../tinyagents-integration-tests/Cargo.toml | 2 +- crates/tinyagents-registry/Cargo.toml | 2 +- crates/tinyagents-runtime/Cargo.toml | 2 +- 6 files changed, 36 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4d25d8cf8..a8c72f4b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1476,7 +1476,7 @@ dependencies = [ "tinyagents-harness", "tinyagents-tracing", "tinyinference-llm", - "tinytools", + "tinytools 0.4.1", "tokio", ] @@ -1506,8 +1506,8 @@ dependencies = [ "tinyagents-tracing", "tinyinference-embeddings", "tinyinference-llm", - "tinytools", - "tinytools-agent", + "tinytools 0.4.1", + "tinytools-agent 0.4.1", "tokio", "uuid", "wait-timeout", @@ -1535,7 +1535,7 @@ dependencies = [ "tinyagents-session", "tinyinference-embeddings", "tinyinference-llm", - "tinytools", + "tinytools 0.4.1", "tokio", ] @@ -1570,7 +1570,7 @@ dependencies = [ "tinyagents-definition", "tinyagents-harness", "tinyinference-llm", - "tinytools", + "tinytools 0.4.1", "tokio", ] @@ -1587,7 +1587,7 @@ dependencies = [ "tinyagents-harness", "tinyagents-session", "tinyinference-llm", - "tinytools", + "tinytools 0.4.1", "tokio", ] @@ -1652,7 +1652,7 @@ dependencies = [ "sha2", "thiserror", "tinyinference-core", - "tinytools-agent", + "tinytools-agent 0.3.0", "tokio", "tracing", ] @@ -1670,6 +1670,17 @@ dependencies = [ [[package]] name = "tinytools" version = "0.3.0" +source = "git+https://github.com/tinyhumansai/tinytools?rev=a5d04a7f3af0abc1dd748f87f94871b36ae9fe71#a5d04a7f3af0abc1dd748f87f94871b36ae9fe71" +dependencies = [ + "anyhow", + "async-trait", + "serde", + "serde_json", +] + +[[package]] +name = "tinytools" +version = "0.4.1" dependencies = [ "anyhow", "async-trait", @@ -1680,11 +1691,22 @@ dependencies = [ [[package]] name = "tinytools-agent" version = "0.3.0" +source = "git+https://github.com/tinyhumansai/tinytools?rev=a5d04a7f3af0abc1dd748f87f94871b36ae9fe71#a5d04a7f3af0abc1dd748f87f94871b36ae9fe71" +dependencies = [ + "regex", + "serde", + "serde_json", + "tinytools 0.3.0", +] + +[[package]] +name = "tinytools-agent" +version = "0.4.1" dependencies = [ "regex", "serde", "serde_json", - "tinytools", + "tinytools 0.4.1", "tracing", ] diff --git a/crates/tinyagents-graph/Cargo.toml b/crates/tinyagents-graph/Cargo.toml index adebe6cd4..8743f807a 100644 --- a/crates/tinyagents-graph/Cargo.toml +++ b/crates/tinyagents-graph/Cargo.toml @@ -17,7 +17,7 @@ serde_json = "1" tinyagents-harness = { path = "../tinyagents-harness", version = "2.1.2", default-features = false } tinyagents-tracing = { path = "../tinyagents-tracing", version = "2.1.2", default-features = false } tinyinference-llm = { path = "../../vendor/tinyinference/crates/tinyinference-llm", version = "0.3.0" } -tinytools = { path = "../../vendor/tinytools/crates/tinytools", version = "0.3.0" } +tinytools = { path = "../../vendor/tinytools/crates/tinytools", version = "0.4.1" } tokio = { version = "1", default-features = false, features = ["sync", "time", "macros", "rt", "rt-multi-thread", "fs"] } [features] diff --git a/crates/tinyagents-harness/Cargo.toml b/crates/tinyagents-harness/Cargo.toml index 9fec13f30..337c2577b 100644 --- a/crates/tinyagents-harness/Cargo.toml +++ b/crates/tinyagents-harness/Cargo.toml @@ -28,10 +28,10 @@ sha2 = "0.11" thiserror = "2" tinyagents-tracing = { path = "../tinyagents-tracing", version = "2.1.2", default-features = false } tinyagents-definition = { path = "../tinyagents-definition", version = "2.1.2" } -tinytools-agent = { path = "../../vendor/tinytools/crates/tinytools-agent", version = "0.3.0", default-features = false } +tinytools-agent = { path = "../../vendor/tinytools/crates/tinytools-agent", version = "0.4.1", default-features = false } tinyinference-llm = { path = "../../vendor/tinyinference/crates/tinyinference-llm", version = "0.3.0" } tinyinference-embeddings = { path = "../../vendor/tinyinference/crates/tinyinference-embeddings", version = "0.3.0" } -tinytools = { path = "../../vendor/tinytools/crates/tinytools", version = "0.3.0" } +tinytools = { path = "../../vendor/tinytools/crates/tinytools", version = "0.4.1" } tokio = { version = "1", default-features = false, features = ["sync", "time", "macros", "rt", "rt-multi-thread", "fs", "io-util", "process"] } tempfile = "3" wait-timeout = "0.2" diff --git a/crates/tinyagents-integration-tests/Cargo.toml b/crates/tinyagents-integration-tests/Cargo.toml index a9171de1c..ebed7f783 100644 --- a/crates/tinyagents-integration-tests/Cargo.toml +++ b/crates/tinyagents-integration-tests/Cargo.toml @@ -25,7 +25,7 @@ tinyagents-registry = { path = "../tinyagents-registry" } tinyagents-session = { path = "../tinyagents-session" } tinyinference-llm = { path = "../../vendor/tinyinference/crates/tinyinference-llm", version = "0.3.0" } tinyinference-embeddings = { path = "../../vendor/tinyinference/crates/tinyinference-embeddings", version = "0.3.0" } -tinytools = { path = "../../vendor/tinytools/crates/tinytools", version = "0.3.0" } +tinytools = { path = "../../vendor/tinytools/crates/tinytools", version = "0.4.1" } tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "test-util"] } [features] diff --git a/crates/tinyagents-registry/Cargo.toml b/crates/tinyagents-registry/Cargo.toml index d3f39fe74..4cad972a2 100644 --- a/crates/tinyagents-registry/Cargo.toml +++ b/crates/tinyagents-registry/Cargo.toml @@ -14,7 +14,7 @@ serde_json = "1" tinyagents-definition = { path = "../tinyagents-definition", version = "2.1.2" } tinyagents-harness = { path = "../tinyagents-harness", version = "2.1.2", default-features = false } tinyinference-llm = { path = "../../vendor/tinyinference/crates/tinyinference-llm", version = "0.3.0" } -tinytools = { path = "../../vendor/tinytools/crates/tinytools", version = "0.3.0" } +tinytools = { path = "../../vendor/tinytools/crates/tinytools", version = "0.4.1" } [features] default = [] diff --git a/crates/tinyagents-runtime/Cargo.toml b/crates/tinyagents-runtime/Cargo.toml index d4cbf889e..f894a07f5 100644 --- a/crates/tinyagents-runtime/Cargo.toml +++ b/crates/tinyagents-runtime/Cargo.toml @@ -14,7 +14,7 @@ thiserror = "2" tinyagents-harness = { path = "../tinyagents-harness", version = "2.1.2" } tinyagents-session = { path = "../tinyagents-session", version = "2.1.2" } tinyinference-llm = { path = "../../vendor/tinyinference/crates/tinyinference-llm", version = "0.3.0" } -tinytools = { path = "../../vendor/tinytools/crates/tinytools", version = "0.3.0" } +tinytools = { path = "../../vendor/tinytools/crates/tinytools", version = "0.4.1" } tokio = { version = "1", features = ["macros", "rt", "sync"] } [dev-dependencies] From 6f39b42c63ff97195e25a12156e83a6881f1aa6c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 03:21:21 +0300 Subject: [PATCH 5/6] test: avoid synthetic call ID sequence coupling Co-authored-by: Medulla --- .../tinyagents-harness/src/tool/prompt_test.rs | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/crates/tinyagents-harness/src/tool/prompt_test.rs b/crates/tinyagents-harness/src/tool/prompt_test.rs index 4db62a4dd..0a10471cf 100644 --- a/crates/tinyagents-harness/src/tool/prompt_test.rs +++ b/crates/tinyagents-harness/src/tool/prompt_test.rs @@ -238,8 +238,8 @@ fn prompt_parser_extracts_single_tool_call() { assert_eq!(cleaned, "Let me read it."); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "read_file"); - // Ids are process-unique, not a per-response index: only the shape and the - // slot suffix are stable. See `next_synthetic_call_id`. + // Ids are process-unique, so only their protocol-specific prefix is part + // of this parser test's contract. Uniqueness is covered separately. assert!( calls[0] .id @@ -247,11 +247,6 @@ fn prompt_parser_extracts_single_tool_call() { "unexpected synthetic id {}", calls[0].id ); - assert!( - calls[0].id.ends_with("_1"), - "slot suffix lost: {}", - calls[0].id - ); assert_eq!(calls[0].arguments, serde_json::json!({"path": "a.txt"})); } @@ -263,11 +258,8 @@ fn prompt_parser_extracts_multiple_calls_and_keeps_prose() { assert_eq!(calls.len(), 2); assert_eq!(calls[0].name, "one"); assert_eq!(calls[1].name, "two"); - assert!( - calls[1].id.ends_with("_2"), - "slot suffix lost: {}", - calls[1].id - ); + assert!(calls[0].id.starts_with(SYNTHETIC_CALL_ID_PREFIX)); + assert!(calls[1].id.starts_with(SYNTHETIC_CALL_ID_PREFIX)); assert_ne!(calls[0].id, calls[1].id); } From dec2cf40ec7742218e223e9fca6010440767d3b7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 03:33:07 +0300 Subject: [PATCH 6/6] chore: retrigger PR description review Co-authored-by: Medulla