Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 36 additions & 10 deletions crates/tinyagents-harness/src/agent_loop/dialect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand All @@ -34,6 +34,10 @@ pub(super) enum RunDialect {
Xml,
/// Positional P-Format, rendered into the system prompt by the host.
PFormat(Arc<PFormatRegistry>),
/// 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<PFormatRegistry>),
}

impl RunDialect {
Expand All @@ -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)))
}
}
}

Expand All @@ -73,7 +77,7 @@ impl RunDialect {
/// new tool this turn) a cheap `Arc::clone`.
pub(super) fn registry_for(&self, tools: &[ToolSchema]) -> Option<Arc<PFormatRegistry>> {
match self {
Self::PFormat(registry) => {
Self::PFormat(registry) | Self::Code(_, registry) => {
let extra: Vec<&ToolSchema> = tools
.iter()
.filter(|schema| !registry.contains_key(&schema.name))
Expand Down Expand Up @@ -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(..) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Replay prior calls using the selected code syntax

When either new code dispatcher completes a tool call and enters another model iteration, coalesce_tool_results has already serialized the recovered structured call back into the generic JSON-in-tag form before this Self::Code arm appends Python or TypeScript instructions. The next request therefore demonstrates one call syntax in its history while requiring another in its system catalogue, undermining the selected dialect precisely during multi-tool loops; make transcript coalescing dialect-aware or replay these calls through CodeDialect. This also conflicts with the updated module documentation's definition of a dialect as including its history replay shape.

AGENTS.md reference: AGENTS.md:L78-L82

Useful? React with 👍 / 👎.

let specs: Vec<tinytools_agent::tinytools::ToolSpec> = tools
.iter()
.map(|schema| tinytools_agent::tinytools::ToolSpec {
Expand All @@ -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
Expand All @@ -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.
Expand Down
38 changes: 38 additions & 0 deletions crates/tinyagents-harness/src/agent_loop/dialect/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, _)
));
}
6 changes: 3 additions & 3 deletions crates/tinyagents-harness/src/config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority medium critique confident

Preserve compatibility for exhaustive ToolDispatcher matches

Adding Python and Typescript variants to the public ToolDispatcher enum makes existing downstream match expressions that exhaustively handle the previous variants fail to compile. Listing the variants here confirms that the breaking public surface remains present; either provide a compatibility strategy (such as a non-exhaustive enum and migration guidance) or explicitly handle this as a versioned breaking change before merging.

[RULE] public-api-compatibility ·

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.
Expand Down
2 changes: 2 additions & 0 deletions crates/tinyagents-harness/src/config/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
11 changes: 11 additions & 0 deletions crates/tinyagents-harness/src/config/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment thread
senamakel marked this conversation as resolved.
Comment thread
senamakel marked this conversation as resolved.
/// 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 ────────────────────────────────────────────────────────────
Expand Down
14 changes: 8 additions & 6 deletions crates/tinyagents-harness/src/runtime/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -314,7 +315,8 @@ pub struct RunPolicy {
/// Whether the loop parses `<tool_call>`-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.
Expand Down
128 changes: 128 additions & 0 deletions crates/tinyagents-integration-tests/tests/e2e_tool_dialects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,134 @@ async fn a_forced_pformat_dialect_parses_positional_calls() {
assert!(system.contains("lookup[0|<q>]"), "{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<tool_call>\nlookup(q=\"needle\")\n</tool_call>",
"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![
"<tool_call>lookup({q: \"needle\"})</tool_call>",
"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![
"<tool_call>lookup(q=\"needle\")</tool_call>",
"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.
Expand Down
5 changes: 3 additions & 2 deletions docs/modules/harness/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,9 @@ Step 12's text-dialect recovery (parsing `<tool_call>`-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
Expand Down
14 changes: 12 additions & 2 deletions docs/modules/harness/tool-dialect.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | `<tool_call>{"name":…,"arguments":{…}}</tool_call>` | full schemas, in its own protocol block | no |
| `PFormatDialect` | `<tool_call>name[a\|b]</tool_call>` | signatures, in the prompt's tool section | no |
| `PFormatDialect` | `<tool_call>name[0\|a\|1\|b]</tool_call>` | signatures, in the prompt's tool section | no |
Comment thread
senamakel marked this conversation as resolved.
| `CodeDialect` | `<tool_call>name(a="x", b=1)</tool_call>` (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
Expand Down
4 changes: 2 additions & 2 deletions docs/modules/harness/tool.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<tool_call>` text and folds consecutive `tool`-role results into one
Expand Down
2 changes: 1 addition & 1 deletion vendor/tinytools
Submodule tinytools updated 36 files
+12 −2 Cargo.lock
+3 −3 Cargo.toml
+5 −1 README.md
+1 −1 crates/tinytools-agent/Cargo.toml
+3 −1 crates/tinytools-agent/README.md
+409 −0 crates/tinytools-agent/src/codecall/literal.rs
+306 −0 crates/tinytools-agent/src/codecall/mod.rs
+367 −0 crates/tinytools-agent/src/codecall/signature.rs
+548 −0 crates/tinytools-agent/src/codecall/test.rs
+69 −0 crates/tinytools-agent/src/codecall/types.rs
+109 −0 crates/tinytools-agent/src/dialect/code.rs
+8 −4 crates/tinytools-agent/src/dialect/mod.rs
+164 −0 crates/tinytools-agent/src/dialect/test.rs
+5 −0 crates/tinytools-agent/src/dialect/types.rs
+4 −0 crates/tinytools-agent/src/lib.rs
+14 −4 crates/tinytools-agent/src/parse/grammar/tagged.rs
+91 −0 crates/tinytools-agent/src/parse/test/tagged.rs
+1 −1 crates/tinytools-agent/src/pformat.rs
+40 −0 crates/tinytools-agent/src/render/catalogue.rs
+52 −3 crates/tinytools-agent/src/render/instructions.rs
+6 −2 crates/tinytools-agent/src/render/mod.rs
+30 −0 crates/tinytools-agent/src/stream/test.rs
+2 −0 crates/tinytools-agent/src/types.rs
+26 −0 crates/tinytools-jev/Cargo.toml
+48 −0 crates/tinytools-jev/README.md
+214 −0 crates/tinytools-jev/src/lib.rs
+209 −0 crates/tinytools-jev/src/test.rs
+139 −0 crates/tinytools-jev/src/types.rs
+6 −0 crates/tinytools/src/lib.rs
+40 −0 crates/tinytools/src/rank/README.md
+283 −0 crates/tinytools/src/rank/bm25.rs
+81 −0 crates/tinytools/src/rank/mod.rs
+123 −0 crates/tinytools/src/rank/test.rs
+146 −0 crates/tinytools/src/rank/types.rs
+11 −0 crates/tinytools/src/tool/types.rs
+25 −0 docs/specs/agent-tool-protocols.md
Loading