From 841a5265ace41d72be92711c87cc9e9254a4ac9d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 05:22:45 +0530 Subject: [PATCH 01/10] chore(deps): update tinytools submodule Bump the vendored tinytools submodule to commit 705677c, incorporating upstream fixes and improvements. Auto-committed-on: macbook --- vendor/tinytools | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinytools b/vendor/tinytools index 9ae1d44de..705677c82 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit 9ae1d44de00863bfff5e3549063d14d57db15c21 +Subproject commit 705677c820843d48e37791b156ac56ad44a54b6f From 1d826520f26e04eb41476608a11ac4ea447a75a0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 05:24:04 +0530 Subject: [PATCH 02/10] feat(harness): add Python and TypeScript code-style tool dialects Add opt-in `Python` and `Typescript` tool dispatcher variants that render tool schemas as code-style signatures in the system prompt, sharing P-Format's positional argument registry. Also skip appending the protocol block when the host has already rendered one, avoiding duplicate prompt content and token waste. Auto-committed-on: macbook --- .../src/agent_loop/dialect.rs | 73 ++++++++++++++++--- crates/tinyagents-harness/src/config/test.rs | 2 + crates/tinyagents-harness/src/config/types.rs | 11 +++ .../tinyagents-harness/src/runtime/types.rs | 14 ++-- 4 files changed, 84 insertions(+), 16 deletions(-) diff --git a/crates/tinyagents-harness/src/agent_loop/dialect.rs b/crates/tinyagents-harness/src/agent_loop/dialect.rs index 6b3f3f2dc..96d1cda98 100644 --- a/crates/tinyagents-harness/src/agent_loop/dialect.rs +++ b/crates/tinyagents-harness/src/agent_loop/dialect.rs @@ -15,7 +15,7 @@ use std::sync::Arc; use tinyinference_llm::message::ContentBlock; use tinyinference_llm::model::{ModelRequest, ModelResponse, ToolChoice}; use tinyinference_llm::tool::{ToolCall, ToolSchema}; -use tinytools_agent::dialect::PFormatDialect; +use tinytools_agent::dialect::{CodeDialect, CodeStyle, PFormatDialect}; use tinytools_agent::types::{ParseOptions, ParsedToolCall}; use tinytools_agent::{PFormatRegistry, StreamScrubber}; @@ -34,8 +34,16 @@ pub(super) enum RunDialect { Xml, /// Positional P-Format, rendered into the system prompt by the host. PFormat(Arc), + /// Code-style calls against Python or TypeScript signatures, rendered + /// into the system prompt by the host. Shares P-Format's registry: both + /// bind positional arguments against the same layout. + Code(CodeStyle, Arc), } +/// The heading every text protocol block starts with. When the host has +/// already put one in the system prompt, the run must not add a second. +const PROTOCOL_HEADING: &str = "## Tool Use Protocol"; + impl RunDialect { /// Resolves the policy against the tools this run offers. pub(super) fn resolve( @@ -47,11 +55,11 @@ impl RunDialect { ToolDispatcher::Auto if native_tool_calling == Some(false) => Self::Xml, ToolDispatcher::Auto | ToolDispatcher::Native => Self::Native, ToolDispatcher::Xml => Self::Xml, - ToolDispatcher::Pformat => Self::PFormat(Arc::new(tinytools_agent::build_registry( - tools - .iter() - .map(|schema| (schema.name.clone(), schema.parameters.clone())), - ))), + ToolDispatcher::Pformat => Self::PFormat(Arc::new(registry_from(tools))), + ToolDispatcher::Python => Self::Code(CodeStyle::Python, Arc::new(registry_from(tools))), + ToolDispatcher::Typescript => { + Self::Code(CodeStyle::TypeScript, Arc::new(registry_from(tools))) + } } } @@ -73,7 +81,7 @@ impl RunDialect { /// new tool this turn) a cheap `Arc::clone`. pub(super) fn registry_for(&self, tools: &[ToolSchema]) -> Option> { match self { - Self::PFormat(registry) => { + Self::PFormat(registry) | Self::Code(_, registry) => { let extra: Vec<&ToolSchema> = tools .iter() .filter(|schema| !registry.contains_key(&schema.name)) @@ -106,11 +114,26 @@ impl RunDialect { let tools = std::mem::take(&mut request.tools); let messages = prompt_tools::coalesce_tool_results(&request.messages); let messages = prompt_tools::ensure_resolvable_user_turn(&messages); + // A host that composes its own system prompt (OpenHuman's + // `ToolsSection`) has already rendered the protocol block and the + // catalogue there. Appending the run's copy on top would put two + // protocol blocks — and, for XML, two full-schema catalogues — in + // front of the model on every turn, which is exactly the token cost + // a text dialect exists to avoid. The schemas still come off the + // wire above; only the prompt rewrite is skipped. + if host_rendered_protocol(&messages) { + tracing::debug!( + "[agent_loop] system prompt already carries a tool protocol block; not appending" + ); + request.messages = messages; + request.tool_choice = ToolChoice::Auto; + return; + } request.messages = match self { Self::Xml | Self::Native => { prompt_tools::with_tool_instructions(&messages, &tools, &request.tool_choice) } - Self::PFormat(_) => { + Self::PFormat(_) | Self::Code(..) => { let specs: Vec = tools .iter() .map(|schema| tinytools_agent::tinytools::ToolSpec { @@ -119,8 +142,20 @@ impl RunDialect { parameters: schema.parameters.clone(), }) .collect(); - let mut block = PFormatDialect::instructions(); - block.push_str(&tinytools_agent::render::render_pformat_catalogue(&specs)); + let mut block = match self { + Self::Code(style, _) => { + let mut block = CodeDialect::instructions(*style); + block.push_str(&tinytools_agent::render::render_code_catalogue( + &specs, *style, + )); + block + } + _ => { + let mut block = PFormatDialect::instructions(); + block.push_str(&tinytools_agent::render::render_pformat_catalogue(&specs)); + block + } + }; // The XML branch renders `tool_choice` into its instructions // via `prompt_tools::tool_instructions`; P-Format has no // schema on the wire either (the wire choice is reset to @@ -145,6 +180,24 @@ impl RunDialect { } } +/// Builds the positional layout registry the P-Format and code dialects +/// bind against, from the schemas offered this run. +fn registry_from(tools: &[ToolSchema]) -> PFormatRegistry { + tinytools_agent::build_registry( + tools + .iter() + .map(|schema| (schema.name.clone(), schema.parameters.clone())), + ) +} + +/// Whether a system message already carries a text-dialect protocol block. +fn host_rendered_protocol(messages: &[tinyinference_llm::message::Message]) -> bool { + messages + .iter() + .filter(|message| matches!(message, tinyinference_llm::message::Message::System(_))) + .any(|message| message.text().contains(PROTOCOL_HEADING)) +} + /// What a model call needs in order to recover text-dialect calls: the /// tools that were offered (which a text-dialect request no longer carries /// on the wire) and the P-Format registry, when there is one. diff --git a/crates/tinyagents-harness/src/config/test.rs b/crates/tinyagents-harness/src/config/test.rs index ccb505437..a6ca4f40e 100644 --- a/crates/tinyagents-harness/src/config/test.rs +++ b/crates/tinyagents-harness/src/config/test.rs @@ -122,6 +122,8 @@ fn tool_dispatcher_round_trips_as_snake_case() { (ToolDispatcher::Native, "\"native\""), (ToolDispatcher::Xml, "\"xml\""), (ToolDispatcher::Pformat, "\"pformat\""), + (ToolDispatcher::Python, "\"python\""), + (ToolDispatcher::Typescript, "\"typescript\""), ] { let json = serde_json::to_string(&variant).expect("serializes"); assert_eq!(json, wire); diff --git a/crates/tinyagents-harness/src/config/types.rs b/crates/tinyagents-harness/src/config/types.rs index 45ecdbcaf..9d87765a6 100644 --- a/crates/tinyagents-harness/src/config/types.rs +++ b/crates/tinyagents-harness/src/config/types.rs @@ -71,6 +71,17 @@ pub enum ToolDispatcher { /// encoding, but it mis-parses on some models, so it is opt-in only and /// never selected by [`Auto`](Self::Auto). Pformat, + /// Force code-style calls with Python signatures in the prompt: + /// `def read_file(path: str, limit: int = None) -> str`, called as + /// `read_file(path="src/main.rs")`. Compact like P-Format but a syntax a + /// code-trained model already writes; opt-in, never selected by + /// [`Auto`](Self::Auto). + Python, + /// Force code-style calls with TypeScript signatures in the prompt: + /// `function read_file(path: string, limit?: number): string;`, called + /// as `read_file({path: "src/main.rs"})`. Opt-in, never selected by + /// [`Auto`](Self::Auto). + Typescript, } // ── RequiredOutput ──────────────────────────────────────────────────────────── diff --git a/crates/tinyagents-harness/src/runtime/types.rs b/crates/tinyagents-harness/src/runtime/types.rs index 5945e4987..f01f6a110 100644 --- a/crates/tinyagents-harness/src/runtime/types.rs +++ b/crates/tinyagents-harness/src/runtime/types.rs @@ -255,11 +255,12 @@ pub struct RunPolicy { /// [`ToolDispatcher::Auto`] (the default) sends tool schemas on the wire /// and lets the provider adapter decide — the OpenAI-compatible adapter /// switches to the JSON-in-tag protocol by itself for a profile without - /// native tool calling. [`ToolDispatcher::Xml`] and - /// [`ToolDispatcher::Pformat`] force a text protocol regardless of - /// provider: the schemas are rendered into the system prompt, nothing goes - /// on the wire as `tools`, and the answer is parsed here. P-Format is the - /// cheapest on tokens and the most demanding on the model, which is why + /// native tool calling. [`ToolDispatcher::Xml`], [`ToolDispatcher::Pformat`], + /// [`ToolDispatcher::Python`] and [`ToolDispatcher::Typescript`] force a + /// text protocol regardless of provider: the schemas are rendered into the + /// system prompt, nothing goes on the wire as `tools`, and the answer is + /// parsed here. P-Format is the cheapest on tokens and the most demanding + /// on the model, and the code dialects sit between it and JSON, which is why /// it is opt-in only. /// /// Under a forced text dialect the answer is always read through every @@ -314,7 +315,8 @@ pub struct RunPolicy { /// Whether the loop parses ``-style text-dialect markup out of /// an assistant's visible text under a native tool dialect (see /// [`RunPolicy::tool_dialect`]). A forced text dialect - /// ([`ToolDispatcher::Xml`] / [`ToolDispatcher::Pformat`], or + /// ([`ToolDispatcher::Xml`] / [`ToolDispatcher::Pformat`] / + /// [`ToolDispatcher::Python`] / [`ToolDispatcher::Typescript`], or /// [`ToolDispatcher::Auto`] falling back to Xml for a model without /// native tool calling) always parses the answer regardless of this /// policy, since the model can only answer in text. From 47f7954b6b0b82e7135db802fea4d6d566b472d9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 05:24:32 +0530 Subject: [PATCH 03/10] test(agent): add tests for code tool dialects Added tests covering Python and TypeScript code dialects, verifying that schemas are stripped from the wire and signatures are rendered into the system prompt. Also added a test ensuring a host-rendered protocol block is not duplicated when the dialect rewrite runs. Auto-committed-on: macbook --- .../src/agent_loop/dialect/test.rs | 38 ++++++ .../tests/e2e_tool_dialects.rs | 119 ++++++++++++++++++ 2 files changed, 157 insertions(+) diff --git a/crates/tinyagents-harness/src/agent_loop/dialect/test.rs b/crates/tinyagents-harness/src/agent_loop/dialect/test.rs index f229cec9a..30fa93808 100644 --- a/crates/tinyagents-harness/src/agent_loop/dialect/test.rs +++ b/crates/tinyagents-harness/src/agent_loop/dialect/test.rs @@ -15,3 +15,41 @@ fn auto_uses_xml_when_the_model_disables_native_tool_calling() { assert!(matches!(dialect, RunDialect::Xml)); } + +#[test] +fn code_dialects_are_opt_in_and_share_the_positional_registry() { + use tinyinference_llm::tool::ToolSchema; + use tinytools_agent::dialect::CodeStyle; + + let schema = ToolSchema::new( + "lookup", + "Looks something up.", + serde_json::json!({ + "type": "object", + "properties": {"q": {"type": "string"}}, + "required": ["q"] + }), + ); + let tools = [schema]; + + // Auto never picks a code dialect, even without native tool calling. + assert!(matches!( + RunDialect::resolve(ToolDispatcher::Auto, &tools, Some(false)), + RunDialect::Xml + )); + + let python = RunDialect::resolve(ToolDispatcher::Python, &tools, Some(false)); + let RunDialect::Code(style, registry) = &python else { + panic!("expected a code dialect, got {python:?}"); + }; + assert_eq!(*style, CodeStyle::Python); + assert!(registry.contains_key("lookup")); + assert!(python.is_text()); + assert!(python.registry_for(&tools).is_some()); + + let typescript = RunDialect::resolve(ToolDispatcher::Typescript, &tools, Some(true)); + assert!(matches!( + typescript, + RunDialect::Code(CodeStyle::TypeScript, _) + )); +} diff --git a/crates/tinyagents-integration-tests/tests/e2e_tool_dialects.rs b/crates/tinyagents-integration-tests/tests/e2e_tool_dialects.rs index aa7a31ad6..d0e0e35a6 100644 --- a/crates/tinyagents-integration-tests/tests/e2e_tool_dialects.rs +++ b/crates/tinyagents-integration-tests/tests/e2e_tool_dialects.rs @@ -294,6 +294,125 @@ async fn a_forced_pformat_dialect_parses_positional_calls() { assert!(system.contains("lookup[0|]"), "{system}"); } +#[tokio::test] +async fn a_forced_python_dialect_parses_code_calls_with_signatures_in_the_prompt() { + let model = Arc::new(ScriptedModel::replies(vec![ + "Looking it up.\n\nlookup(q=\"needle\")\n", + "done", + ])); + let listener = Arc::new(RecordingListener::new()); + let mut harness: AgentHarness<()> = AgentHarness::new(); + harness + .register_model("mock", model.clone()) + .set_default_model("mock") + .register_tool(Arc::new(Lookup)) + .push_middleware(Arc::new(CaptureMiddleware { + listener: listener.clone(), + })) + .with_policy(RunPolicy { + tool_dialect: ToolDispatcher::Python, + ..RunPolicy::default() + }); + + let run = harness + .invoke_default(&(), vec![Message::user("go")]) + .await + .expect("run succeeds"); + assert_eq!(run.tool_calls, 1); + let ids = dispatched_ids(&listener); + assert_eq!(ids.len(), 1); + assert!(ids[0].ends_with("-tool-1"), "harness-minted id, got {}", ids[0]); + + let first = &model.requests()[0]; + assert!(first.tools.is_empty(), "schemas must not go on the wire"); + let system = first + .messages + .iter() + .find(|m| matches!(m, Message::System(_))) + .expect("system") + .text(); + assert!(system.contains("## Tool Use Protocol"), "{system}"); + assert!(system.contains("def lookup(q: str) -> str # Looks something up."), "{system}"); + assert!(!system.contains("\"type\": \"object\""), "no JSON schema: {system}"); +} + +#[tokio::test] +async fn a_forced_typescript_dialect_parses_object_calls() { + let model = Arc::new(ScriptedModel::replies(vec![ + "lookup({q: \"needle\"})", + "done", + ])); + let mut harness: AgentHarness<()> = AgentHarness::new(); + harness + .register_model("mock", model.clone()) + .set_default_model("mock") + .register_tool(Arc::new(Lookup)) + .with_policy(RunPolicy { + tool_dialect: ToolDispatcher::Typescript, + ..RunPolicy::default() + }); + + let run = harness + .invoke_default(&(), vec![Message::user("go")]) + .await + .expect("run succeeds"); + assert_eq!(run.tool_calls, 1); + let system = model.requests()[0] + .messages + .iter() + .find(|m| matches!(m, Message::System(_))) + .expect("system") + .text(); + assert!( + system.contains("function lookup(q: string): string; // Looks something up."), + "{system}" + ); +} + +#[tokio::test] +async fn a_host_rendered_protocol_block_is_not_appended_twice() { + // OpenHuman composes the protocol block and the catalogue into its own + // system prompt. The run must still strip the schemas off the wire but + // must not put a second block in front of the model. + let model = Arc::new(ScriptedModel::replies(vec![ + "lookup(q=\"needle\")", + "done", + ])); + let mut harness: AgentHarness<()> = AgentHarness::new(); + harness + .register_model("mock", model.clone()) + .set_default_model("mock") + .register_tool(Arc::new(Lookup)) + .with_policy(RunPolicy { + tool_dialect: ToolDispatcher::Python, + ..RunPolicy::default() + }); + + let host_prompt = "You are a helper.\n\n## Tool Use Protocol\n\nHost-rendered block.\n\n## Tools\n\ndef lookup(q: str) -> str"; + let run = harness + .invoke_default( + &(), + vec![Message::system(host_prompt), Message::user("go")], + ) + .await + .expect("run succeeds"); + assert_eq!(run.tool_calls, 1, "the call still parses"); + let first = &model.requests()[0]; + assert!(first.tools.is_empty(), "schemas still come off the wire"); + let system = first + .messages + .iter() + .find(|m| matches!(m, Message::System(_))) + .expect("system") + .text(); + assert_eq!( + system.matches("## Tool Use Protocol").count(), + 1, + "one protocol block, not two: {system}" + ); + assert!(!system.contains("Call a tool by writing"), "{system}"); +} + /// Middleware that forces `tool_choice` before the dialect rewrite runs, the /// same shape a caller or another middleware forcing a specific tool would /// produce. From 0576a4695f82e9f2e0fa52ee8d4ea961b18ff9a0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 05:28:10 +0530 Subject: [PATCH 04/10] chore: format long assert and invocation expressions Reformatted multi-line assert macros and the invoke_default call to comply with rustfmt line-width rules, wrapping expressions that exceeded the configured maximum line length. No behavior or test logic was changed. Auto-committed-on: macbook --- .../tests/e2e_tool_dialects.rs | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/crates/tinyagents-integration-tests/tests/e2e_tool_dialects.rs b/crates/tinyagents-integration-tests/tests/e2e_tool_dialects.rs index d0e0e35a6..4d0db6574 100644 --- a/crates/tinyagents-integration-tests/tests/e2e_tool_dialects.rs +++ b/crates/tinyagents-integration-tests/tests/e2e_tool_dialects.rs @@ -321,7 +321,11 @@ async fn a_forced_python_dialect_parses_code_calls_with_signatures_in_the_prompt assert_eq!(run.tool_calls, 1); let ids = dispatched_ids(&listener); assert_eq!(ids.len(), 1); - assert!(ids[0].ends_with("-tool-1"), "harness-minted id, got {}", ids[0]); + assert!( + ids[0].ends_with("-tool-1"), + "harness-minted id, got {}", + ids[0] + ); let first = &model.requests()[0]; assert!(first.tools.is_empty(), "schemas must not go on the wire"); @@ -332,8 +336,14 @@ async fn a_forced_python_dialect_parses_code_calls_with_signatures_in_the_prompt .expect("system") .text(); assert!(system.contains("## Tool Use Protocol"), "{system}"); - assert!(system.contains("def lookup(q: str) -> str # Looks something up."), "{system}"); - assert!(!system.contains("\"type\": \"object\""), "no JSON schema: {system}"); + assert!( + system.contains("def lookup(q: str) -> str # Looks something up."), + "{system}" + ); + assert!( + !system.contains("\"type\": \"object\""), + "no JSON schema: {system}" + ); } #[tokio::test] @@ -390,10 +400,7 @@ async fn a_host_rendered_protocol_block_is_not_appended_twice() { let host_prompt = "You are a helper.\n\n## Tool Use Protocol\n\nHost-rendered block.\n\n## Tools\n\ndef lookup(q: str) -> str"; let run = harness - .invoke_default( - &(), - vec![Message::system(host_prompt), Message::user("go")], - ) + .invoke_default(&(), vec![Message::system(host_prompt), Message::user("go")]) .await .expect("run succeeds"); assert_eq!(run.tool_calls, 1, "the call still parses"); From 68b0eb0abc490541979d9fa2d869f7b4ee0b2189 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 05:28:48 +0530 Subject: [PATCH 05/10] docs(harness): document the new code dialects and dialect selection The tool dialect documentation now covers the newly added `CodeDialect` for Python and TypeScript call syntax, alongside the existing XML, P-Format, and native dialects. It also explains how the agent loop selects a dialect per run via `RunPolicy::tool_dialect`, including the automatic fallback to XML when native is unsupported, and clarifies that P-Format and code dialects are opt-in while the loop strips pre-composed schemas without duplicating the tool protocol block. Auto-committed-on: macbook --- docs/modules/harness/tool-dialect.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/modules/harness/tool-dialect.md b/docs/modules/harness/tool-dialect.md index 67fa36a42..85389db7c 100644 --- a/docs/modules/harness/tool-dialect.md +++ b/docs/modules/harness/tool-dialect.md @@ -74,14 +74,22 @@ that produces no error anywhere — the model emits a call, nothing recognises i and the iteration is spent. So they live behind a single trait, and a dialect is chosen once rather than assembled from parts that can disagree. -Three ship: +Four ship: | Dialect | Call syntax | Catalogue | Specs in the request | | --- | --- | --- | --- | | `XmlDialect` | `{"name":…,"arguments":{…}}` | full schemas, in its own protocol block | no | -| `PFormatDialect` | `name[a\|b]` | signatures, in the prompt's tool section | no | +| `PFormatDialect` | `name[0\|a\|1\|b]` | signatures, in the prompt's tool section | no | +| `CodeDialect` | `name(a="x", b=1)` (Python) or `name({a: "x", b: 1})` (TypeScript) | `def name(a: str, b: int = None) -> str` / `function name(a: string, b?: number): string;` signatures, in the prompt's tool section | no | | `NativeDialect` | the provider's structured channel | none — the request carries the specs | yes | +The agent loop selects one per run from `RunPolicy::tool_dialect` +(`ToolDispatcher::{Auto, Native, Xml, Pformat, Python, Typescript}`). `Auto` +resolves to native when the model profile supports it and to XML otherwise; +P-Format and the code dialects are opt-in. When a host has already composed a +`## Tool Use Protocol` block into the system prompt, the loop strips the +schemas off the wire but does not append a second block. + ## Which surface to use Use `tinytools_agent::dialect` directly for dialect selection, formatting, and From 1c0684887c02f51279e841ecce0f410955a0e4f2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 05:08:57 +0300 Subject: [PATCH 06/10] chore: files changed vendor/tinytools Auto-committed-on: dragonfly Co-authored-by: Medulla --- vendor/tinytools | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinytools b/vendor/tinytools index 705677c82..9ae1d44de 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit 705677c820843d48e37791b156ac56ad44a54b6f +Subproject commit 9ae1d44de00863bfff5e3549063d14d57db15c21 From 3ed92d4be3dc27f83bd9bb003c0f483241afcfba Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 05:09:11 +0300 Subject: [PATCH 07/10] chore(deps): update tinytools subproject commit Update the vendored tinytools subproject to a newer commit, incorporating upstream changes. Auto-committed-on: dragonfly Co-authored-by: Medulla --- vendor/tinytools | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinytools b/vendor/tinytools index 9ae1d44de..705677c82 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit 9ae1d44de00863bfff5e3549063d14d57db15c21 +Subproject commit 705677c820843d48e37791b156ac56ad44a54b6f From 135b2c70304eb8bc484e45d58e18f2b1f9f8254c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 05:10:31 +0300 Subject: [PATCH 08/10] fix(harness): always append authoritative tool protocol block The previous logic skipped appending the protocol block when the system prompt contained a matching heading, but this could suppress the canonical block when the heading was merely mentioned in user-controlled text. The loop now always renders its authoritative block from the final post-middleware tool set, ensuring the correct dialect and catalogue are presented regardless of prompt content. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/agent_loop/dialect.rs | 27 ------------------- .../tests/e2e_tool_dialects.rs | 19 +++++++------ docs/modules/harness/tool-dialect.md | 6 +++-- 3 files changed, 15 insertions(+), 37 deletions(-) diff --git a/crates/tinyagents-harness/src/agent_loop/dialect.rs b/crates/tinyagents-harness/src/agent_loop/dialect.rs index 96d1cda98..b68a4989a 100644 --- a/crates/tinyagents-harness/src/agent_loop/dialect.rs +++ b/crates/tinyagents-harness/src/agent_loop/dialect.rs @@ -40,10 +40,6 @@ pub(super) enum RunDialect { Code(CodeStyle, Arc), } -/// The heading every text protocol block starts with. When the host has -/// already put one in the system prompt, the run must not add a second. -const PROTOCOL_HEADING: &str = "## Tool Use Protocol"; - impl RunDialect { /// Resolves the policy against the tools this run offers. pub(super) fn resolve( @@ -114,21 +110,6 @@ impl RunDialect { let tools = std::mem::take(&mut request.tools); let messages = prompt_tools::coalesce_tool_results(&request.messages); let messages = prompt_tools::ensure_resolvable_user_turn(&messages); - // A host that composes its own system prompt (OpenHuman's - // `ToolsSection`) has already rendered the protocol block and the - // catalogue there. Appending the run's copy on top would put two - // protocol blocks — and, for XML, two full-schema catalogues — in - // front of the model on every turn, which is exactly the token cost - // a text dialect exists to avoid. The schemas still come off the - // wire above; only the prompt rewrite is skipped. - if host_rendered_protocol(&messages) { - tracing::debug!( - "[agent_loop] system prompt already carries a tool protocol block; not appending" - ); - request.messages = messages; - request.tool_choice = ToolChoice::Auto; - return; - } request.messages = match self { Self::Xml | Self::Native => { prompt_tools::with_tool_instructions(&messages, &tools, &request.tool_choice) @@ -190,14 +171,6 @@ fn registry_from(tools: &[ToolSchema]) -> PFormatRegistry { ) } -/// Whether a system message already carries a text-dialect protocol block. -fn host_rendered_protocol(messages: &[tinyinference_llm::message::Message]) -> bool { - messages - .iter() - .filter(|message| matches!(message, tinyinference_llm::message::Message::System(_))) - .any(|message| message.text().contains(PROTOCOL_HEADING)) -} - /// What a model call needs in order to recover text-dialect calls: the /// tools that were offered (which a text-dialect request no longer carries /// on the wire) and the P-Format registry, when there is one. diff --git a/crates/tinyagents-integration-tests/tests/e2e_tool_dialects.rs b/crates/tinyagents-integration-tests/tests/e2e_tool_dialects.rs index 4d0db6574..1c6c2e637 100644 --- a/crates/tinyagents-integration-tests/tests/e2e_tool_dialects.rs +++ b/crates/tinyagents-integration-tests/tests/e2e_tool_dialects.rs @@ -380,10 +380,11 @@ async fn a_forced_typescript_dialect_parses_object_calls() { } #[tokio::test] -async fn a_host_rendered_protocol_block_is_not_appended_twice() { - // OpenHuman composes the protocol block and the catalogue into its own - // system prompt. The run must still strip the schemas off the wire but - // must not put a second block in front of the model. +async fn an_untrusted_protocol_heading_does_not_suppress_the_current_catalogue() { + // A system prompt may mention the heading without containing the active + // dialect, the final post-middleware catalogue, or the effective tool + // choice. The loop must render its authoritative block from the request + // rather than treating user-controlled prompt text as provenance. let model = Arc::new(ScriptedModel::replies(vec![ "lookup(q=\"needle\")", "done", @@ -398,7 +399,8 @@ async fn a_host_rendered_protocol_block_is_not_appended_twice() { ..RunPolicy::default() }); - let host_prompt = "You are a helper.\n\n## Tool Use Protocol\n\nHost-rendered block.\n\n## Tools\n\ndef lookup(q: str) -> str"; + let host_prompt = + "You are a helper.\n\n## Tool Use Protocol\n\nThis heading is documentation, not a catalogue."; let run = harness .invoke_default(&(), vec![Message::system(host_prompt), Message::user("go")]) .await @@ -414,10 +416,11 @@ async fn a_host_rendered_protocol_block_is_not_appended_twice() { .text(); assert_eq!( system.matches("## Tool Use Protocol").count(), - 1, - "one protocol block, not two: {system}" + 2, + "the canonical protocol must be appended: {system}" ); - assert!(!system.contains("Call a tool by writing"), "{system}"); + assert!(system.contains("def lookup(q: str) -> str"), "{system}"); + assert!(system.contains("Call a tool by writing"), "{system}"); } /// Middleware that forces `tool_choice` before the dialect rewrite runs, the diff --git a/docs/modules/harness/tool-dialect.md b/docs/modules/harness/tool-dialect.md index 85389db7c..7bedd3bdc 100644 --- a/docs/modules/harness/tool-dialect.md +++ b/docs/modules/harness/tool-dialect.md @@ -87,8 +87,10 @@ The agent loop selects one per run from `RunPolicy::tool_dialect` (`ToolDispatcher::{Auto, Native, Xml, Pformat, Python, Typescript}`). `Auto` resolves to native when the model profile supports it and to XML otherwise; P-Format and the code dialects are opt-in. When a host has already composed a -`## Tool Use Protocol` block into the system prompt, the loop strips the -schemas off the wire but does not append a second block. +tool protocol into the system prompt, the loop still appends its authoritative +block from the final post-middleware tool set. A heading in arbitrary prompt +text cannot prove that the host block matches the selected dialect, current +catalogue, or effective tool choice. ## Which surface to use From f4d1cf23b6c732a128014d81e722e83c580368e2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 05:11:14 +0300 Subject: [PATCH 09/10] fix(e2e): collapse host prompt to one line The host prompt string was split across two lines, which is unnecessary and makes the test fixture harder to read. This change joins the string onto a single line without altering its content, keeping the test setup concise. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinyagents-integration-tests/tests/e2e_tool_dialects.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/tinyagents-integration-tests/tests/e2e_tool_dialects.rs b/crates/tinyagents-integration-tests/tests/e2e_tool_dialects.rs index 1c6c2e637..fe47b0cc6 100644 --- a/crates/tinyagents-integration-tests/tests/e2e_tool_dialects.rs +++ b/crates/tinyagents-integration-tests/tests/e2e_tool_dialects.rs @@ -399,8 +399,7 @@ async fn an_untrusted_protocol_heading_does_not_suppress_the_current_catalogue() ..RunPolicy::default() }); - let host_prompt = - "You are a helper.\n\n## Tool Use Protocol\n\nThis heading is documentation, not a catalogue."; + let host_prompt = "You are a helper.\n\n## Tool Use Protocol\n\nThis heading is documentation, not a catalogue."; let run = harness .invoke_default(&(), vec![Message::system(host_prompt), Message::user("go")]) .await From e0d3a968c35f7064c2e266ec51462ca6751fc743 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 05:20:20 +0300 Subject: [PATCH 10/10] docs(harness): document Python and Typescript tool-call dialects The ToolDispatcher enum now includes `Python` and `Typescript` as additional tool-call encoding strategies, alongside the existing `Auto`, `Native`, `Xml`, and `Pformat` variants. The documentation has been updated to reflect these new forced text dialects, which are used when parsing tool-call markup from assistant text in the runtime and when normalizing tool results in the harness. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinyagents-harness/src/config/README.md | 6 +++--- docs/modules/harness/runtime.md | 5 +++-- docs/modules/harness/tool.md | 4 ++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/tinyagents-harness/src/config/README.md b/crates/tinyagents-harness/src/config/README.md index d4f697c3b..e27ac325b 100644 --- a/crates/tinyagents-harness/src/config/README.md +++ b/crates/tinyagents-harness/src/config/README.md @@ -35,9 +35,9 @@ question by existing. necessarily available where these are applied) for memory injected into a turn. - [`ToolDispatcher`] — enum of tool-call encoding strategies (`Auto`, - `Native`, `Xml`, `Pformat`); modelled as an enum rather than a free-form - string so an unrecognised mode is a mapping error at the boundary, not a - silent fallthrough in the turn loop. + `Native`, `Xml`, `Pformat`, `Python`, `Typescript`); modelled as an enum + rather than a free-form string so an unrecognised mode is a mapping error at + the boundary, not a silent fallthrough in the turn loop. - [`RequiredOutput`] — a structured-output contract asserting the model's reply carries a particular JSON block; a blank `block_key` makes the contract inert by design, so it is a safe zero-value default. diff --git a/docs/modules/harness/runtime.md b/docs/modules/harness/runtime.md index 3d0595642..8da0324ed 100644 --- a/docs/modules/harness/runtime.md +++ b/docs/modules/harness/runtime.md @@ -144,8 +144,9 @@ Step 12's text-dialect recovery (parsing ``-style markup out of an assistant's visible text through the `tinytools-agent` grammars, both on the streamed deltas and on the terminal response) always runs under a forced text dialect (`RunPolicy::tool_dialect` of `Xml` / `Pformat`, or `Auto` -falling back to Xml for a model without native tool calling) — there, parsing -text is the protocol. Under a native dialect it is gated by +falling back to Xml for a model without native tool calling; `Python` and +`Typescript` are forced text dialects too) — there, parsing text is the +protocol. Under a native dialect it is gated by `RunPolicy::text_dialect_recovery` (`TextDialectRecovery::Off | On | Auto`, default `Auto`): it only runs when the resolved model's profile does not report native tool calling, and it always skips markup that appears only diff --git a/docs/modules/harness/tool.md b/docs/modules/harness/tool.md index fccb7c3d2..4614ac599 100644 --- a/docs/modules/harness/tool.md +++ b/docs/modules/harness/tool.md @@ -196,8 +196,8 @@ template by the serving runtime (LM Studio, llama.cpp, Ollama), so the outgoing message list has to satisfy that template, not just the wire schema. Three helpers in `tinyinference_llm::prompt_tools` normalize it, and both the OpenAI-compatible adapter (for a profile with `tool_calling = false`, or after a -"tools unsupported" 400) and the harness (for a forced `Xml` / `Pformat` -dialect) apply them: +"tools unsupported" 400) and the harness (for a forced `Xml`, `Pformat`, +`Python`, or `Typescript` dialect) apply them: - `coalesce_tool_results` renders assistant `tool_calls` back into `` text and folds consecutive `tool`-role results into one