diff --git a/crates/tinyagents-harness/src/agent_loop/dialect.rs b/crates/tinyagents-harness/src/agent_loop/dialect.rs index 6b3f3f2dc..b68a4989a 100644 --- a/crates/tinyagents-harness/src/agent_loop/dialect.rs +++ b/crates/tinyagents-harness/src/agent_loop/dialect.rs @@ -15,7 +15,7 @@ use std::sync::Arc; use tinyinference_llm::message::ContentBlock; use tinyinference_llm::model::{ModelRequest, ModelResponse, ToolChoice}; use tinyinference_llm::tool::{ToolCall, ToolSchema}; -use tinytools_agent::dialect::PFormatDialect; +use tinytools_agent::dialect::{CodeDialect, CodeStyle, PFormatDialect}; use tinytools_agent::types::{ParseOptions, ParsedToolCall}; use tinytools_agent::{PFormatRegistry, StreamScrubber}; @@ -34,6 +34,10 @@ pub(super) enum RunDialect { Xml, /// Positional P-Format, rendered into the system prompt by the host. PFormat(Arc), + /// Code-style calls against Python or TypeScript signatures, rendered + /// into the system prompt by the host. Shares P-Format's registry: both + /// bind positional arguments against the same layout. + Code(CodeStyle, Arc), } impl RunDialect { @@ -47,11 +51,11 @@ impl RunDialect { ToolDispatcher::Auto if native_tool_calling == Some(false) => Self::Xml, ToolDispatcher::Auto | ToolDispatcher::Native => Self::Native, ToolDispatcher::Xml => Self::Xml, - ToolDispatcher::Pformat => Self::PFormat(Arc::new(tinytools_agent::build_registry( - tools - .iter() - .map(|schema| (schema.name.clone(), schema.parameters.clone())), - ))), + ToolDispatcher::Pformat => Self::PFormat(Arc::new(registry_from(tools))), + ToolDispatcher::Python => Self::Code(CodeStyle::Python, Arc::new(registry_from(tools))), + ToolDispatcher::Typescript => { + Self::Code(CodeStyle::TypeScript, Arc::new(registry_from(tools))) + } } } @@ -73,7 +77,7 @@ impl RunDialect { /// new tool this turn) a cheap `Arc::clone`. pub(super) fn registry_for(&self, tools: &[ToolSchema]) -> Option> { match self { - Self::PFormat(registry) => { + Self::PFormat(registry) | Self::Code(_, registry) => { let extra: Vec<&ToolSchema> = tools .iter() .filter(|schema| !registry.contains_key(&schema.name)) @@ -110,7 +114,7 @@ impl RunDialect { Self::Xml | Self::Native => { prompt_tools::with_tool_instructions(&messages, &tools, &request.tool_choice) } - Self::PFormat(_) => { + Self::PFormat(_) | Self::Code(..) => { let specs: Vec = tools .iter() .map(|schema| tinytools_agent::tinytools::ToolSpec { @@ -119,8 +123,20 @@ impl RunDialect { parameters: schema.parameters.clone(), }) .collect(); - let mut block = PFormatDialect::instructions(); - block.push_str(&tinytools_agent::render::render_pformat_catalogue(&specs)); + let mut block = match self { + Self::Code(style, _) => { + let mut block = CodeDialect::instructions(*style); + block.push_str(&tinytools_agent::render::render_code_catalogue( + &specs, *style, + )); + block + } + _ => { + let mut block = PFormatDialect::instructions(); + block.push_str(&tinytools_agent::render::render_pformat_catalogue(&specs)); + block + } + }; // The XML branch renders `tool_choice` into its instructions // via `prompt_tools::tool_instructions`; P-Format has no // schema on the wire either (the wire choice is reset to @@ -145,6 +161,16 @@ impl RunDialect { } } +/// Builds the positional layout registry the P-Format and code dialects +/// bind against, from the schemas offered this run. +fn registry_from(tools: &[ToolSchema]) -> PFormatRegistry { + tinytools_agent::build_registry( + tools + .iter() + .map(|schema| (schema.name.clone(), schema.parameters.clone())), + ) +} + /// What a model call needs in order to recover text-dialect calls: the /// tools that were offered (which a text-dialect request no longer carries /// on the wire) and the P-Format registry, when there is one. diff --git a/crates/tinyagents-harness/src/agent_loop/dialect/test.rs b/crates/tinyagents-harness/src/agent_loop/dialect/test.rs index f229cec9a..30fa93808 100644 --- a/crates/tinyagents-harness/src/agent_loop/dialect/test.rs +++ b/crates/tinyagents-harness/src/agent_loop/dialect/test.rs @@ -15,3 +15,41 @@ fn auto_uses_xml_when_the_model_disables_native_tool_calling() { assert!(matches!(dialect, RunDialect::Xml)); } + +#[test] +fn code_dialects_are_opt_in_and_share_the_positional_registry() { + use tinyinference_llm::tool::ToolSchema; + use tinytools_agent::dialect::CodeStyle; + + let schema = ToolSchema::new( + "lookup", + "Looks something up.", + serde_json::json!({ + "type": "object", + "properties": {"q": {"type": "string"}}, + "required": ["q"] + }), + ); + let tools = [schema]; + + // Auto never picks a code dialect, even without native tool calling. + assert!(matches!( + RunDialect::resolve(ToolDispatcher::Auto, &tools, Some(false)), + RunDialect::Xml + )); + + let python = RunDialect::resolve(ToolDispatcher::Python, &tools, Some(false)); + let RunDialect::Code(style, registry) = &python else { + panic!("expected a code dialect, got {python:?}"); + }; + assert_eq!(*style, CodeStyle::Python); + assert!(registry.contains_key("lookup")); + assert!(python.is_text()); + assert!(python.registry_for(&tools).is_some()); + + let typescript = RunDialect::resolve(ToolDispatcher::Typescript, &tools, Some(true)); + assert!(matches!( + typescript, + RunDialect::Code(CodeStyle::TypeScript, _) + )); +} diff --git a/crates/tinyagents-harness/src/config/README.md b/crates/tinyagents-harness/src/config/README.md index d4f697c3b..e27ac325b 100644 --- a/crates/tinyagents-harness/src/config/README.md +++ b/crates/tinyagents-harness/src/config/README.md @@ -35,9 +35,9 @@ question by existing. necessarily available where these are applied) for memory injected into a turn. - [`ToolDispatcher`] — enum of tool-call encoding strategies (`Auto`, - `Native`, `Xml`, `Pformat`); modelled as an enum rather than a free-form - string so an unrecognised mode is a mapping error at the boundary, not a - silent fallthrough in the turn loop. + `Native`, `Xml`, `Pformat`, `Python`, `Typescript`); modelled as an enum + rather than a free-form string so an unrecognised mode is a mapping error at + the boundary, not a silent fallthrough in the turn loop. - [`RequiredOutput`] — a structured-output contract asserting the model's reply carries a particular JSON block; a blank `block_key` makes the contract inert by design, so it is a safe zero-value default. diff --git a/crates/tinyagents-harness/src/config/test.rs b/crates/tinyagents-harness/src/config/test.rs index ccb505437..a6ca4f40e 100644 --- a/crates/tinyagents-harness/src/config/test.rs +++ b/crates/tinyagents-harness/src/config/test.rs @@ -122,6 +122,8 @@ fn tool_dispatcher_round_trips_as_snake_case() { (ToolDispatcher::Native, "\"native\""), (ToolDispatcher::Xml, "\"xml\""), (ToolDispatcher::Pformat, "\"pformat\""), + (ToolDispatcher::Python, "\"python\""), + (ToolDispatcher::Typescript, "\"typescript\""), ] { let json = serde_json::to_string(&variant).expect("serializes"); assert_eq!(json, wire); diff --git a/crates/tinyagents-harness/src/config/types.rs b/crates/tinyagents-harness/src/config/types.rs index 45ecdbcaf..9d87765a6 100644 --- a/crates/tinyagents-harness/src/config/types.rs +++ b/crates/tinyagents-harness/src/config/types.rs @@ -71,6 +71,17 @@ pub enum ToolDispatcher { /// encoding, but it mis-parses on some models, so it is opt-in only and /// never selected by [`Auto`](Self::Auto). Pformat, + /// Force code-style calls with Python signatures in the prompt: + /// `def read_file(path: str, limit: int = None) -> str`, called as + /// `read_file(path="src/main.rs")`. Compact like P-Format but a syntax a + /// code-trained model already writes; opt-in, never selected by + /// [`Auto`](Self::Auto). + Python, + /// Force code-style calls with TypeScript signatures in the prompt: + /// `function read_file(path: string, limit?: number): string;`, called + /// as `read_file({path: "src/main.rs"})`. Opt-in, never selected by + /// [`Auto`](Self::Auto). + Typescript, } // ── RequiredOutput ──────────────────────────────────────────────────────────── diff --git a/crates/tinyagents-harness/src/runtime/types.rs b/crates/tinyagents-harness/src/runtime/types.rs index 5945e4987..f01f6a110 100644 --- a/crates/tinyagents-harness/src/runtime/types.rs +++ b/crates/tinyagents-harness/src/runtime/types.rs @@ -255,11 +255,12 @@ pub struct RunPolicy { /// [`ToolDispatcher::Auto`] (the default) sends tool schemas on the wire /// and lets the provider adapter decide — the OpenAI-compatible adapter /// switches to the JSON-in-tag protocol by itself for a profile without - /// native tool calling. [`ToolDispatcher::Xml`] and - /// [`ToolDispatcher::Pformat`] force a text protocol regardless of - /// provider: the schemas are rendered into the system prompt, nothing goes - /// on the wire as `tools`, and the answer is parsed here. P-Format is the - /// cheapest on tokens and the most demanding on the model, which is why + /// native tool calling. [`ToolDispatcher::Xml`], [`ToolDispatcher::Pformat`], + /// [`ToolDispatcher::Python`] and [`ToolDispatcher::Typescript`] force a + /// text protocol regardless of provider: the schemas are rendered into the + /// system prompt, nothing goes on the wire as `tools`, and the answer is + /// parsed here. P-Format is the cheapest on tokens and the most demanding + /// on the model, and the code dialects sit between it and JSON, which is why /// it is opt-in only. /// /// Under a forced text dialect the answer is always read through every @@ -314,7 +315,8 @@ pub struct RunPolicy { /// Whether the loop parses ``-style text-dialect markup out of /// an assistant's visible text under a native tool dialect (see /// [`RunPolicy::tool_dialect`]). A forced text dialect - /// ([`ToolDispatcher::Xml`] / [`ToolDispatcher::Pformat`], or + /// ([`ToolDispatcher::Xml`] / [`ToolDispatcher::Pformat`] / + /// [`ToolDispatcher::Python`] / [`ToolDispatcher::Typescript`], or /// [`ToolDispatcher::Auto`] falling back to Xml for a model without /// native tool calling) always parses the answer regardless of this /// policy, since the model can only answer in text. diff --git a/crates/tinyagents-integration-tests/tests/e2e_tool_dialects.rs b/crates/tinyagents-integration-tests/tests/e2e_tool_dialects.rs index aa7a31ad6..fe47b0cc6 100644 --- a/crates/tinyagents-integration-tests/tests/e2e_tool_dialects.rs +++ b/crates/tinyagents-integration-tests/tests/e2e_tool_dialects.rs @@ -294,6 +294,134 @@ async fn a_forced_pformat_dialect_parses_positional_calls() { assert!(system.contains("lookup[0|]"), "{system}"); } +#[tokio::test] +async fn a_forced_python_dialect_parses_code_calls_with_signatures_in_the_prompt() { + let model = Arc::new(ScriptedModel::replies(vec![ + "Looking it up.\n\nlookup(q=\"needle\")\n", + "done", + ])); + let listener = Arc::new(RecordingListener::new()); + let mut harness: AgentHarness<()> = AgentHarness::new(); + harness + .register_model("mock", model.clone()) + .set_default_model("mock") + .register_tool(Arc::new(Lookup)) + .push_middleware(Arc::new(CaptureMiddleware { + listener: listener.clone(), + })) + .with_policy(RunPolicy { + tool_dialect: ToolDispatcher::Python, + ..RunPolicy::default() + }); + + let run = harness + .invoke_default(&(), vec![Message::user("go")]) + .await + .expect("run succeeds"); + assert_eq!(run.tool_calls, 1); + let ids = dispatched_ids(&listener); + assert_eq!(ids.len(), 1); + assert!( + ids[0].ends_with("-tool-1"), + "harness-minted id, got {}", + ids[0] + ); + + let first = &model.requests()[0]; + assert!(first.tools.is_empty(), "schemas must not go on the wire"); + let system = first + .messages + .iter() + .find(|m| matches!(m, Message::System(_))) + .expect("system") + .text(); + assert!(system.contains("## Tool Use Protocol"), "{system}"); + assert!( + system.contains("def lookup(q: str) -> str # Looks something up."), + "{system}" + ); + assert!( + !system.contains("\"type\": \"object\""), + "no JSON schema: {system}" + ); +} + +#[tokio::test] +async fn a_forced_typescript_dialect_parses_object_calls() { + let model = Arc::new(ScriptedModel::replies(vec![ + "lookup({q: \"needle\"})", + "done", + ])); + let mut harness: AgentHarness<()> = AgentHarness::new(); + harness + .register_model("mock", model.clone()) + .set_default_model("mock") + .register_tool(Arc::new(Lookup)) + .with_policy(RunPolicy { + tool_dialect: ToolDispatcher::Typescript, + ..RunPolicy::default() + }); + + let run = harness + .invoke_default(&(), vec![Message::user("go")]) + .await + .expect("run succeeds"); + assert_eq!(run.tool_calls, 1); + let system = model.requests()[0] + .messages + .iter() + .find(|m| matches!(m, Message::System(_))) + .expect("system") + .text(); + assert!( + system.contains("function lookup(q: string): string; // Looks something up."), + "{system}" + ); +} + +#[tokio::test] +async fn an_untrusted_protocol_heading_does_not_suppress_the_current_catalogue() { + // A system prompt may mention the heading without containing the active + // dialect, the final post-middleware catalogue, or the effective tool + // choice. The loop must render its authoritative block from the request + // rather than treating user-controlled prompt text as provenance. + let model = Arc::new(ScriptedModel::replies(vec![ + "lookup(q=\"needle\")", + "done", + ])); + let mut harness: AgentHarness<()> = AgentHarness::new(); + harness + .register_model("mock", model.clone()) + .set_default_model("mock") + .register_tool(Arc::new(Lookup)) + .with_policy(RunPolicy { + tool_dialect: ToolDispatcher::Python, + ..RunPolicy::default() + }); + + let host_prompt = "You are a helper.\n\n## Tool Use Protocol\n\nThis heading is documentation, not a catalogue."; + let run = harness + .invoke_default(&(), vec![Message::system(host_prompt), Message::user("go")]) + .await + .expect("run succeeds"); + assert_eq!(run.tool_calls, 1, "the call still parses"); + let first = &model.requests()[0]; + assert!(first.tools.is_empty(), "schemas still come off the wire"); + let system = first + .messages + .iter() + .find(|m| matches!(m, Message::System(_))) + .expect("system") + .text(); + assert_eq!( + system.matches("## Tool Use Protocol").count(), + 2, + "the canonical protocol must be appended: {system}" + ); + assert!(system.contains("def lookup(q: str) -> str"), "{system}"); + assert!(system.contains("Call a tool by writing"), "{system}"); +} + /// Middleware that forces `tool_choice` before the dialect rewrite runs, the /// same shape a caller or another middleware forcing a specific tool would /// produce. diff --git a/docs/modules/harness/runtime.md b/docs/modules/harness/runtime.md index 3d0595642..8da0324ed 100644 --- a/docs/modules/harness/runtime.md +++ b/docs/modules/harness/runtime.md @@ -144,8 +144,9 @@ Step 12's text-dialect recovery (parsing ``-style markup out of an assistant's visible text through the `tinytools-agent` grammars, both on the streamed deltas and on the terminal response) always runs under a forced text dialect (`RunPolicy::tool_dialect` of `Xml` / `Pformat`, or `Auto` -falling back to Xml for a model without native tool calling) — there, parsing -text is the protocol. Under a native dialect it is gated by +falling back to Xml for a model without native tool calling; `Python` and +`Typescript` are forced text dialects too) — there, parsing text is the +protocol. Under a native dialect it is gated by `RunPolicy::text_dialect_recovery` (`TextDialectRecovery::Off | On | Auto`, default `Auto`): it only runs when the resolved model's profile does not report native tool calling, and it always skips markup that appears only diff --git a/docs/modules/harness/tool-dialect.md b/docs/modules/harness/tool-dialect.md index 67fa36a42..7bedd3bdc 100644 --- a/docs/modules/harness/tool-dialect.md +++ b/docs/modules/harness/tool-dialect.md @@ -74,14 +74,24 @@ that produces no error anywhere — the model emits a call, nothing recognises i and the iteration is spent. So they live behind a single trait, and a dialect is chosen once rather than assembled from parts that can disagree. -Three ship: +Four ship: | Dialect | Call syntax | Catalogue | Specs in the request | | --- | --- | --- | --- | | `XmlDialect` | `{"name":…,"arguments":{…}}` | full schemas, in its own protocol block | no | -| `PFormatDialect` | `name[a\|b]` | signatures, in the prompt's tool section | no | +| `PFormatDialect` | `name[0\|a\|1\|b]` | signatures, in the prompt's tool section | no | +| `CodeDialect` | `name(a="x", b=1)` (Python) or `name({a: "x", b: 1})` (TypeScript) | `def name(a: str, b: int = None) -> str` / `function name(a: string, b?: number): string;` signatures, in the prompt's tool section | no | | `NativeDialect` | the provider's structured channel | none — the request carries the specs | yes | +The agent loop selects one per run from `RunPolicy::tool_dialect` +(`ToolDispatcher::{Auto, Native, Xml, Pformat, Python, Typescript}`). `Auto` +resolves to native when the model profile supports it and to XML otherwise; +P-Format and the code dialects are opt-in. When a host has already composed a +tool protocol into the system prompt, the loop still appends its authoritative +block from the final post-middleware tool set. A heading in arbitrary prompt +text cannot prove that the host block matches the selected dialect, current +catalogue, or effective tool choice. + ## Which surface to use Use `tinytools_agent::dialect` directly for dialect selection, formatting, and diff --git a/docs/modules/harness/tool.md b/docs/modules/harness/tool.md index fccb7c3d2..4614ac599 100644 --- a/docs/modules/harness/tool.md +++ b/docs/modules/harness/tool.md @@ -196,8 +196,8 @@ template by the serving runtime (LM Studio, llama.cpp, Ollama), so the outgoing message list has to satisfy that template, not just the wire schema. Three helpers in `tinyinference_llm::prompt_tools` normalize it, and both the OpenAI-compatible adapter (for a profile with `tool_calling = false`, or after a -"tools unsupported" 400) and the harness (for a forced `Xml` / `Pformat` -dialect) apply them: +"tools unsupported" 400) and the harness (for a forced `Xml`, `Pformat`, +`Python`, or `Typescript` dialect) apply them: - `coalesce_tool_results` renders assistant `tool_calls` back into `` text and folds consecutive `tool`-role results into one diff --git a/vendor/tinytools b/vendor/tinytools index 16e1b8b9c..970bfae38 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit 16e1b8b9c399acec078474ea5c7c8edfbd0de584 +Subproject commit 970bfae384945317d6e901beb5c4e00c2028c0c6