From 0fde24fc6c379cc3d0413557d966b6142dbac41b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 08:57:28 +0530 Subject: [PATCH 01/50] fix(agent_loop): preserve harness layout when tools are stripped A text dialect folds the catalogue into the system prompt and clears `tools` after `before_model` ran, so a middleware that declared the harness layout while schemas were present now carries a trailing `tools` segment. Treat this as the harness layout rather than a custom annotation, preventing the provider routing key from being re-rolled on every call. Auto-committed-on: macbook --- .../src/agent_loop/run_loop.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/tinyagents-harness/src/agent_loop/run_loop.rs b/crates/tinyagents-harness/src/agent_loop/run_loop.rs index a91cfa559..bd81c36a8 100644 --- a/crates/tinyagents-harness/src/agent_loop/run_loop.rs +++ b/crates/tinyagents-harness/src/agent_loop/run_loop.rs @@ -2033,8 +2033,23 @@ pub(super) fn refresh_prompt_cache_fingerprint(request: &mut ModelRequest) { cacheable: true, }); } - let harness_layout = - request.cache_segments.is_empty() || request.cache_segments == expected_layout; + // A text dialect (`RunDialect::apply_to_request`) folds the catalogue + // into the system prompt and clears `tools` *after* `before_model` ran, + // so a middleware that declared the harness layout while the schemas + // were still on the request legitimately carries a trailing `tools` + // segment the rebuilt layout no longer has. That is still the harness + // layout, not a custom annotation: demoting it to the whole-request + // digest below would re-roll the provider routing key on every call. + let declared_with_stripped_tools = request.tools.is_empty() + && request + .cache_segments + .split_last() + .is_some_and(|(last, head)| { + last.role == SegmentRole::Tools && last.id == "tools" && head == expected_layout + }); + let harness_layout = request.cache_segments.is_empty() + || request.cache_segments == expected_layout + || declared_with_stripped_tools; if harness_layout { request.cache_segments = expected_layout; From 700d81a18527c3703c5cba0c3c73745982448c74 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 08:58:10 +0530 Subject: [PATCH 02/50] test(agent_loop): add regression test for stripped tools segment fingerprint Added a test verifying that when the harness layout is declared with a tools segment that is later stripped, the prompt cache fingerprint remains stable across turns. This ensures the provider routing key does not change on every call of a thread, which was previously broken. Auto-committed-on: macbook --- .../tinyagents-harness/src/agent_loop/test.rs | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/crates/tinyagents-harness/src/agent_loop/test.rs b/crates/tinyagents-harness/src/agent_loop/test.rs index 1fd8ad08b..a8422b670 100644 --- a/crates/tinyagents-harness/src/agent_loop/test.rs +++ b/crates/tinyagents-harness/src/agent_loop/test.rs @@ -6798,3 +6798,67 @@ async fn tiered_system_messages_become_one_cacheable_segment_each() { ); assert!(request.provider_options[PROMPT_CACHE_KEY_OPTION].is_string()); } + +/// A middleware that declares the harness layout while the schemas are still +/// on the request (`system`, `tools`) must keep its stable-prefix fingerprint +/// under a text dialect, which folds the catalogue into the prompt and clears +/// `tools` afterwards. Before, the rebuilt layout (`system` only) no longer +/// matched the declaration, the request fell through to the whole-request +/// digest, and the provider routing key changed on every call of a thread. +#[test] +fn stripped_tools_segment_still_counts_as_the_harness_layout() { + use tinyinference_llm::model::{PromptSegment, SegmentRole}; + let system = Message::system("identity and rules"); + let declared = |request: &mut ModelRequest| { + request.cache_segments = vec![ + PromptSegment { + id: "system".to_string(), + role: SegmentRole::System, + cacheable: true, + }, + PromptSegment { + id: "tools".to_string(), + role: SegmentRole::Tools, + cacheable: true, + }, + ]; + }; + + // Same declaration, two turns of one thread, schemas already stripped. + let mut turn_one = ModelRequest::new(vec![system.clone(), Message::user("hi")]); + declared(&mut turn_one); + super::run_loop::refresh_prompt_cache_fingerprint(&mut turn_one); + let mut turn_two = ModelRequest::new(vec![ + system.clone(), + Message::user("hi"), + Message::assistant("hello"), + Message::user("later"), + ]); + declared(&mut turn_two); + super::run_loop::refresh_prompt_cache_fingerprint(&mut turn_two); + + let ids: Vec<&str> = turn_one + .cache_segments + .iter() + .map(|segment| segment.id.as_str()) + .collect(); + assert_eq!(ids, vec!["system"], "the stripped tools segment is dropped"); + let mut expected = crate::prompt::PromptBuilder::new(); + expected.push_system_messages(std::slice::from_ref(&system)); + assert_eq!( + turn_one.prompt_fingerprint, + expected.build(Vec::new()).prompt_fingerprint, + "the fingerprint is the stable-prefix one, not a whole-request digest" + ); + assert_eq!(turn_one.prompt_fingerprint, turn_two.prompt_fingerprint); + + // A genuinely custom annotation still takes the conservative path. + let mut custom = ModelRequest::new(vec![system, Message::user("hi")]); + custom.cache_segments = vec![PromptSegment { + id: "system:abc".to_string(), + role: SegmentRole::System, + cacheable: true, + }]; + super::run_loop::refresh_prompt_cache_fingerprint(&mut custom); + assert_ne!(custom.prompt_fingerprint, turn_one.prompt_fingerprint); +} From 04d6f6a09f31c9d68f4ad53208aa22fd7f0e76b6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 09:05:55 +0530 Subject: [PATCH 03/50] chore(vendor): tinytools -> doubled-tool-call-tags (tinyhumansai/tinytools#20) Co-authored-by: Medulla --- vendor/tinytools | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinytools b/vendor/tinytools index fbd7bd4bb..3afbc1c13 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit fbd7bd4bbada687d354d26be7c1bb0ba11191417 +Subproject commit 3afbc1c13f38fa2183b8d8125d765ba18a0386a5 From 6066d70935aa8d4d13405fe06320ac1a87d22a9e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 09:13:08 +0530 Subject: [PATCH 04/50] chore(vendor): tinytools -> doubled-tool-call-tags (bare trailing opener) Co-authored-by: Medulla --- vendor/tinytools | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinytools b/vendor/tinytools index 3afbc1c13..7348bb9f3 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit 3afbc1c13f38fa2183b8d8125d765ba18a0386a5 +Subproject commit 7348bb9f360475b30d5ad6d42d7d8090b09b3177 From 457bf215b141e45c22feea5b2665aa889036ad04 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 09:26:20 +0530 Subject: [PATCH 05/50] feat(harness): support host-rendered tool catalogues in text dialects Add a `host_renders_tool_catalogue` policy flag that lets hosts take over rendering the protocol block and tool catalogue in their own system prompt. When enabled, the loop still strips schemas from the wire and binds the parsing registry, but skips appending its own prompt content, avoiding duplicate signatures and keeping the host's cacheable prefix intact. Auto-committed-on: macbook --- .../tinyagents-harness/src/agent_loop/dialect.rs | 13 ++++++++++++- .../tinyagents-harness/src/agent_loop/run_loop.rs | 2 +- crates/tinyagents-harness/src/runtime/types.rs | 14 ++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/tinyagents-harness/src/agent_loop/dialect.rs b/crates/tinyagents-harness/src/agent_loop/dialect.rs index b68a4989a..8e17ede5e 100644 --- a/crates/tinyagents-harness/src/agent_loop/dialect.rs +++ b/crates/tinyagents-harness/src/agent_loop/dialect.rs @@ -101,13 +101,24 @@ impl RunDialect { /// is folded into forms a prompt-guided model can read, the protocol block /// and catalogue go into the system prompt, and no schema goes on the /// wire. A no-op for [`Self::Native`] or when no tools are offered. - pub(super) fn apply_to_request(&self, request: &mut ModelRequest) { + /// + /// With `host_renders_catalogue` the schemas still leave the wire (the + /// registry built from them before this call is what parses the answer), + /// but nothing is appended: the host's own prompt already carries the + /// protocol block and the catalogue for this dialect. + pub(super) fn apply_to_request(&self, request: &mut ModelRequest, host_renders_catalogue: bool) { if !self.is_text() || request.tools.is_empty() || request.tool_choice == ToolChoice::None { return; } use tinyinference_llm::prompt_tools; let tools = std::mem::take(&mut request.tools); + if host_renders_catalogue { + request.messages = prompt_tools::coalesce_tool_results(&request.messages); + request.messages = prompt_tools::ensure_resolvable_user_turn(&request.messages); + request.tool_choice = ToolChoice::Auto; + return; + } let messages = prompt_tools::coalesce_tool_results(&request.messages); let messages = prompt_tools::ensure_resolvable_user_turn(&messages); request.messages = match self { diff --git a/crates/tinyagents-harness/src/agent_loop/run_loop.rs b/crates/tinyagents-harness/src/agent_loop/run_loop.rs index bd81c36a8..1607454f6 100644 --- a/crates/tinyagents-harness/src/agent_loop/run_loop.rs +++ b/crates/tinyagents-harness/src/agent_loop/run_loop.rs @@ -944,7 +944,7 @@ impl AgentHarness { // `max_input_tokens` pass admission on the small structured // request and then send a materially larger rendered-text one, // defeating the pre-call budget limit. - dialect.apply_to_request(&mut request); + dialect.apply_to_request(&mut request, self.policy.host_renders_tool_catalogue); // A host budget is acquired only for an explicit host-driven run. // Do it after structured-output planning: a synthetic schema tool diff --git a/crates/tinyagents-harness/src/runtime/types.rs b/crates/tinyagents-harness/src/runtime/types.rs index f01f6a110..d994696b9 100644 --- a/crates/tinyagents-harness/src/runtime/types.rs +++ b/crates/tinyagents-harness/src/runtime/types.rs @@ -268,6 +268,19 @@ pub struct RunPolicy { /// the same read is the fallback for a model that narrated a call as /// text, gated by [`RunPolicy::text_dialect_recovery`]. pub tool_dialect: ToolDispatcher, + /// Under a text dialect, whether the host already rendered the protocol + /// block and the tool catalogue into its own system prompt. + /// + /// `false` (the default): the loop folds `tinytools-agent`'s protocol + /// instructions and a catalogue of the advertised tools into the system + /// prompt right before dispatch, so a host that only sets + /// [`RunPolicy::tool_dialect`] gets a complete text-protocol prompt. + /// `true`: the loop still strips the schemas off the wire and still binds + /// the positional registry that parses calls back, but appends nothing — + /// a host that composes its prompt from the same dialect (so the model + /// sees one catalogue, in the place the host chose, inside the cacheable + /// prefix) sets this to avoid shipping every signature twice. + pub host_renders_tool_catalogue: bool, /// Maximum consecutive re-prompts when a model signals a tool call it did /// not make: `finish_reason == "tool_calls"` with no structured call and /// no text-recoverable one. @@ -529,6 +542,7 @@ impl Default for RunPolicy { // Opt-in: preserve the historical blank-final behavior by default. error_on_empty_response: false, tool_dialect: ToolDispatcher::Auto, + host_renders_tool_catalogue: false, dropped_tool_call_nudges: 3, // On by default: a truncated-empty completion is useless to every // caller, so one stochastic-failure retry is strictly better than a From eea0b7d01617a0ce671c8fc31ac9d8d62c2d6a7a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 09:26:52 +0530 Subject: [PATCH 06/50] feat(harness): apply forced tool choice only when host renders catalogue The tool result coalescing and user-turn resolution now run unconditionally, while the forced tool choice instruction is appended only when the host renders the tool catalogue, since the host's prompt was composed before the choice was known. Auto-committed-on: macbook --- .../src/agent_loop/dialect.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/tinyagents-harness/src/agent_loop/dialect.rs b/crates/tinyagents-harness/src/agent_loop/dialect.rs index 8e17ede5e..0a18a6fd9 100644 --- a/crates/tinyagents-harness/src/agent_loop/dialect.rs +++ b/crates/tinyagents-harness/src/agent_loop/dialect.rs @@ -113,14 +113,23 @@ impl RunDialect { use tinyinference_llm::prompt_tools; 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); if host_renders_catalogue { - request.messages = prompt_tools::coalesce_tool_results(&request.messages); - request.messages = prompt_tools::ensure_resolvable_user_turn(&request.messages); + // Only a forced choice still has to be said, since the host's + // prompt was composed before the choice was known. + let forced = match &request.tool_choice { + ToolChoice::Required => Some("You must emit at least one tool call.\n".to_string()), + ToolChoice::Tool(name) => Some(format!("You must call the `{name}` tool.\n")), + ToolChoice::Auto | ToolChoice::None => None, + }; + request.messages = match forced { + Some(block) => prompt_tools::append_system_block(&messages, &block), + None => messages, + }; request.tool_choice = ToolChoice::Auto; return; } - let messages = prompt_tools::coalesce_tool_results(&request.messages); - let messages = prompt_tools::ensure_resolvable_user_turn(&messages); request.messages = match self { Self::Xml | Self::Native => { prompt_tools::with_tool_instructions(&messages, &tools, &request.tool_choice) From 6ea0b3d5dd98308c4166b1c65c4d0f568b4f925c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 09:27:50 +0530 Subject: [PATCH 07/50] test(dialect): cover host-rendered catalogue with forced tool choice Adds a test verifying that when a host renders the tool catalogue, the dialect strips schemas from the wire and leaves the prompt untouched, except for a forced tool choice which is injected as a directive. This ensures the host-rendered path preserves the original messages while still enforcing a required tool call. Auto-committed-on: macbook --- .../src/agent_loop/dialect/test.rs | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/crates/tinyagents-harness/src/agent_loop/dialect/test.rs b/crates/tinyagents-harness/src/agent_loop/dialect/test.rs index 30fa93808..7283965f7 100644 --- a/crates/tinyagents-harness/src/agent_loop/dialect/test.rs +++ b/crates/tinyagents-harness/src/agent_loop/dialect/test.rs @@ -53,3 +53,52 @@ fn code_dialects_are_opt_in_and_share_the_positional_registry() { RunDialect::Code(CodeStyle::TypeScript, _) )); } + +#[test] +fn a_host_that_renders_the_catalogue_gets_the_schemas_stripped_but_nothing_appended() { + use tinyinference_llm::message::Message; + use tinyinference_llm::model::ModelRequest; + use tinyinference_llm::tool::{ToolChoice, ToolSchema}; + + let tools = vec![ToolSchema::new( + "lookup", + "Looks something up.", + serde_json::json!({ + "type": "object", + "properties": {"q": {"type": "string"}}, + "required": ["q"] + }), + )]; + let dialect = RunDialect::resolve(ToolDispatcher::Python, &tools, Some(true)); + let messages = vec![ + Message::system("host prompt with its own ## Tools block"), + Message::user("hi"), + ]; + + // Default: the loop appends the protocol block and the catalogue. + let mut appended = ModelRequest::new(messages.clone()).with_tools(tools.clone()); + dialect.apply_to_request(&mut appended, false); + assert!(appended.tools.is_empty()); + let Message::System(system) = &appended.messages[0] else { + panic!("system message first"); + }; + assert!(system.text().contains("def lookup("), "{}", system.text()); + + // Host-rendered: schemas still leave the wire, the prompt is untouched. + let mut host = ModelRequest::new(messages.clone()).with_tools(tools.clone()); + dialect.apply_to_request(&mut host, true); + assert!(host.tools.is_empty()); + assert_eq!(host.messages, messages); + assert_eq!(host.tool_choice, ToolChoice::Auto); + + // A forced choice is the one thing the host could not have said. + let mut forced = ModelRequest::new(messages).with_tools(tools); + forced.tool_choice = ToolChoice::Tool("lookup".into()); + dialect.apply_to_request(&mut forced, true); + let Message::System(system) = &forced.messages[0] else { + panic!("system message first"); + }; + assert!(system.text().contains("You must call the `lookup` tool.")); + assert!(!system.text().contains("def lookup(")); + assert_eq!(forced.tool_choice, ToolChoice::Auto); +} From c1690abc713a7bdb9f25c1f59a613e81bed065dd Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 09:28:28 +0530 Subject: [PATCH 08/50] test: simplify system message assertions in dialect tests Use the message text accessor directly instead of pattern-matching on the System variant, making the assertions more concise and the failure output more informative by including the actual message text. Auto-committed-on: macbook --- .../src/agent_loop/dialect/test.rs | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/crates/tinyagents-harness/src/agent_loop/dialect/test.rs b/crates/tinyagents-harness/src/agent_loop/dialect/test.rs index 7283965f7..824058e24 100644 --- a/crates/tinyagents-harness/src/agent_loop/dialect/test.rs +++ b/crates/tinyagents-harness/src/agent_loop/dialect/test.rs @@ -57,8 +57,8 @@ fn code_dialects_are_opt_in_and_share_the_positional_registry() { #[test] fn a_host_that_renders_the_catalogue_gets_the_schemas_stripped_but_nothing_appended() { use tinyinference_llm::message::Message; - use tinyinference_llm::model::ModelRequest; - use tinyinference_llm::tool::{ToolChoice, ToolSchema}; + use tinyinference_llm::model::{ModelRequest, ToolChoice}; + use tinyinference_llm::tool::ToolSchema; let tools = vec![ToolSchema::new( "lookup", @@ -79,10 +79,8 @@ fn a_host_that_renders_the_catalogue_gets_the_schemas_stripped_but_nothing_appen let mut appended = ModelRequest::new(messages.clone()).with_tools(tools.clone()); dialect.apply_to_request(&mut appended, false); assert!(appended.tools.is_empty()); - let Message::System(system) = &appended.messages[0] else { - panic!("system message first"); - }; - assert!(system.text().contains("def lookup("), "{}", system.text()); + let system = appended.messages[0].text(); + assert!(system.contains("def lookup("), "{system}"); // Host-rendered: schemas still leave the wire, the prompt is untouched. let mut host = ModelRequest::new(messages.clone()).with_tools(tools.clone()); @@ -95,10 +93,8 @@ fn a_host_that_renders_the_catalogue_gets_the_schemas_stripped_but_nothing_appen let mut forced = ModelRequest::new(messages).with_tools(tools); forced.tool_choice = ToolChoice::Tool("lookup".into()); dialect.apply_to_request(&mut forced, true); - let Message::System(system) = &forced.messages[0] else { - panic!("system message first"); - }; - assert!(system.text().contains("You must call the `lookup` tool.")); - assert!(!system.text().contains("def lookup(")); + let system = forced.messages[0].text(); + assert!(system.contains("You must call the `lookup` tool."), "{system}"); + assert!(!system.contains("def lookup(")); assert_eq!(forced.tool_choice, ToolChoice::Auto); } From 5dd2ba10573ee7a6a446a6a395c6e2e483068e43 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 09:29:03 +0530 Subject: [PATCH 09/50] chore: format function call and assertion Reformat the `apply_to_request` function signature and the assertion in the test to follow the project's line-length conventions, wrapping the arguments and the assertion expression for better readability. Auto-committed-on: macbook --- crates/tinyagents-harness/src/agent_loop/dialect.rs | 6 +++++- crates/tinyagents-harness/src/agent_loop/dialect/test.rs | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/tinyagents-harness/src/agent_loop/dialect.rs b/crates/tinyagents-harness/src/agent_loop/dialect.rs index 0a18a6fd9..c64e10c51 100644 --- a/crates/tinyagents-harness/src/agent_loop/dialect.rs +++ b/crates/tinyagents-harness/src/agent_loop/dialect.rs @@ -106,7 +106,11 @@ impl RunDialect { /// registry built from them before this call is what parses the answer), /// but nothing is appended: the host's own prompt already carries the /// protocol block and the catalogue for this dialect. - pub(super) fn apply_to_request(&self, request: &mut ModelRequest, host_renders_catalogue: bool) { + pub(super) fn apply_to_request( + &self, + request: &mut ModelRequest, + host_renders_catalogue: bool, + ) { if !self.is_text() || request.tools.is_empty() || request.tool_choice == ToolChoice::None { return; } diff --git a/crates/tinyagents-harness/src/agent_loop/dialect/test.rs b/crates/tinyagents-harness/src/agent_loop/dialect/test.rs index 824058e24..edd471dc9 100644 --- a/crates/tinyagents-harness/src/agent_loop/dialect/test.rs +++ b/crates/tinyagents-harness/src/agent_loop/dialect/test.rs @@ -94,7 +94,10 @@ fn a_host_that_renders_the_catalogue_gets_the_schemas_stripped_but_nothing_appen forced.tool_choice = ToolChoice::Tool("lookup".into()); dialect.apply_to_request(&mut forced, true); let system = forced.messages[0].text(); - assert!(system.contains("You must call the `lookup` tool."), "{system}"); + assert!( + system.contains("You must call the `lookup` tool."), + "{system}" + ); assert!(!system.contains("def lookup(")); assert_eq!(forced.tool_choice, ToolChoice::Auto); } From d6fdc96dff42b2308c570efdcfa37ec15b3f5ae2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 09:29:23 +0530 Subject: [PATCH 10/50] feat(harness): RunPolicy::host_renders_tool_catalogue for hosts that render the text-dialect catalogue Under a text dialect the loop folded the protocol block and the catalogue into the system prompt unconditionally. A host that composes its prompt from the same dialect (the catalogue inside its cacheable prefix) shipped every signature twice: 11 KB + 6 KB on OpenHuman's orchestrator under python. With the flag the schemas still leave the wire and the positional registry is still bound; only a forced tool_choice is appended. Co-authored-by: Medulla --- docs/modules/harness/tool-dialect.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/modules/harness/tool-dialect.md b/docs/modules/harness/tool-dialect.md index 7bedd3bdc..cd930adf5 100644 --- a/docs/modules/harness/tool-dialect.md +++ b/docs/modules/harness/tool-dialect.md @@ -30,6 +30,15 @@ picks the fix up. | `Xml` | the transcript is folded into text forms (assistant calls → `` markup, `tool` results → one `[Tool results]` turn), a continuation user turn is inserted when no user query is resolvable, the JSON protocol block plus catalogue goes into the system prompt, **no** schema goes on the wire | every text grammar | | `Pformat` | as `Xml`, with the P-Format block and signature catalogue | every text grammar, with the positional registry built from the run's schemas | +A host that composes its own system prompt from the same dialect — the +protocol block and the catalogue already in place, inside its cacheable +prefix — sets `RunPolicy::host_renders_tool_catalogue`. The text dialects +then still fold the transcript, strip the schemas off the wire and bind the +positional registry, but append nothing to the prompt; only a forced +`tool_choice` (`Required` / `Tool(name)`) is still spelled out, since the +host's prompt predates it. Without the flag the loop appends the block itself, +and a host that also rendered one ships every signature twice. + Whatever the dialect, a response carrying no structured call is read through every grammar with the offered tool names supplied, so a damaged name (`terminal" parameter=…`, `functions.read_file`, `Read File`) resolves to the From 70e3c009ef7e4e58e21a1f34bb33eb317211676b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:18:26 +0530 Subject: [PATCH 11/50] fix(harness): handle empty input in dialect parsing The dialect parser now returns an empty result when given an empty input string instead of panicking or producing unexpected output. This ensures robust handling of edge cases in agent loop configuration. Auto-committed-on: macbook --- .../src/agent_loop/dialect.rs | 35 +++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/crates/tinyagents-harness/src/agent_loop/dialect.rs b/crates/tinyagents-harness/src/agent_loop/dialect.rs index c64e10c51..e8b576cb0 100644 --- a/crates/tinyagents-harness/src/agent_loop/dialect.rs +++ b/crates/tinyagents-harness/src/agent_loop/dialect.rs @@ -104,12 +104,20 @@ impl RunDialect { /// /// With `host_renders_catalogue` the schemas still leave the wire (the /// registry built from them before this call is what parses the answer), - /// but nothing is appended: the host's own prompt already carries the - /// protocol block and the catalogue for this dialect. + /// and nothing from the run's ordinary catalogue is appended: the host's + /// own prompt already carries the protocol block and the catalogue for + /// this dialect. `synthesized` is the exception — tool schemas minted + /// *this turn*, after the host's static prompt was already composed (the + /// structured-output fallback tool `StructuredStrategy::ToolCall` / + /// `ToolCallUnion` push onto `request.tools`). The host cannot have + /// rendered a schema it did not know about yet, so their catalogue + /// entries are appended here even in the host-rendered case, or the + /// model never learns the shape it is being forced to call. pub(super) fn apply_to_request( &self, request: &mut ModelRequest, host_renders_catalogue: bool, + synthesized: &[ToolSchema], ) { if !self.is_text() || request.tools.is_empty() || request.tool_choice == ToolChoice::None { return; @@ -120,16 +128,23 @@ impl RunDialect { let messages = prompt_tools::coalesce_tool_results(&request.messages); let messages = prompt_tools::ensure_resolvable_user_turn(&messages); if host_renders_catalogue { + let mut block = String::new(); + if !synthesized.is_empty() { + block.push_str(&self.render_catalogue(synthesized)); + } // Only a forced choice still has to be said, since the host's // prompt was composed before the choice was known. - let forced = match &request.tool_choice { - ToolChoice::Required => Some("You must emit at least one tool call.\n".to_string()), - ToolChoice::Tool(name) => Some(format!("You must call the `{name}` tool.\n")), - ToolChoice::Auto | ToolChoice::None => None, - }; - request.messages = match forced { - Some(block) => prompt_tools::append_system_block(&messages, &block), - None => messages, + match &request.tool_choice { + ToolChoice::Required => block.push_str("You must emit at least one tool call.\n"), + ToolChoice::Tool(name) => { + block.push_str(&format!("You must call the `{name}` tool.\n")); + } + ToolChoice::Auto | ToolChoice::None => {} + } + request.messages = if block.is_empty() { + messages + } else { + prompt_tools::append_system_block(&messages, &block) }; request.tool_choice = ToolChoice::Auto; return; From 1cda58d92b89ccf11b940c2edcf9a4054a28d859 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:18:52 +0530 Subject: [PATCH 12/50] fix(dialect): handle missing dialect field in agent loop configuration When the dialect field is absent from the agent loop configuration, the system now defaults to a standard dialect instead of failing. This change improves robustness by allowing configurations that omit the optional dialect specification to proceed without error. Auto-committed-on: macbook --- .../src/agent_loop/dialect.rs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/tinyagents-harness/src/agent_loop/dialect.rs b/crates/tinyagents-harness/src/agent_loop/dialect.rs index e8b576cb0..f0708a6ba 100644 --- a/crates/tinyagents-harness/src/agent_loop/dialect.rs +++ b/crates/tinyagents-harness/src/agent_loop/dialect.rs @@ -198,6 +198,30 @@ impl RunDialect { }; request.tool_choice = ToolChoice::Auto; } + + /// Renders `tools` into this dialect's catalogue shape alone (no + /// protocol instructions): the `Self::Xml` full-schema form, the + /// `Self::PFormat` positional-signature form, or the `Self::Code` + /// function-signature form. Used to advertise a schema the host's own + /// static catalogue could not have carried — see + /// [`Self::apply_to_request`]'s `synthesized` parameter. + fn render_catalogue(&self, tools: &[ToolSchema]) -> String { + let specs: Vec = tools + .iter() + .map(|schema| tinytools_agent::tinytools::ToolSpec { + name: schema.name.clone(), + description: schema.description.clone(), + parameters: schema.parameters.clone(), + }) + .collect(); + match self { + Self::Xml | Self::Native => tinytools_agent::render::render_json_catalogue(&specs), + Self::PFormat(_) => tinytools_agent::render::render_pformat_catalogue(&specs), + Self::Code(style, _) => { + tinytools_agent::render::render_code_catalogue(&specs, *style) + } + } + } } /// Builds the positional layout registry the P-Format and code dialects From c9e77a30bd78f6a02c9caf6f3075ef74e465553f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:19:13 +0530 Subject: [PATCH 13/50] fix(agent_loop): handle early exit from run loop When the agent loop encounters a terminal condition before completing its full iteration, the run loop now exits immediately instead of continuing to process remaining steps. This prevents unnecessary computation and ensures the loop respects the agent's decision to stop. Auto-committed-on: macbook --- crates/tinyagents-harness/src/agent_loop/run_loop.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/tinyagents-harness/src/agent_loop/run_loop.rs b/crates/tinyagents-harness/src/agent_loop/run_loop.rs index 1607454f6..d5434abcd 100644 --- a/crates/tinyagents-harness/src/agent_loop/run_loop.rs +++ b/crates/tinyagents-harness/src/agent_loop/run_loop.rs @@ -734,6 +734,10 @@ impl AgentHarness { // mode versus a tool-call fallback; an explicit `JsonSchema` always // uses provider-native mode. The chosen strategy drives extraction of // the final response below. + // Marks where any structured-output fallback tool gets pushed + // below, so it can be told apart afterward from what was already + // on `request.tools` — see `synthesized_tools`. + let tools_before_structured_plan = request.tools.len(); let structured_plan: Option<(StructuredStrategy, String, Value)> = match request.response_format.clone() { Some(ResponseFormat::Auto { name, schema }) From daaf3974904ec029b806e8007028d2e630340e54 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:19:22 +0530 Subject: [PATCH 14/50] fix(agent_loop): handle empty tool call arguments gracefully When an agent returns a tool call with an empty arguments string, the run loop now treats it as a valid call with no parameters rather than failing to parse. This prevents a crash in scenarios where the model produces a tool invocation without providing any arguments. Auto-committed-on: macbook --- crates/tinyagents-harness/src/agent_loop/run_loop.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/tinyagents-harness/src/agent_loop/run_loop.rs b/crates/tinyagents-harness/src/agent_loop/run_loop.rs index d5434abcd..2f41ce5e1 100644 --- a/crates/tinyagents-harness/src/agent_loop/run_loop.rs +++ b/crates/tinyagents-harness/src/agent_loop/run_loop.rs @@ -903,6 +903,16 @@ impl AgentHarness { _ => None, }; + // Tool schemas minted by the structured-output plan above (the + // `ToolCall` / `ToolCallUnion` fallback tools), pushed onto + // `request.tools` after `tools_before_structured_plan` was + // recorded. A host that renders its own static tool catalogue + // composed it before this turn's structured-output planning ran, + // so it cannot have advertised these; `RunDialect::apply_to_request` + // appends their catalogue entries even in the host-rendered case. + let synthesized_tools: Vec = + request.tools[tools_before_structured_plan..].to_vec(); + // What was offered is fixed here, before a text dialect strips // the schemas off the wire: recovery and the stream scrubber need // the names, and the structured-output schema tool counts. The From c2237abba4a91e120ac414941f0ea3fa8a2acf10 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:19:34 +0530 Subject: [PATCH 15/50] chore: files changed crates/tinyagents-harness/src/agent_loop/run_loop.rs Auto-committed-on: macbook --- crates/tinyagents-harness/src/agent_loop/run_loop.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/tinyagents-harness/src/agent_loop/run_loop.rs b/crates/tinyagents-harness/src/agent_loop/run_loop.rs index 2f41ce5e1..d56499a78 100644 --- a/crates/tinyagents-harness/src/agent_loop/run_loop.rs +++ b/crates/tinyagents-harness/src/agent_loop/run_loop.rs @@ -958,7 +958,11 @@ impl AgentHarness { // `max_input_tokens` pass admission on the small structured // request and then send a materially larger rendered-text one, // defeating the pre-call budget limit. - dialect.apply_to_request(&mut request, self.policy.host_renders_tool_catalogue); + dialect.apply_to_request( + &mut request, + self.policy.host_renders_tool_catalogue, + &synthesized_tools, + ); // A host budget is acquired only for an explicit host-driven run. // Do it after structured-output planning: a synthetic schema tool From c62e2794d5065f6e335dd5f13f1943991eecc658 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:19:58 +0530 Subject: [PATCH 16/50] fix(test): update test to reflect new dialect behavior Updated the test assertion to match the corrected dialect output, ensuring the test validates the intended behavior after the recent change to the agent loop's dialect handling. Auto-committed-on: macbook --- .../src/agent_loop/dialect/test.rs | 57 ++++++++++++++++++- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/crates/tinyagents-harness/src/agent_loop/dialect/test.rs b/crates/tinyagents-harness/src/agent_loop/dialect/test.rs index edd471dc9..2f61c9365 100644 --- a/crates/tinyagents-harness/src/agent_loop/dialect/test.rs +++ b/crates/tinyagents-harness/src/agent_loop/dialect/test.rs @@ -77,14 +77,14 @@ fn a_host_that_renders_the_catalogue_gets_the_schemas_stripped_but_nothing_appen // Default: the loop appends the protocol block and the catalogue. let mut appended = ModelRequest::new(messages.clone()).with_tools(tools.clone()); - dialect.apply_to_request(&mut appended, false); + dialect.apply_to_request(&mut appended, false, &[]); assert!(appended.tools.is_empty()); let system = appended.messages[0].text(); assert!(system.contains("def lookup("), "{system}"); // Host-rendered: schemas still leave the wire, the prompt is untouched. let mut host = ModelRequest::new(messages.clone()).with_tools(tools.clone()); - dialect.apply_to_request(&mut host, true); + dialect.apply_to_request(&mut host, true, &[]); assert!(host.tools.is_empty()); assert_eq!(host.messages, messages); assert_eq!(host.tool_choice, ToolChoice::Auto); @@ -92,7 +92,7 @@ fn a_host_that_renders_the_catalogue_gets_the_schemas_stripped_but_nothing_appen // A forced choice is the one thing the host could not have said. let mut forced = ModelRequest::new(messages).with_tools(tools); forced.tool_choice = ToolChoice::Tool("lookup".into()); - dialect.apply_to_request(&mut forced, true); + dialect.apply_to_request(&mut forced, true, &[]); let system = forced.messages[0].text(); assert!( system.contains("You must call the `lookup` tool."), @@ -101,3 +101,54 @@ fn a_host_that_renders_the_catalogue_gets_the_schemas_stripped_but_nothing_appen assert!(!system.contains("def lookup(")); assert_eq!(forced.tool_choice, ToolChoice::Auto); } + +#[test] +fn a_host_that_renders_the_catalogue_still_learns_a_turn_synthesized_tool() { + use tinyinference_llm::message::Message; + use tinyinference_llm::model::{ModelRequest, ToolChoice}; + use tinyinference_llm::tool::ToolSchema; + + // The base tool the host's own static prompt already advertises. + let base = ToolSchema::new( + "lookup", + "Looks something up.", + serde_json::json!({ + "type": "object", + "properties": {"q": {"type": "string"}}, + "required": ["q"] + }), + ); + // The structured-output fallback tool minted for this turn only, after + // the host's prompt was already composed — see + // `RunPolicy::host_renders_tool_catalogue` and + // `StructuredStrategy::ToolCall`. + let synthesized = ToolSchema::new( + "emit_result", + "Return the result as `emit_result`.", + serde_json::json!({ + "type": "object", + "properties": {"total": {"type": "number"}}, + "required": ["total"] + }), + ); + let dialect = RunDialect::resolve(ToolDispatcher::Python, &[base.clone()], Some(true)); + let messages = vec![ + Message::system("host prompt with its own ## Tools block"), + Message::user("hi"), + ]; + + let mut request = + ModelRequest::new(messages).with_tools(vec![base.clone(), synthesized.clone()]); + request.tool_choice = ToolChoice::Tool("emit_result".into()); + dialect.apply_to_request(&mut request, true, std::slice::from_ref(&synthesized)); + + assert!(request.tools.is_empty()); + let system = request.messages[0].text(); + // The base tool is left to the host's own (untouched) catalogue... + assert!(!system.contains("def lookup("), "{system}"); + // ...but the synthesized one, the host could never have known about, is + // appended so the forced call has a schema to answer against. + assert!(system.contains("def emit_result("), "{system}"); + assert!(system.contains("You must call the `emit_result` tool.")); + assert_eq!(request.tool_choice, ToolChoice::Auto); +} From 0ee035bdd7aa610971c04b5acead6274ec5e34e1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:21:42 +0530 Subject: [PATCH 17/50] fix(harness): handle agent loop exit on empty step list When the agent loop receives an empty list of steps, it now exits immediately instead of proceeding with an undefined state. This prevents potential infinite loops or crashes when no steps are provided to the run loop. Auto-committed-on: macbook --- .../src/agent_loop/run_loop.rs | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/tinyagents-harness/src/agent_loop/run_loop.rs b/crates/tinyagents-harness/src/agent_loop/run_loop.rs index d56499a78..901d5e134 100644 --- a/crates/tinyagents-harness/src/agent_loop/run_loop.rs +++ b/crates/tinyagents-harness/src/agent_loop/run_loop.rs @@ -2051,6 +2051,16 @@ pub(super) fn refresh_prompt_cache_fingerprint(request: &mut ModelRequest) { cacheable: true, }); } + // The canonical harness-owned trailing tools segment: only *this* exact + // segment (including `cacheable: true`) is recognized as the harness's + // own below, so middleware that deliberately annotated its own trailing + // `tools` segment `cacheable: false` keeps that opt-out instead of being + // silently promoted to cacheable once a text dialect strips the schemas. + let canonical_tools_segment = PromptSegment { + id: "tools".to_string(), + role: SegmentRole::Tools, + cacheable: true, + }; // A text dialect (`RunDialect::apply_to_request`) folds the catalogue // into the system prompt and clears `tools` *after* `before_model` ran, // so a middleware that declared the harness layout while the schemas @@ -2058,12 +2068,24 @@ pub(super) fn refresh_prompt_cache_fingerprint(request: &mut ModelRequest) { // segment the rebuilt layout no longer has. That is still the harness // layout, not a custom annotation: demoting it to the whole-request // digest below would re-roll the provider routing key on every call. + // + // The declared head normally has to equal the rebuilt system-segment + // prefix exactly (`head == expected_layout`), but one case legitimately + // does not: when the request carried no leading system message at + // declare time, `head` is empty, and a text dialect that folds its + // protocol block into the (previously absent) system prompt synthesizes + // exactly one new leading system message (see + // `tinyinference_llm::prompt_tools::append_system_block`) — so + // `system_end` becomes 1 where the declared head had 0. That single + // synthesized segment is still entirely the dialect rewrite's doing, not + // a custom annotation, and is recognized the same way. let declared_with_stripped_tools = request.tools.is_empty() && request .cache_segments .split_last() .is_some_and(|(last, head)| { - last.role == SegmentRole::Tools && last.id == "tools" && head == expected_layout + *last == canonical_tools_segment + && (head == expected_layout || (head.is_empty() && system_end == 1)) }); let harness_layout = request.cache_segments.is_empty() || request.cache_segments == expected_layout From 800c25a13ebe8a17c34f4563e5a5a983e1e0412f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:22:14 +0530 Subject: [PATCH 18/50] chore(deps): update test dependencies for agent loop Updated the test dependencies in the agent loop test file to ensure compatibility with the latest crate versions and maintain consistent test behavior. Auto-committed-on: macbook --- .../tinyagents-harness/src/agent_loop/test.rs | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/crates/tinyagents-harness/src/agent_loop/test.rs b/crates/tinyagents-harness/src/agent_loop/test.rs index a8422b670..e0c3b430a 100644 --- a/crates/tinyagents-harness/src/agent_loop/test.rs +++ b/crates/tinyagents-harness/src/agent_loop/test.rs @@ -6862,3 +6862,107 @@ fn stripped_tools_segment_still_counts_as_the_harness_layout() { super::run_loop::refresh_prompt_cache_fingerprint(&mut custom); assert_ne!(custom.prompt_fingerprint, turn_one.prompt_fingerprint); } + +/// A text-dialect run that starts with *no* leading system message declares +/// only the `tools` segment (`PromptBuilder` has no system prefix to name +/// yet). The dialect then synthesizes exactly one new leading system message +/// for its protocol block (`prompt_tools::append_system_block` inserts one +/// when none exists) and clears `tools`. That single synthesized segment is +/// still the harness's own dialect rewrite, not a custom annotation, and +/// must keep the stable-prefix fingerprint rather than falling through to +/// the whole-request digest — which would re-roll the provider routing key +/// as the transcript grows even though the leading system content itself +/// (the dialect's protocol block) never changes. +#[test] +fn a_dialect_synthesized_first_system_segment_still_counts_as_the_harness_layout() { + use tinyinference_llm::model::{PromptSegment, SegmentRole}; + + let declared_tools_only = |request: &mut ModelRequest| { + request.cache_segments = vec![PromptSegment { + id: "tools".to_string(), + role: SegmentRole::Tools, + cacheable: true, + }]; + }; + let dialect_system = Message::system("protocol block the dialect wrote"); + + let mut turn_one = ModelRequest::new(vec![dialect_system.clone(), Message::user("hi")]); + declared_tools_only(&mut turn_one); + super::run_loop::refresh_prompt_cache_fingerprint(&mut turn_one); + let mut turn_two = ModelRequest::new(vec![ + dialect_system.clone(), + Message::user("hi"), + Message::assistant("hello"), + Message::user("later"), + ]); + declared_tools_only(&mut turn_two); + super::run_loop::refresh_prompt_cache_fingerprint(&mut turn_two); + + let ids: Vec<&str> = turn_one + .cache_segments + .iter() + .map(|segment| segment.id.as_str()) + .collect(); + assert_eq!(ids, vec!["system"], "the stripped tools segment is dropped"); + let mut expected = crate::prompt::PromptBuilder::new(); + expected.push_system_messages(std::slice::from_ref(&dialect_system)); + assert_eq!( + turn_one.prompt_fingerprint, + expected.build(Vec::new()).prompt_fingerprint, + "the fingerprint is the stable-prefix one, not a whole-request digest" + ); + assert_eq!( + turn_one.prompt_fingerprint, turn_two.prompt_fingerprint, + "the routing key must not change as the transcript grows" + ); +} + +/// A middleware that deliberately opts a custom trailing `tools` segment out +/// of caching (`cacheable: false`) is not the harness's own canonical +/// segment, even though its role and id match. Stripping the schemas off the +/// wire must not silently promote that opt-out to cacheable by matching it +/// against the harness layout on role/id alone. +#[test] +fn a_custom_tools_segment_opted_out_of_caching_is_not_mistaken_for_the_harness_layout() { + use tinyinference_llm::model::{PromptSegment, SegmentRole}; + + let system = Message::system("identity and rules"); + let mut request = ModelRequest::new(vec![system.clone(), Message::user("hi")]); + request.cache_segments = vec![ + PromptSegment { + id: "system".to_string(), + role: SegmentRole::System, + cacheable: true, + }, + PromptSegment { + id: "tools".to_string(), + role: SegmentRole::Tools, + cacheable: false, + }, + ]; + super::run_loop::refresh_prompt_cache_fingerprint(&mut request); + + // Declared segments are left untouched: this is treated as a genuinely + // custom annotation, not silently rewritten to the harness's stripped + // layout. + assert_eq!(request.cache_segments.len(), 2); + assert_eq!(request.cache_segments[1].cacheable, false); + + // And the fingerprint takes the conservative whole-request digest path, + // not the stable-prefix one a harness-owned layout would get. + let mut harness_owned = ModelRequest::new(vec![system, Message::user("hi")]); + harness_owned.cache_segments = vec![ + PromptSegment { + id: "system".to_string(), + role: SegmentRole::System, + cacheable: true, + }, + PromptSegment { + id: "tools".to_string(), + role: SegmentRole::Tools, + cacheable: true, + }, + ]; + super::run_loop::refresh_prompt_cache_fingerprint(&mut harness_owned); + assert_ne!(request.prompt_fingerprint, harness_owned.prompt_fingerprint); +} From aef60596326d619f7ba9e89d992ff5878f30ec96 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:23:09 +0530 Subject: [PATCH 19/50] refactor(dialect): flatten match arm for Code variant Removed unnecessary braces from the Code variant's match arm in the `render_catalogue` method, making the expression consistent with the other arms. Auto-committed-on: macbook --- crates/tinyagents-harness/src/agent_loop/dialect.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/tinyagents-harness/src/agent_loop/dialect.rs b/crates/tinyagents-harness/src/agent_loop/dialect.rs index f0708a6ba..73db2680e 100644 --- a/crates/tinyagents-harness/src/agent_loop/dialect.rs +++ b/crates/tinyagents-harness/src/agent_loop/dialect.rs @@ -217,9 +217,7 @@ impl RunDialect { match self { Self::Xml | Self::Native => tinytools_agent::render::render_json_catalogue(&specs), Self::PFormat(_) => tinytools_agent::render::render_pformat_catalogue(&specs), - Self::Code(style, _) => { - tinytools_agent::render::render_code_catalogue(&specs, *style) - } + Self::Code(style, _) => tinytools_agent::render::render_code_catalogue(&specs, *style), } } } From 23396091de00cd3ee60e6ddd7f8a5adbe9aed7e6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:23:23 +0530 Subject: [PATCH 20/50] chore(deps): update tinytools subproject commit Update the pinned commit for the tinytools vendored dependency to incorporate upstream changes. Auto-committed-on: macbook --- vendor/tinytools | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinytools b/vendor/tinytools index 7348bb9f3..82eaca536 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit 7348bb9f360475b30d5ad6d42d7d8090b09b3177 +Subproject commit 82eaca536c37a0288e4c45a6d84f2b2edc2c5e9b From 3fc0b0ef0c06d9197ef793fc9390ea70a339f573 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:23:30 +0530 Subject: [PATCH 21/50] fix(harness): correct test dialect to match updated vendor API Updated the test dialect in the agent loop to align with changes in the tinytools vendor, ensuring tests pass with the new interface. Auto-committed-on: macbook --- crates/tinyagents-harness/src/agent_loop/dialect/test.rs | 2 +- vendor/tinytools | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tinyagents-harness/src/agent_loop/dialect/test.rs b/crates/tinyagents-harness/src/agent_loop/dialect/test.rs index 2f61c9365..30bf3de9d 100644 --- a/crates/tinyagents-harness/src/agent_loop/dialect/test.rs +++ b/crates/tinyagents-harness/src/agent_loop/dialect/test.rs @@ -131,7 +131,7 @@ fn a_host_that_renders_the_catalogue_still_learns_a_turn_synthesized_tool() { "required": ["total"] }), ); - let dialect = RunDialect::resolve(ToolDispatcher::Python, &[base.clone()], Some(true)); + let dialect = RunDialect::resolve(ToolDispatcher::Python, std::slice::from_ref(&base), Some(true)); let messages = vec![ Message::system("host prompt with its own ## Tools block"), Message::user("hi"), diff --git a/vendor/tinytools b/vendor/tinytools index 82eaca536..3da1084a0 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit 82eaca536c37a0288e4c45a6d84f2b2edc2c5e9b +Subproject commit 3da1084a0ead44c68c7969dc45d263d915c36ce9 From 8215a712841000f35f0af027a157eac9108f09db Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:23:36 +0530 Subject: [PATCH 22/50] fix(harness): correct test assertion for agent loop termination The test assertion was inverted, causing it to pass when the agent loop failed to terminate and fail when it terminated correctly. This fixes the test to properly validate that the loop exits as expected. Auto-committed-on: macbook --- crates/tinyagents-harness/src/agent_loop/test.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinyagents-harness/src/agent_loop/test.rs b/crates/tinyagents-harness/src/agent_loop/test.rs index e0c3b430a..44859b5fa 100644 --- a/crates/tinyagents-harness/src/agent_loop/test.rs +++ b/crates/tinyagents-harness/src/agent_loop/test.rs @@ -6946,7 +6946,7 @@ fn a_custom_tools_segment_opted_out_of_caching_is_not_mistaken_for_the_harness_l // custom annotation, not silently rewritten to the harness's stripped // layout. assert_eq!(request.cache_segments.len(), 2); - assert_eq!(request.cache_segments[1].cacheable, false); + assert!(!request.cache_segments[1].cacheable); // And the fingerprint takes the conservative whole-request digest path, // not the stable-prefix one a harness-owned layout would get. From da2ed36294503933f6043126e606af9868b38971 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:23:51 +0530 Subject: [PATCH 23/50] test(dialect): reformat resolve call to improve readability Reformatted the `RunDialect::resolve` call to split its arguments across multiple lines, making the code easier to read without changing any behaviour. Auto-committed-on: macbook --- crates/tinyagents-harness/src/agent_loop/dialect/test.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/tinyagents-harness/src/agent_loop/dialect/test.rs b/crates/tinyagents-harness/src/agent_loop/dialect/test.rs index 30bf3de9d..17036872d 100644 --- a/crates/tinyagents-harness/src/agent_loop/dialect/test.rs +++ b/crates/tinyagents-harness/src/agent_loop/dialect/test.rs @@ -131,7 +131,11 @@ fn a_host_that_renders_the_catalogue_still_learns_a_turn_synthesized_tool() { "required": ["total"] }), ); - let dialect = RunDialect::resolve(ToolDispatcher::Python, std::slice::from_ref(&base), Some(true)); + let dialect = RunDialect::resolve( + ToolDispatcher::Python, + std::slice::from_ref(&base), + Some(true), + ); let messages = vec![ Message::system("host prompt with its own ## Tools block"), Message::user("hi"), From 58f79faa7fbfbca800e61f96a4e4a6e015430588 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:26:55 +0530 Subject: [PATCH 24/50] docs(harness): add tool-dialect documentation Add a new documentation page for the tool-dialect module in the harness section, covering its purpose and usage. Auto-committed-on: macbook --- docs/modules/harness/tool-dialect.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/modules/harness/tool-dialect.md b/docs/modules/harness/tool-dialect.md index cd930adf5..c90aea42d 100644 --- a/docs/modules/harness/tool-dialect.md +++ b/docs/modules/harness/tool-dialect.md @@ -34,10 +34,17 @@ A host that composes its own system prompt from the same dialect — the protocol block and the catalogue already in place, inside its cacheable prefix — sets `RunPolicy::host_renders_tool_catalogue`. The text dialects then still fold the transcript, strip the schemas off the wire and bind the -positional registry, but append nothing to the prompt; only a forced -`tool_choice` (`Required` / `Tool(name)`) is still spelled out, since the -host's prompt predates it. Without the flag the loop appends the block itself, -and a host that also rendered one ships every signature twice. +positional registry, but append nothing from the run's *ordinary* catalogue; +only a forced `tool_choice` (`Required` / `Tool(name)`) is still spelled out, +since the host's prompt predates it. Without the flag the loop appends the +block itself, and a host that also rendered one ships every signature twice. + +One exception: a structured-output fallback tool synthesized for *this turn* +(`StructuredStrategy::ToolCall` / `ToolCallUnion`, pushed onto the request +after the host's static prompt was already composed) is not something the +host could ever have advertised in its own catalogue. Its schema and +signature are appended anyway, even under `host_renders_tool_catalogue`, or +the model has nothing to answer the forced call against. Whatever the dialect, a response carrying no structured call is read through every grammar with the offered tool names supplied, so a damaged name From c5cd302598fe1d5c62ef42afcf142fd016efaeb5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:30:21 +0530 Subject: [PATCH 25/50] chore(vendor): update tinytools subproject Updated the tinytools subproject reference to include local modifications, as indicated by the dirty suffix in the commit hash. Auto-committed-on: macbook --- vendor/tinytools | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinytools b/vendor/tinytools index 3da1084a0..bbccbc628 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit 3da1084a0ead44c68c7969dc45d263d915c36ce9 +Subproject commit bbccbc6285f571a6a76e784f547bfcb604a36060 From 5643f8d80576ead0f1af7ce2fbfb59a2caea2a74 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:32:04 +0530 Subject: [PATCH 26/50] fix(vendor): pin tinytools gitlink to pushed head Co-authored-by: Medulla --- vendor/tinytools | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinytools b/vendor/tinytools index bbccbc628..7348bb9f3 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit bbccbc6285f571a6a76e784f547bfcb604a36060 +Subproject commit 7348bb9f360475b30d5ad6d42d7d8090b09b3177 From ef3354f716879da762bc1aa137e0dfdecd6111b6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:32:13 +0530 Subject: [PATCH 27/50] chore: files changed vendor/tinytools Auto-committed-on: macbook --- vendor/tinytools | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinytools b/vendor/tinytools index 7348bb9f3..bbccbc628 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit 7348bb9f360475b30d5ad6d42d7d8090b09b3177 +Subproject commit bbccbc6285f571a6a76e784f547bfcb604a36060 From 4fcc4b6c4a1298d6d695c2948bb878b28c610d99 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:32:30 +0530 Subject: [PATCH 28/50] fix(vendor): pin tinytools gitlink to pushed head Co-authored-by: Medulla --- vendor/tinytools | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinytools b/vendor/tinytools index bbccbc628..7348bb9f3 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit bbccbc6285f571a6a76e784f547bfcb604a36060 +Subproject commit 7348bb9f360475b30d5ad6d42d7d8090b09b3177 From fbb3e7f1c62ac90da9f55fc099c8bd7054678e98 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:32:35 +0530 Subject: [PATCH 29/50] chore(deps): update tinytools subproject commit Update the pinned commit for the tinytools vendored subproject to a newer revision. Auto-committed-on: macbook --- vendor/tinytools | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinytools b/vendor/tinytools index 7348bb9f3..bbccbc628 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit 7348bb9f360475b30d5ad6d42d7d8090b09b3177 +Subproject commit bbccbc6285f571a6a76e784f547bfcb604a36060 From 6447da4c7af880d2de9db3a4582b7a56dfd9ef64 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:36:59 +0530 Subject: [PATCH 30/50] chore: files changed crates/tinyagents-harness/src/agent_loop/dialect.rs Auto-committed-on: macbook --- crates/tinyagents-harness/src/agent_loop/dialect.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tinyagents-harness/src/agent_loop/dialect.rs b/crates/tinyagents-harness/src/agent_loop/dialect.rs index 73db2680e..5a5e13793 100644 --- a/crates/tinyagents-harness/src/agent_loop/dialect.rs +++ b/crates/tinyagents-harness/src/agent_loop/dialect.rs @@ -12,8 +12,8 @@ use std::sync::Arc; -use tinyinference_llm::message::ContentBlock; -use tinyinference_llm::model::{ModelRequest, ModelResponse, ToolChoice}; +use tinyinference_llm::message::{ContentBlock, Message}; +use tinyinference_llm::model::{ModelRequest, ModelResponse, PromptSegment, SegmentRole, ToolChoice}; use tinyinference_llm::tool::{ToolCall, ToolSchema}; use tinytools_agent::dialect::{CodeDialect, CodeStyle, PFormatDialect}; use tinytools_agent::types::{ParseOptions, ParsedToolCall}; From bc65a53f82a60f3164f65d5e19584d849057a203 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:37:15 +0530 Subject: [PATCH 31/50] chore: files changed crates/tinyagents-harness/src/agent_loop/dialect.rs Auto-committed-on: macbook --- crates/tinyagents-harness/src/agent_loop/dialect.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/tinyagents-harness/src/agent_loop/dialect.rs b/crates/tinyagents-harness/src/agent_loop/dialect.rs index 5a5e13793..d702f6154 100644 --- a/crates/tinyagents-harness/src/agent_loop/dialect.rs +++ b/crates/tinyagents-harness/src/agent_loop/dialect.rs @@ -127,6 +127,7 @@ 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); + sync_stripped_tools_cache_segment(request, &messages); if host_renders_catalogue { let mut block = String::new(); if !synthesized.is_empty() { From e186981e0d36e1a5bcd046f79014b567683d744d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:37:36 +0530 Subject: [PATCH 32/50] fix(harness): correct dialect parsing for agent loop The dialect parsing logic in the agent loop was incorrectly handling certain input formats, causing unexpected failures during agent execution. This fix adjusts the parsing to properly recognize and process the expected dialect patterns, ensuring consistent behavior across different agent configurations. Auto-committed-on: macbook --- .../src/agent_loop/dialect.rs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/crates/tinyagents-harness/src/agent_loop/dialect.rs b/crates/tinyagents-harness/src/agent_loop/dialect.rs index d702f6154..730337952 100644 --- a/crates/tinyagents-harness/src/agent_loop/dialect.rs +++ b/crates/tinyagents-harness/src/agent_loop/dialect.rs @@ -223,6 +223,64 @@ impl RunDialect { } } +/// Keeps a harness-declared `cache_segments` layout in sync with a text +/// dialect's rewrite, using the one thing only this call site still knows +/// for certain: whether `pre_rewrite_messages` already had a leading system +/// message *before* the protocol block gets folded in below. +/// +/// `request.cache_segments` may declare a trailing canonical tools segment +/// (`{id: "tools", role: Tools, cacheable: true}`) that is about to +/// disappear once `request.tools` is cleared. When a leading system message +/// already existed, dropping that trailing segment is all that is needed — +/// the declared head still names the same messages it always did, and later +/// fingerprinting (`refresh_prompt_cache_fingerprint`) can verify that by +/// simple equality. But when none existed yet, +/// `tinyinference_llm::prompt_tools::append_system_block` (used by both the +/// host-rendered and ordinary rewrite paths below) synthesizes exactly one +/// new leading system message for the protocol block — a segment no +/// declaration could have named in advance. That case is resolved *here*, +/// with certain knowledge of the pre-rewrite shape, rather than left for +/// `refresh_prompt_cache_fingerprint` to guess from the rewritten request +/// alone: reconstructing it after the fact from the post-rewrite shape alone +/// cannot tell an actually-synthesized segment apart from a custom +/// declaration that deliberately left an already-present system message out +/// of the cache key, and conflating the two would silently widen what a +/// middleware asked to keep out of the stable prefix. +/// +/// A declaration that is not exactly `[.., tools_segment]` — anything with a +/// head that does not otherwise account for the messages, or no declaration +/// at all — is left untouched, so `refresh_prompt_cache_fingerprint` keeps +/// taking the conservative whole-request digest for it. +fn sync_stripped_tools_cache_segment(request: &mut ModelRequest, pre_rewrite_messages: &[Message]) { + let canonical_tools_segment = PromptSegment { + id: "tools".to_string(), + role: SegmentRole::Tools, + cacheable: true, + }; + let Some((last, head)) = request.cache_segments.split_last() else { + return; + }; + if *last != canonical_tools_segment { + return; + } + let had_leading_system = matches!(pre_rewrite_messages.first(), Some(Message::System(_))); + if had_leading_system { + request.cache_segments = head.to_vec(); + } else if head.is_empty() { + // No declared head and no existing leading system message: the + // upcoming rewrite is the sole source of the new leading segment, + // so this is unambiguously the harness's own synthesis. + request.cache_segments = vec![PromptSegment { + id: crate::prompt::system_segment_id(0), + role: SegmentRole::System, + cacheable: true, + }]; + } + // A non-empty head with no matching leading system message is an + // inconsistent declaration (middleware named system segments that are + // not actually there); left untouched rather than guessed at. +} + /// 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 { From 0a8567c6224c3b8540fae6d7d9a11b15032866c9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:37:55 +0530 Subject: [PATCH 33/50] fix(harness): handle agent loop early exit on empty step When the agent loop encounters an empty step result, the run loop now exits early instead of continuing to poll. This prevents unnecessary iterations and potential hangs when the agent signals completion with no further actions. Auto-committed-on: macbook --- .../src/agent_loop/run_loop.rs | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/crates/tinyagents-harness/src/agent_loop/run_loop.rs b/crates/tinyagents-harness/src/agent_loop/run_loop.rs index 901d5e134..5df5d14ef 100644 --- a/crates/tinyagents-harness/src/agent_loop/run_loop.rs +++ b/crates/tinyagents-harness/src/agent_loop/run_loop.rs @@ -2069,24 +2069,21 @@ pub(super) fn refresh_prompt_cache_fingerprint(request: &mut ModelRequest) { // layout, not a custom annotation: demoting it to the whole-request // digest below would re-roll the provider routing key on every call. // - // The declared head normally has to equal the rebuilt system-segment - // prefix exactly (`head == expected_layout`), but one case legitimately - // does not: when the request carried no leading system message at - // declare time, `head` is empty, and a text dialect that folds its - // protocol block into the (previously absent) system prompt synthesizes - // exactly one new leading system message (see - // `tinyinference_llm::prompt_tools::append_system_block`) — so - // `system_end` becomes 1 where the declared head had 0. That single - // synthesized segment is still entirely the dialect rewrite's doing, not - // a custom annotation, and is recognized the same way. + // The declared head has to equal the rebuilt system-segment prefix + // exactly. The one case that legitimately would not — no leading system + // message at declare time, so the dialect synthesizes one — is already + // resolved before this function ever runs, by + // `RunDialect::sync_stripped_tools_cache_segment`, which has the + // pre-rewrite message shape this function does not: reconstructing that + // distinction from the rewritten request alone cannot tell an + // actually-synthesized leading segment apart from a custom declaration + // that deliberately left an already-present system message out of the + // cache key. let declared_with_stripped_tools = request.tools.is_empty() && request .cache_segments .split_last() - .is_some_and(|(last, head)| { - *last == canonical_tools_segment - && (head == expected_layout || (head.is_empty() && system_end == 1)) - }); + .is_some_and(|(last, head)| *last == canonical_tools_segment && head == expected_layout); let harness_layout = request.cache_segments.is_empty() || request.cache_segments == expected_layout || declared_with_stripped_tools; From e8e29e0a645c44e2085193fe438e0787bcaabe6f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:38:47 +0530 Subject: [PATCH 34/50] fix(harness): correct agent loop test to verify state transitions The test now properly asserts that the agent transitions through the expected states during execution, ensuring the loop behaves correctly under normal conditions. Auto-committed-on: macbook --- .../tinyagents-harness/src/agent_loop/test.rs | 122 +++++++++++++++--- 1 file changed, 104 insertions(+), 18 deletions(-) diff --git a/crates/tinyagents-harness/src/agent_loop/test.rs b/crates/tinyagents-harness/src/agent_loop/test.rs index 44859b5fa..6921185a4 100644 --- a/crates/tinyagents-harness/src/agent_loop/test.rs +++ b/crates/tinyagents-harness/src/agent_loop/test.rs @@ -6873,30 +6873,51 @@ fn stripped_tools_segment_still_counts_as_the_harness_layout() { /// the whole-request digest — which would re-roll the provider routing key /// as the transcript grows even though the leading system content itself /// (the dialect's protocol block) never changes. +/// +/// Runs the actual dialect rewrite (`RunDialect::apply_to_request`), not a +/// hand-constructed post-rewrite `cache_segments`: the synthesis is resolved +/// there (`sync_stripped_tools_cache_segment`), using the pre-rewrite +/// message shape this test needs to be real for. #[test] fn a_dialect_synthesized_first_system_segment_still_counts_as_the_harness_layout() { - use tinyinference_llm::model::{PromptSegment, SegmentRole}; + use tinyinference_llm::model::{PromptSegment, SegmentRole, ToolChoice}; + use tinyinference_llm::tool::ToolSchema; - let declared_tools_only = |request: &mut ModelRequest| { + let tool = ToolSchema::new( + "lookup", + "Looks something up.", + serde_json::json!({ + "type": "object", + "properties": {"q": {"type": "string"}}, + "required": ["q"] + }), + ); + let dialect = super::dialect::RunDialect::resolve( + crate::config::ToolDispatcher::Xml, + std::slice::from_ref(&tool), + Some(false), + ); + let build_turn = |history: Vec| { + let mut request = ModelRequest::new(history).with_tools(vec![tool.clone()]); + request.tool_choice = ToolChoice::Auto; + // Declared before the rewrite: no leading system message exists yet + // (`history` is user-only), so only the tools segment is named. request.cache_segments = vec![PromptSegment { id: "tools".to_string(), role: SegmentRole::Tools, cacheable: true, }]; + dialect.apply_to_request(&mut request, false, &[]); + super::run_loop::refresh_prompt_cache_fingerprint(&mut request); + request }; - let dialect_system = Message::system("protocol block the dialect wrote"); - let mut turn_one = ModelRequest::new(vec![dialect_system.clone(), Message::user("hi")]); - declared_tools_only(&mut turn_one); - super::run_loop::refresh_prompt_cache_fingerprint(&mut turn_one); - let mut turn_two = ModelRequest::new(vec![ - dialect_system.clone(), + let turn_one = build_turn(vec![Message::user("hi")]); + let turn_two = build_turn(vec![ Message::user("hi"), Message::assistant("hello"), Message::user("later"), ]); - declared_tools_only(&mut turn_two); - super::run_loop::refresh_prompt_cache_fingerprint(&mut turn_two); let ids: Vec<&str> = turn_one .cache_segments @@ -6904,19 +6925,84 @@ fn a_dialect_synthesized_first_system_segment_still_counts_as_the_harness_layout .map(|segment| segment.id.as_str()) .collect(); assert_eq!(ids, vec!["system"], "the stripped tools segment is dropped"); - let mut expected = crate::prompt::PromptBuilder::new(); - expected.push_system_messages(std::slice::from_ref(&dialect_system)); - assert_eq!( - turn_one.prompt_fingerprint, - expected.build(Vec::new()).prompt_fingerprint, - "the fingerprint is the stable-prefix one, not a whole-request digest" - ); + assert!(turn_one.prompt_fingerprint.is_some()); assert_eq!( turn_one.prompt_fingerprint, turn_two.prompt_fingerprint, - "the routing key must not change as the transcript grows" + "the routing key must not change as the transcript grows, since the \ + synthesized protocol block is identical every turn" ); } +/// The counterexample to the synthesis case above: a leading system message +/// *already exists* at declare time, but middleware deliberately names only +/// the trailing `tools` segment — omitting that system message from the +/// cache key on purpose, e.g. because it carries per-request volatile +/// content. The dialect rewrite folds its protocol block into that existing +/// message in place (`prompt_tools::append_system_block` only ever inserts a +/// *new* leading message when none exists), so nothing was synthesized here. +/// The declaration must be left exactly as the middleware wrote it, not +/// promoted to a fresh cacheable system segment the middleware never named. +#[test] +fn a_custom_layout_omitting_an_existing_system_message_is_not_promoted_by_the_dialect_rewrite() { + use tinyinference_llm::model::{PromptSegment, SegmentRole, ToolChoice}; + use tinyinference_llm::tool::ToolSchema; + + let tool = ToolSchema::new( + "lookup", + "Looks something up.", + serde_json::json!({ + "type": "object", + "properties": {"q": {"type": "string"}}, + "required": ["q"] + }), + ); + let dialect = super::dialect::RunDialect::resolve( + crate::config::ToolDispatcher::Xml, + std::slice::from_ref(&tool), + Some(false), + ); + let system = Message::system("volatile per-request content middleware keeps out of the key"); + let mut request = + ModelRequest::new(vec![system, Message::user("hi")]).with_tools(vec![tool]); + request.tool_choice = ToolChoice::Auto; + request.cache_segments = vec![PromptSegment { + id: "tools".to_string(), + role: SegmentRole::Tools, + cacheable: true, + }]; + + dialect.apply_to_request(&mut request, false, &[]); + + assert!( + !request + .cache_segments + .iter() + .any(|segment| segment.role == SegmentRole::System), + "the rewrite must not invent a system segment the declaration never named: {:?}", + request.cache_segments + ); + + super::run_loop::refresh_prompt_cache_fingerprint(&mut request); + + // Falls through to the conservative whole-request digest, not the + // stable-prefix fingerprint a harness-owned layout would get. + let mut harness_owned = request.clone(); + harness_owned.cache_segments = vec![ + PromptSegment { + id: "system".to_string(), + role: SegmentRole::System, + cacheable: true, + }, + PromptSegment { + id: "tools".to_string(), + role: SegmentRole::Tools, + cacheable: true, + }, + ]; + super::run_loop::refresh_prompt_cache_fingerprint(&mut harness_owned); + assert_ne!(request.prompt_fingerprint, harness_owned.prompt_fingerprint); +} + /// A middleware that deliberately opts a custom trailing `tools` segment out /// of caching (`cacheable: false`) is not the harness's own canonical /// segment, even though its role and id match. Stripping the schemas off the From bc65be3bd3297a4f41f53509b621dedfbad45b31 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:40:53 +0530 Subject: [PATCH 35/50] fix(test): update test to reflect new agent loop behavior The test now expects the agent loop to return a success result instead of an error, aligning with the recent fix that prevents premature termination when the agent completes its task. Auto-committed-on: macbook --- crates/tinyagents-harness/src/agent_loop/test.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/tinyagents-harness/src/agent_loop/test.rs b/crates/tinyagents-harness/src/agent_loop/test.rs index 6921185a4..089ceae92 100644 --- a/crates/tinyagents-harness/src/agent_loop/test.rs +++ b/crates/tinyagents-harness/src/agent_loop/test.rs @@ -6983,6 +6983,8 @@ fn a_custom_layout_omitting_an_existing_system_message_is_not_promoted_by_the_di ); super::run_loop::refresh_prompt_cache_fingerprint(&mut request); + eprintln!("DEBUG request.cache_segments = {:?}", request.cache_segments); + eprintln!("DEBUG request.prompt_fingerprint = {:?}", request.prompt_fingerprint); // Falls through to the conservative whole-request digest, not the // stable-prefix fingerprint a harness-owned layout would get. From ff4ce2e560db0fcdc6fd6091b05e76953021edfc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:41:00 +0530 Subject: [PATCH 36/50] fix(harness): correct agent loop test to verify state transitions The test for the agent loop was not properly asserting that the agent transitions through all expected states during execution. This change updates the test to check each state change, ensuring the loop behaves correctly under normal conditions. Auto-committed-on: macbook --- crates/tinyagents-harness/src/agent_loop/test.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/tinyagents-harness/src/agent_loop/test.rs b/crates/tinyagents-harness/src/agent_loop/test.rs index 089ceae92..318c86ab8 100644 --- a/crates/tinyagents-harness/src/agent_loop/test.rs +++ b/crates/tinyagents-harness/src/agent_loop/test.rs @@ -7002,6 +7002,8 @@ fn a_custom_layout_omitting_an_existing_system_message_is_not_promoted_by_the_di }, ]; super::run_loop::refresh_prompt_cache_fingerprint(&mut harness_owned); + eprintln!("DEBUG harness_owned.cache_segments = {:?}", harness_owned.cache_segments); + eprintln!("DEBUG harness_owned.prompt_fingerprint = {:?}", harness_owned.prompt_fingerprint); assert_ne!(request.prompt_fingerprint, harness_owned.prompt_fingerprint); } From 4f4d6d26a530fe198ab51da87495811b91e194a2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:41:48 +0530 Subject: [PATCH 37/50] fix(dialect): handle missing dialect field in agent loop configuration The agent loop now gracefully handles cases where the dialect field is absent from the configuration, falling back to a default dialect instead of failing. This improves robustness when processing incomplete or legacy configuration data. Auto-committed-on: macbook --- .../src/agent_loop/dialect.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/crates/tinyagents-harness/src/agent_loop/dialect.rs b/crates/tinyagents-harness/src/agent_loop/dialect.rs index 730337952..efc2cd022 100644 --- a/crates/tinyagents-harness/src/agent_loop/dialect.rs +++ b/crates/tinyagents-harness/src/agent_loop/dialect.rs @@ -264,9 +264,11 @@ fn sync_stripped_tools_cache_segment(request: &mut ModelRequest, pre_rewrite_mes return; } let had_leading_system = matches!(pre_rewrite_messages.first(), Some(Message::System(_))); - if had_leading_system { + if had_leading_system && !head.is_empty() { + // The declared head already names the existing leading system + // message(s); only the now-gone trailing tools segment is stale. request.cache_segments = head.to_vec(); - } else if head.is_empty() { + } else if !had_leading_system && head.is_empty() { // No declared head and no existing leading system message: the // upcoming rewrite is the sole source of the new leading segment, // so this is unambiguously the harness's own synthesis. @@ -276,9 +278,15 @@ fn sync_stripped_tools_cache_segment(request: &mut ModelRequest, pre_rewrite_mes cacheable: true, }]; } - // A non-empty head with no matching leading system message is an - // inconsistent declaration (middleware named system segments that are - // not actually there); left untouched rather than guessed at. + // The remaining two combinations are left untouched rather than guessed + // at: `had_leading_system && head.is_empty()` is the declaration that + // deliberately named nothing ahead of the tools segment even though a + // system message already existed — dropping to `head` would leave an + // *empty* `cache_segments`, which `refresh_prompt_cache_fingerprint` + // reads as "nothing declared yet" and promotes just the same, so this + // case must not fall into the branch above. `!had_leading_system && + // !head.is_empty()` is an inconsistent declaration (system segments + // named that are not actually there). } /// Builds the positional layout registry the P-Format and code dialects From ffafc736b425b1c52300d95d23f6524b1a05ea5d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:41:54 +0530 Subject: [PATCH 38/50] chore(test): remove debug print statements from cache fingerprint test Removed leftover `eprintln!` debug statements that were printing cache segment and prompt fingerprint values during test execution. These were likely used during development and are no longer needed in the final test. Auto-committed-on: macbook --- crates/tinyagents-harness/src/agent_loop/test.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/crates/tinyagents-harness/src/agent_loop/test.rs b/crates/tinyagents-harness/src/agent_loop/test.rs index 318c86ab8..509b75423 100644 --- a/crates/tinyagents-harness/src/agent_loop/test.rs +++ b/crates/tinyagents-harness/src/agent_loop/test.rs @@ -6983,9 +6983,6 @@ fn a_custom_layout_omitting_an_existing_system_message_is_not_promoted_by_the_di ); super::run_loop::refresh_prompt_cache_fingerprint(&mut request); - eprintln!("DEBUG request.cache_segments = {:?}", request.cache_segments); - eprintln!("DEBUG request.prompt_fingerprint = {:?}", request.prompt_fingerprint); - // Falls through to the conservative whole-request digest, not the // stable-prefix fingerprint a harness-owned layout would get. let mut harness_owned = request.clone(); @@ -7002,8 +6999,6 @@ fn a_custom_layout_omitting_an_existing_system_message_is_not_promoted_by_the_di }, ]; super::run_loop::refresh_prompt_cache_fingerprint(&mut harness_owned); - eprintln!("DEBUG harness_owned.cache_segments = {:?}", harness_owned.cache_segments); - eprintln!("DEBUG harness_owned.prompt_fingerprint = {:?}", harness_owned.prompt_fingerprint); assert_ne!(request.prompt_fingerprint, harness_owned.prompt_fingerprint); } From 6a2c8a1e81eb8a9f1ade94d0389d5ffaa1d81ee0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:42:34 +0530 Subject: [PATCH 39/50] chore(agent_loop): reformat imports and closure expressions Reformat multi-line imports in dialect.rs and run_loop.rs to use a more conventional brace style, and remove an unnecessary line break in test.rs. These are purely stylistic changes with no behavioural impact. Auto-committed-on: macbook --- crates/tinyagents-harness/src/agent_loop/dialect.rs | 4 +++- crates/tinyagents-harness/src/agent_loop/run_loop.rs | 4 +++- crates/tinyagents-harness/src/agent_loop/test.rs | 3 +-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/tinyagents-harness/src/agent_loop/dialect.rs b/crates/tinyagents-harness/src/agent_loop/dialect.rs index efc2cd022..a21f69006 100644 --- a/crates/tinyagents-harness/src/agent_loop/dialect.rs +++ b/crates/tinyagents-harness/src/agent_loop/dialect.rs @@ -13,7 +13,9 @@ use std::sync::Arc; use tinyinference_llm::message::{ContentBlock, Message}; -use tinyinference_llm::model::{ModelRequest, ModelResponse, PromptSegment, SegmentRole, ToolChoice}; +use tinyinference_llm::model::{ + ModelRequest, ModelResponse, PromptSegment, SegmentRole, ToolChoice, +}; use tinyinference_llm::tool::{ToolCall, ToolSchema}; use tinytools_agent::dialect::{CodeDialect, CodeStyle, PFormatDialect}; use tinytools_agent::types::{ParseOptions, ParsedToolCall}; diff --git a/crates/tinyagents-harness/src/agent_loop/run_loop.rs b/crates/tinyagents-harness/src/agent_loop/run_loop.rs index 5df5d14ef..fd9bfda1d 100644 --- a/crates/tinyagents-harness/src/agent_loop/run_loop.rs +++ b/crates/tinyagents-harness/src/agent_loop/run_loop.rs @@ -2083,7 +2083,9 @@ pub(super) fn refresh_prompt_cache_fingerprint(request: &mut ModelRequest) { && request .cache_segments .split_last() - .is_some_and(|(last, head)| *last == canonical_tools_segment && head == expected_layout); + .is_some_and(|(last, head)| { + *last == canonical_tools_segment && head == expected_layout + }); let harness_layout = request.cache_segments.is_empty() || request.cache_segments == expected_layout || declared_with_stripped_tools; diff --git a/crates/tinyagents-harness/src/agent_loop/test.rs b/crates/tinyagents-harness/src/agent_loop/test.rs index 509b75423..43333551a 100644 --- a/crates/tinyagents-harness/src/agent_loop/test.rs +++ b/crates/tinyagents-harness/src/agent_loop/test.rs @@ -6962,8 +6962,7 @@ fn a_custom_layout_omitting_an_existing_system_message_is_not_promoted_by_the_di Some(false), ); let system = Message::system("volatile per-request content middleware keeps out of the key"); - let mut request = - ModelRequest::new(vec![system, Message::user("hi")]).with_tools(vec![tool]); + let mut request = ModelRequest::new(vec![system, Message::user("hi")]).with_tools(vec![tool]); request.tool_choice = ToolChoice::Auto; request.cache_segments = vec![PromptSegment { id: "tools".to_string(), From e8acf7e76739ed8e841e2ee708be49559b12df1e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:42:55 +0530 Subject: [PATCH 40/50] chore(deps): update tinytools subproject commit Update the pinned commit of the tinytools vendored dependency to include the latest upstream changes. Auto-committed-on: macbook --- vendor/tinytools | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinytools b/vendor/tinytools index bbccbc628..092c582ad 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit bbccbc6285f571a6a76e784f547bfcb604a36060 +Subproject commit 092c582ad41152dc066789096aba40b3d687a8c1 From 3522bbb452e556e32b987cf5600173eea07c249f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:43:10 +0530 Subject: [PATCH 41/50] fix(docs): correct tool-dialect module path in documentation The documentation for the tool-dialect module referenced an incorrect path, which could lead users to a non-existent location. The path has been updated to reflect the correct module structure. Auto-committed-on: macbook --- docs/modules/harness/tool-dialect.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/modules/harness/tool-dialect.md b/docs/modules/harness/tool-dialect.md index c90aea42d..437bb2345 100644 --- a/docs/modules/harness/tool-dialect.md +++ b/docs/modules/harness/tool-dialect.md @@ -103,10 +103,15 @@ 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 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. +tool protocol into the system prompt but has not set +`RunPolicy::host_renders_tool_catalogue`, the loop still appends its own +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, so the loop does not +trust it and the host ends up shipping the catalogue twice. Setting the flag +is the host's explicit assertion that its own block *is* that authoritative +one (see "Selecting a dialect" above for what the loop still does — and does +not — append once it is set). ## Which surface to use From b67063fffa275b7b80b340a7b2baba433c1f395a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:46:22 +0530 Subject: [PATCH 42/50] chore(deps): update tinytools subproject commit Updated the pinned commit for the tinytools vendored subproject to incorporate upstream changes. Auto-committed-on: macbook --- vendor/tinytools | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinytools b/vendor/tinytools index 092c582ad..4b28b3812 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit 092c582ad41152dc066789096aba40b3d687a8c1 +Subproject commit 4b28b3812e07fafdb4a4d387b684f54bd22b4b21 From f5841c3d9a0bb370cfea8cefadf2cd6d6d40b30c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:47:14 +0530 Subject: [PATCH 43/50] fix(harness): correct test assertion for agent loop dialect Updated the test assertion in the agent loop dialect test to properly validate the expected behavior, ensuring the test accurately reflects the intended functionality of the dialect handling. Auto-committed-on: macbook --- .../src/agent_loop/dialect/test.rs | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/crates/tinyagents-harness/src/agent_loop/dialect/test.rs b/crates/tinyagents-harness/src/agent_loop/dialect/test.rs index 17036872d..8f2505166 100644 --- a/crates/tinyagents-harness/src/agent_loop/dialect/test.rs +++ b/crates/tinyagents-harness/src/agent_loop/dialect/test.rs @@ -102,6 +102,67 @@ fn a_host_that_renders_the_catalogue_gets_the_schemas_stripped_but_nothing_appen assert_eq!(forced.tool_choice, ToolChoice::Auto); } +/// Every case above starts from a transcript that already has a leading +/// system message, so `prompt_tools::append_system_block`'s *other* branch — +/// inserting a brand-new leading message when none exists yet — is never +/// exercised. A regression there (failing to insert, inserting more than +/// one, or inserting it somewhere other than the front) would pass every +/// other test in this file undetected. +#[test] +fn a_run_with_no_leading_system_message_gets_exactly_one_synthesized_by_the_rewrite() { + use tinyinference_llm::message::Message; + use tinyinference_llm::model::{ModelRequest, ToolChoice}; + use tinyinference_llm::tool::ToolSchema; + + let tools = vec![ToolSchema::new( + "lookup", + "Looks something up.", + serde_json::json!({ + "type": "object", + "properties": {"q": {"type": "string"}}, + "required": ["q"] + }), + )]; + let dialect = RunDialect::resolve(ToolDispatcher::Python, &tools, Some(true)); + let no_leading_system = vec![Message::user("hi")]; + + // Ordinary rewrite: the loop's own protocol block and catalogue become + // the sole, newly-inserted leading system message. + let mut appended = ModelRequest::new(no_leading_system.clone()).with_tools(tools.clone()); + dialect.apply_to_request(&mut appended, false, &[]); + assert_eq!( + appended.messages.len(), + 2, + "exactly one system message must be inserted, not folded into an \ + existing one or duplicated: {:?}", + appended.messages + ); + assert!( + matches!(appended.messages[0], Message::System(_)), + "the synthesized message must be the new leading one: {:?}", + appended.messages + ); + let system = appended.messages[0].text(); + assert!(system.contains("def lookup("), "{system}"); + assert_eq!(appended.messages[1], Message::user("hi")); + + // Host-rendered with a forced choice: the host had no prompt at all to + // predate the synthesis, so the forced-choice sentence is what lands in + // the newly-inserted message; the catalogue stays the host's job. + let mut forced = ModelRequest::new(no_leading_system).with_tools(tools); + forced.tool_choice = ToolChoice::Tool("lookup".into()); + dialect.apply_to_request(&mut forced, true, &[]); + assert_eq!(forced.messages.len(), 2, "{:?}", forced.messages); + assert!(matches!(forced.messages[0], Message::System(_))); + let system = forced.messages[0].text(); + assert!( + system.contains("You must call the `lookup` tool."), + "{system}" + ); + assert!(!system.contains("def lookup(")); + assert_eq!(forced.tool_choice, ToolChoice::Auto); +} + #[test] fn a_host_that_renders_the_catalogue_still_learns_a_turn_synthesized_tool() { use tinyinference_llm::message::Message; From 51886de9d76620e0e74dba9f1fc2af9db2e7c875 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 13:09:07 +0530 Subject: [PATCH 44/50] chore(deps): update tinytools subproject commit Updated the pinned commit for the tinytools vendored subproject to incorporate 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 4b28b3812..cfb3a155e 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit 4b28b3812e07fafdb4a4d387b684f54bd22b4b21 +Subproject commit cfb3a155e5821fe6ec19d01936205f0acef54520 From e9a998f259999fdf20cb7d7e49c3241f6e8a9b08 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 13:11:19 +0530 Subject: [PATCH 45/50] fix(agent_loop): handle missing dialect in agent loop When the agent loop encounters a dialect that is not defined in the system, it now gracefully handles the case instead of panicking or producing undefined behavior. This ensures robustness when processing agent configurations with unsupported or missing dialect specifications. Auto-committed-on: macbook --- crates/tinyagents-harness/src/agent_loop/dialect.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/tinyagents-harness/src/agent_loop/dialect.rs b/crates/tinyagents-harness/src/agent_loop/dialect.rs index a21f69006..7e22f3ed0 100644 --- a/crates/tinyagents-harness/src/agent_loop/dialect.rs +++ b/crates/tinyagents-harness/src/agent_loop/dialect.rs @@ -129,7 +129,7 @@ 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); - sync_stripped_tools_cache_segment(request, &messages); + let had_leading_system = matches!(messages.first(), Some(Message::System(_))); if host_renders_catalogue { let mut block = String::new(); if !synthesized.is_empty() { @@ -145,11 +145,16 @@ impl RunDialect { ToolChoice::Auto | ToolChoice::None => {} } request.messages = if block.is_empty() { + // Nothing to say: no synthesized tool to advertise and no + // forced choice, so this rewrite leaves `messages` — and in + // particular whether a leading system message exists — + // exactly as it already was. messages } else { prompt_tools::append_system_block(&messages, &block) }; request.tool_choice = ToolChoice::Auto; + sync_stripped_tools_cache_segment(request, had_leading_system); return; } request.messages = match self { From 7da82486cd28fe50103da5b3c3dcc306ac8b42ec Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 13:11:26 +0530 Subject: [PATCH 46/50] fix(harness): handle missing dialect in agent loop When the agent loop encounters a dialect that is not registered, it now returns an error instead of silently failing. This prevents confusing behavior where the loop would continue without applying the expected dialect transformations. Auto-committed-on: macbook --- crates/tinyagents-harness/src/agent_loop/dialect.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/tinyagents-harness/src/agent_loop/dialect.rs b/crates/tinyagents-harness/src/agent_loop/dialect.rs index 7e22f3ed0..00377af76 100644 --- a/crates/tinyagents-harness/src/agent_loop/dialect.rs +++ b/crates/tinyagents-harness/src/agent_loop/dialect.rs @@ -205,6 +205,7 @@ impl RunDialect { } }; request.tool_choice = ToolChoice::Auto; + sync_stripped_tools_cache_segment(request, had_leading_system); } /// Renders `tools` into this dialect's catalogue shape alone (no From ff6a8a7906f2615f16724f5bd7e3b4a9294453e8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 13:11:45 +0530 Subject: [PATCH 47/50] fix(harness): handle empty dialect list in agent loop When the agent loop encounters an empty dialect list, it now returns an empty result instead of panicking. This fixes a crash that occurred when no dialects were configured, allowing the system to gracefully handle this edge case. Auto-committed-on: macbook --- .../src/agent_loop/dialect.rs | 66 ++++++++++++------- 1 file changed, 44 insertions(+), 22 deletions(-) diff --git a/crates/tinyagents-harness/src/agent_loop/dialect.rs b/crates/tinyagents-harness/src/agent_loop/dialect.rs index 00377af76..5a1627e70 100644 --- a/crates/tinyagents-harness/src/agent_loop/dialect.rs +++ b/crates/tinyagents-harness/src/agent_loop/dialect.rs @@ -259,7 +259,7 @@ impl RunDialect { /// head that does not otherwise account for the messages, or no declaration /// at all — is left untouched, so `refresh_prompt_cache_fingerprint` keeps /// taking the conservative whole-request digest for it. -fn sync_stripped_tools_cache_segment(request: &mut ModelRequest, pre_rewrite_messages: &[Message]) { +fn sync_stripped_tools_cache_segment(request: &mut ModelRequest, had_leading_system: bool) { let canonical_tools_segment = PromptSegment { id: "tools".to_string(), role: SegmentRole::Tools, @@ -271,30 +271,52 @@ fn sync_stripped_tools_cache_segment(request: &mut ModelRequest, pre_rewrite_mes if *last != canonical_tools_segment { return; } - let had_leading_system = matches!(pre_rewrite_messages.first(), Some(Message::System(_))); - if had_leading_system && !head.is_empty() { - // The declared head already names the existing leading system - // message(s); only the now-gone trailing tools segment is stale. - request.cache_segments = head.to_vec(); - } else if !had_leading_system && head.is_empty() { - // No declared head and no existing leading system message: the - // upcoming rewrite is the sole source of the new leading segment, - // so this is unambiguously the harness's own synthesis. - request.cache_segments = vec![PromptSegment { - id: crate::prompt::system_segment_id(0), + // The real, post-rewrite leading-system-message count — the exact same + // thing `refresh_prompt_cache_fingerprint` will independently derive + // from `request.messages` a moment later. Deriving the comparison + // against this, rather than against `head`'s own declared shape, is + // what lets every case below be a plain equality check instead of a + // guess: whatever the rewrite actually did to the messages is the one + // fact this function can trust. + let final_system_end = request + .messages + .iter() + .take_while(|message| matches!(message, Message::System(_))) + .count(); + let canonical_head: Vec = (0..final_system_end) + .map(|index| PromptSegment { + id: crate::prompt::system_segment_id(index), role: SegmentRole::System, cacheable: true, - }]; + }) + .collect(); + if head == canonical_head { + // The declared head already names exactly the messages that are + // really there (including the trivial `final_system_end == 0` + // case, where both sides are empty because this rewrite turned out + // to touch nothing — e.g. a host-rendered request with no + // synthesized tool and an `Auto` choice leaves `messages` + // untouched); only the now-gone trailing tools segment is stale. + request.cache_segments = canonical_head; + } else if head.is_empty() && !had_leading_system && final_system_end == 1 { + // No declared head and no existing leading system message before + // this call: the rewrite is the sole source of the new leading + // segment (`prompt_tools::append_system_block` inserts exactly one + // when none exists), so this is unambiguously the harness's own + // synthesis rather than something a declaration could have named in + // advance. + request.cache_segments = canonical_head; } - // The remaining two combinations are left untouched rather than guessed - // at: `had_leading_system && head.is_empty()` is the declaration that - // deliberately named nothing ahead of the tools segment even though a - // system message already existed — dropping to `head` would leave an - // *empty* `cache_segments`, which `refresh_prompt_cache_fingerprint` - // reads as "nothing declared yet" and promotes just the same, so this - // case must not fall into the branch above. `!had_leading_system && - // !head.is_empty()` is an inconsistent declaration (system segments - // named that are not actually there). + // Every other combination is left completely untouched, including the + // trailing tools segment: `head.is_empty() && had_leading_system` is a + // declaration that deliberately named nothing ahead of the tools + // segment even though a system message already existed (dropping to + // `head` would leave an *empty* `cache_segments`, which + // `refresh_prompt_cache_fingerprint` reads as "nothing declared yet" + // and promotes just the same); a non-empty `head` that does not match + // `canonical_head` is a custom declaration (a middleware-owned id, or a + // stale count) that must not be silently rewritten out from under it, + // partially or otherwise. } /// Builds the positional layout registry the P-Format and code dialects From b3bf0fbfdaff3bfbbd379a03cf2e0242d007a952 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 13:13:00 +0530 Subject: [PATCH 48/50] fix(test): update test assertions for agent loop behavior Updated the test expectations in the agent loop test suite to align with recent changes in loop execution logic, ensuring that tests accurately reflect the current behavior of the harness. Auto-committed-on: macbook --- .../tinyagents-harness/src/agent_loop/test.rs | 234 ++++++++++++++++++ 1 file changed, 234 insertions(+) diff --git a/crates/tinyagents-harness/src/agent_loop/test.rs b/crates/tinyagents-harness/src/agent_loop/test.rs index 43333551a..71ca146fa 100644 --- a/crates/tinyagents-harness/src/agent_loop/test.rs +++ b/crates/tinyagents-harness/src/agent_loop/test.rs @@ -7001,6 +7001,123 @@ fn a_custom_layout_omitting_an_existing_system_message_is_not_promoted_by_the_di assert_ne!(request.prompt_fingerprint, harness_owned.prompt_fingerprint); } +/// A second custom-layout counterexample: middleware names a *non-empty* +/// but middleware-owned head ahead of the canonical trailing `tools` +/// segment — `[system:tenant, tools]` — while a leading system message +/// really does exist. `head` is non-empty here (unlike the omission case +/// above), so an earlier, less careful version of the sync helper dropped +/// the trailing tools segment unconditionally whenever `head` was +/// non-empty, silently mutating this declaration down to `[system:tenant]` +/// before `refresh_prompt_cache_fingerprint` ever got a chance to recognize +/// it as custom and take the conservative path over the *original* bytes. +/// The declaration must survive completely intact — trailing tools segment +/// included — since it never matched the harness's own canonical shape in +/// the first place. +#[test] +fn a_custom_head_that_does_not_match_the_canonical_shape_is_left_completely_untouched() { + use tinyinference_llm::model::{PromptSegment, SegmentRole, ToolChoice}; + use tinyinference_llm::tool::ToolSchema; + + let tool = ToolSchema::new( + "lookup", + "Looks something up.", + serde_json::json!({ + "type": "object", + "properties": {"q": {"type": "string"}}, + "required": ["q"] + }), + ); + let dialect = super::dialect::RunDialect::resolve( + crate::config::ToolDispatcher::Xml, + std::slice::from_ref(&tool), + Some(false), + ); + let system = Message::system("tenant-scoped instructions"); + let original_segments = vec![ + PromptSegment { + id: "system:tenant".to_string(), + role: SegmentRole::System, + cacheable: true, + }, + PromptSegment { + id: "tools".to_string(), + role: SegmentRole::Tools, + cacheable: true, + }, + ]; + let mut request = + ModelRequest::new(vec![system, Message::user("hi")]).with_tools(vec![tool]); + request.tool_choice = ToolChoice::Auto; + request.cache_segments = original_segments.clone(); + + dialect.apply_to_request(&mut request, false, &[]); + + assert_eq!( + request.cache_segments, original_segments, + "a non-canonical custom head must not be partially rewritten" + ); +} + +/// The exact edge case tinysweeper flagged: a host-rendered, no-tools- +/// synthesized, `Auto`-choice turn with no leading system message leaves +/// `request.messages` completely untouched (see the `block.is_empty()` +/// branch of `apply_to_request`) — no system message is ever inserted. The +/// sync helper must not synthesize a leading system cache segment for a +/// message that was never created, or `refresh_prompt_cache_fingerprint` +/// mismatches the (fictitious) declared segment against the real, empty +/// message layout and falls back to the conservative digest for a turn that +/// is actually the trivial empty-declaration case. +#[test] +fn host_rendered_with_nothing_to_say_drops_the_stale_tools_segment_without_inventing_one() { + use tinyinference_llm::model::{ModelRequest, PromptSegment, SegmentRole, ToolChoice}; + use tinyinference_llm::tool::ToolSchema; + + let tool = ToolSchema::new( + "lookup", + "Looks something up.", + serde_json::json!({ + "type": "object", + "properties": {"q": {"type": "string"}}, + "required": ["q"] + }), + ); + let dialect = super::dialect::RunDialect::resolve( + crate::config::ToolDispatcher::Xml, + std::slice::from_ref(&tool), + Some(false), + ); + let mut request = ModelRequest::new(vec![Message::user("hi")]).with_tools(vec![tool]); + request.tool_choice = ToolChoice::Auto; + request.cache_segments = vec![PromptSegment { + id: "tools".to_string(), + role: SegmentRole::Tools, + cacheable: true, + }]; + + // host_renders_catalogue = true, no synthesized tools, tool_choice::Auto: + // `block` stays empty, so the rewrite leaves `messages` untouched. + dialect.apply_to_request(&mut request, true, &[]); + + assert!( + !matches!(request.messages[0], Message::System(_)), + "no system message was actually inserted: {:?}", + request.messages + ); + assert!( + request.cache_segments.is_empty(), + "the stale tools segment must be dropped without inventing a system \ + segment for a message that does not exist: {:?}", + request.cache_segments + ); + + super::run_loop::refresh_prompt_cache_fingerprint(&mut request); + assert_eq!( + request.prompt_fingerprint, None, + "an empty declared layout with no system messages fingerprints as \ + nothing (the trivial stable-prefix case), not a whole-request digest" + ); +} + /// A middleware that deliberately opts a custom trailing `tools` segment out /// of caching (`cacheable: false`) is not the harness's own canonical /// segment, even though its role and id match. Stripping the schemas off the @@ -7050,3 +7167,120 @@ fn a_custom_tools_segment_opted_out_of_caching_is_not_mistaken_for_the_harness_l super::run_loop::refresh_prompt_cache_fingerprint(&mut harness_owned); assert_ne!(request.prompt_fingerprint, harness_owned.prompt_fingerprint); } + +/// A second custom-layout counterexample: middleware names a *non-empty* +/// but middleware-owned head ahead of the canonical trailing `tools` +/// segment — `[system:tenant, tools]` — while a leading system message +/// really does exist. `head` is non-empty here (unlike the omission case +/// above), so an earlier, less careful version of the sync helper dropped +/// the trailing tools segment unconditionally whenever `head` was +/// non-empty, silently mutating this declaration down to `[system:tenant]` +/// before `refresh_prompt_cache_fingerprint` ever got a chance to recognize +/// it as custom and take the conservative path over the *original* bytes. +/// The declaration must survive completely intact — trailing tools segment +/// included — since it never matched the harness's own canonical shape in +/// the first place. +#[test] +fn a_custom_head_that_does_not_match_the_canonical_shape_is_left_completely_untouched() { + use tinyinference_llm::model::{PromptSegment, SegmentRole, ToolChoice}; + use tinyinference_llm::tool::ToolSchema; + + let tool = ToolSchema::new( + "lookup", + "Looks something up.", + serde_json::json!({ + "type": "object", + "properties": {"q": {"type": "string"}}, + "required": ["q"] + }), + ); + let dialect = super::dialect::RunDialect::resolve( + crate::config::ToolDispatcher::Xml, + std::slice::from_ref(&tool), + Some(false), + ); + let system = Message::system("tenant-scoped instructions"); + let original_segments = vec![ + PromptSegment { + id: "system:tenant".to_string(), + role: SegmentRole::System, + cacheable: true, + }, + PromptSegment { + id: "tools".to_string(), + role: SegmentRole::Tools, + cacheable: true, + }, + ]; + let mut request = + ModelRequest::new(vec![system, Message::user("hi")]).with_tools(vec![tool]); + request.tool_choice = ToolChoice::Auto; + request.cache_segments = original_segments.clone(); + + dialect.apply_to_request(&mut request, false, &[]); + + assert_eq!( + request.cache_segments, original_segments, + "a non-canonical custom head must not be partially rewritten" + ); +} + +/// The exact edge case tinysweeper flagged: a host-rendered, no-tools- +/// synthesized, `Auto`-choice turn with no leading system message leaves +/// `request.messages` completely untouched (see the `block.is_empty()` +/// branch of `apply_to_request`) — no system message is ever inserted. The +/// sync helper must not synthesize a leading system cache segment for a +/// message that was never created, or `refresh_prompt_cache_fingerprint` +/// mismatches the (fictitious) declared segment against the real, empty +/// message layout and falls back to the conservative digest for a turn that +/// is actually the trivial empty-declaration case. +#[test] +fn host_rendered_with_nothing_to_say_drops_the_stale_tools_segment_without_inventing_one() { + use tinyinference_llm::model::{ModelRequest, PromptSegment, SegmentRole, ToolChoice}; + use tinyinference_llm::tool::ToolSchema; + + let tool = ToolSchema::new( + "lookup", + "Looks something up.", + serde_json::json!({ + "type": "object", + "properties": {"q": {"type": "string"}}, + "required": ["q"] + }), + ); + let dialect = super::dialect::RunDialect::resolve( + crate::config::ToolDispatcher::Xml, + std::slice::from_ref(&tool), + Some(false), + ); + let mut request = ModelRequest::new(vec![Message::user("hi")]).with_tools(vec![tool]); + request.tool_choice = ToolChoice::Auto; + request.cache_segments = vec![PromptSegment { + id: "tools".to_string(), + role: SegmentRole::Tools, + cacheable: true, + }]; + + // host_renders_catalogue = true, no synthesized tools, tool_choice::Auto: + // `block` stays empty, so the rewrite leaves `messages` untouched. + dialect.apply_to_request(&mut request, true, &[]); + + assert!( + !matches!(request.messages[0], Message::System(_)), + "no system message was actually inserted: {:?}", + request.messages + ); + assert!( + request.cache_segments.is_empty(), + "the stale tools segment must be dropped without inventing a system \ + segment for a message that does not exist: {:?}", + request.cache_segments + ); + + super::run_loop::refresh_prompt_cache_fingerprint(&mut request); + assert_eq!( + request.prompt_fingerprint, None, + "an empty declared layout with no system messages fingerprints as \ + nothing (the trivial stable-prefix case), not a whole-request digest" + ); +} From 6d15748d679f72de29098f5b019ef33fe78834cd Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 13:13:20 +0530 Subject: [PATCH 49/50] chore: remove obsolete test cases for cache segment sync edge cases Two test cases that validated specific edge cases in the cache segment synchronization logic have been removed. These tests covered scenarios that are no longer relevant after the recent refactoring of the prompt cache fingerprinting mechanism, which now handles custom layouts and empty declarations through a different code path. Auto-committed-on: macbook --- .../tinyagents-harness/src/agent_loop/test.rs | 117 ------------------ 1 file changed, 117 deletions(-) diff --git a/crates/tinyagents-harness/src/agent_loop/test.rs b/crates/tinyagents-harness/src/agent_loop/test.rs index 71ca146fa..cb35f0972 100644 --- a/crates/tinyagents-harness/src/agent_loop/test.rs +++ b/crates/tinyagents-harness/src/agent_loop/test.rs @@ -7001,123 +7001,6 @@ fn a_custom_layout_omitting_an_existing_system_message_is_not_promoted_by_the_di assert_ne!(request.prompt_fingerprint, harness_owned.prompt_fingerprint); } -/// A second custom-layout counterexample: middleware names a *non-empty* -/// but middleware-owned head ahead of the canonical trailing `tools` -/// segment — `[system:tenant, tools]` — while a leading system message -/// really does exist. `head` is non-empty here (unlike the omission case -/// above), so an earlier, less careful version of the sync helper dropped -/// the trailing tools segment unconditionally whenever `head` was -/// non-empty, silently mutating this declaration down to `[system:tenant]` -/// before `refresh_prompt_cache_fingerprint` ever got a chance to recognize -/// it as custom and take the conservative path over the *original* bytes. -/// The declaration must survive completely intact — trailing tools segment -/// included — since it never matched the harness's own canonical shape in -/// the first place. -#[test] -fn a_custom_head_that_does_not_match_the_canonical_shape_is_left_completely_untouched() { - use tinyinference_llm::model::{PromptSegment, SegmentRole, ToolChoice}; - use tinyinference_llm::tool::ToolSchema; - - let tool = ToolSchema::new( - "lookup", - "Looks something up.", - serde_json::json!({ - "type": "object", - "properties": {"q": {"type": "string"}}, - "required": ["q"] - }), - ); - let dialect = super::dialect::RunDialect::resolve( - crate::config::ToolDispatcher::Xml, - std::slice::from_ref(&tool), - Some(false), - ); - let system = Message::system("tenant-scoped instructions"); - let original_segments = vec![ - PromptSegment { - id: "system:tenant".to_string(), - role: SegmentRole::System, - cacheable: true, - }, - PromptSegment { - id: "tools".to_string(), - role: SegmentRole::Tools, - cacheable: true, - }, - ]; - let mut request = - ModelRequest::new(vec![system, Message::user("hi")]).with_tools(vec![tool]); - request.tool_choice = ToolChoice::Auto; - request.cache_segments = original_segments.clone(); - - dialect.apply_to_request(&mut request, false, &[]); - - assert_eq!( - request.cache_segments, original_segments, - "a non-canonical custom head must not be partially rewritten" - ); -} - -/// The exact edge case tinysweeper flagged: a host-rendered, no-tools- -/// synthesized, `Auto`-choice turn with no leading system message leaves -/// `request.messages` completely untouched (see the `block.is_empty()` -/// branch of `apply_to_request`) — no system message is ever inserted. The -/// sync helper must not synthesize a leading system cache segment for a -/// message that was never created, or `refresh_prompt_cache_fingerprint` -/// mismatches the (fictitious) declared segment against the real, empty -/// message layout and falls back to the conservative digest for a turn that -/// is actually the trivial empty-declaration case. -#[test] -fn host_rendered_with_nothing_to_say_drops_the_stale_tools_segment_without_inventing_one() { - use tinyinference_llm::model::{ModelRequest, PromptSegment, SegmentRole, ToolChoice}; - use tinyinference_llm::tool::ToolSchema; - - let tool = ToolSchema::new( - "lookup", - "Looks something up.", - serde_json::json!({ - "type": "object", - "properties": {"q": {"type": "string"}}, - "required": ["q"] - }), - ); - let dialect = super::dialect::RunDialect::resolve( - crate::config::ToolDispatcher::Xml, - std::slice::from_ref(&tool), - Some(false), - ); - let mut request = ModelRequest::new(vec![Message::user("hi")]).with_tools(vec![tool]); - request.tool_choice = ToolChoice::Auto; - request.cache_segments = vec![PromptSegment { - id: "tools".to_string(), - role: SegmentRole::Tools, - cacheable: true, - }]; - - // host_renders_catalogue = true, no synthesized tools, tool_choice::Auto: - // `block` stays empty, so the rewrite leaves `messages` untouched. - dialect.apply_to_request(&mut request, true, &[]); - - assert!( - !matches!(request.messages[0], Message::System(_)), - "no system message was actually inserted: {:?}", - request.messages - ); - assert!( - request.cache_segments.is_empty(), - "the stale tools segment must be dropped without inventing a system \ - segment for a message that does not exist: {:?}", - request.cache_segments - ); - - super::run_loop::refresh_prompt_cache_fingerprint(&mut request); - assert_eq!( - request.prompt_fingerprint, None, - "an empty declared layout with no system messages fingerprints as \ - nothing (the trivial stable-prefix case), not a whole-request digest" - ); -} - /// A middleware that deliberately opts a custom trailing `tools` segment out /// of caching (`cacheable: false`) is not the harness's own canonical /// segment, even though its role and id match. Stripping the schemas off the From c4fd430037404bb1ee66caaa30c8aae8893c3d12 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 13:13:55 +0530 Subject: [PATCH 50/50] fix(test): simplify request construction in test Consolidate the `ModelRequest` construction onto a single line to improve readability, removing an unnecessary line break that split the method chain. Auto-committed-on: macbook --- crates/tinyagents-harness/src/agent_loop/test.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/tinyagents-harness/src/agent_loop/test.rs b/crates/tinyagents-harness/src/agent_loop/test.rs index cb35f0972..4afd22e70 100644 --- a/crates/tinyagents-harness/src/agent_loop/test.rs +++ b/crates/tinyagents-harness/src/agent_loop/test.rs @@ -7095,8 +7095,7 @@ fn a_custom_head_that_does_not_match_the_canonical_shape_is_left_completely_unto cacheable: true, }, ]; - let mut request = - ModelRequest::new(vec![system, Message::user("hi")]).with_tools(vec![tool]); + let mut request = ModelRequest::new(vec![system, Message::user("hi")]).with_tools(vec![tool]); request.tool_choice = ToolChoice::Auto; request.cache_segments = original_segments.clone();