From 705677c820843d48e37791b156ac56ad44a54b6f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 05:22:42 +0530 Subject: [PATCH 1/5] Add code-style tool-call dialect (Python / TypeScript) Render a tool catalogue as function signatures and read function calls back, for small code-trained models that have never seen P-Format. One grammar reads both spellings; calls live in the existing markers, are registry-gated, accept literals only, and refuse whole bodies that are not entirely calls to known tools. Co-authored-by: Medulla --- crates/tinytools-agent/README.md | 4 +- .../tinytools-agent/src/codecall/literal.rs | 355 ++++++++++++++ crates/tinytools-agent/src/codecall/mod.rs | 306 ++++++++++++ .../tinytools-agent/src/codecall/signature.rs | 245 ++++++++++ crates/tinytools-agent/src/codecall/test.rs | 458 ++++++++++++++++++ crates/tinytools-agent/src/codecall/types.rs | 69 +++ crates/tinytools-agent/src/dialect/code.rs | 109 +++++ crates/tinytools-agent/src/dialect/mod.rs | 12 +- crates/tinytools-agent/src/dialect/test.rs | 164 +++++++ crates/tinytools-agent/src/dialect/types.rs | 5 + crates/tinytools-agent/src/lib.rs | 4 + .../src/parse/grammar/tagged.rs | 18 +- .../tinytools-agent/src/parse/test/tagged.rs | 91 ++++ crates/tinytools-agent/src/pformat.rs | 2 +- .../tinytools-agent/src/render/catalogue.rs | 40 ++ .../src/render/instructions.rs | 55 ++- crates/tinytools-agent/src/render/mod.rs | 8 +- crates/tinytools-agent/src/stream/test.rs | 30 ++ crates/tinytools-agent/src/types.rs | 2 + docs/specs/agent-tool-protocols.md | 25 + 20 files changed, 1987 insertions(+), 15 deletions(-) create mode 100644 crates/tinytools-agent/src/codecall/literal.rs create mode 100644 crates/tinytools-agent/src/codecall/mod.rs create mode 100644 crates/tinytools-agent/src/codecall/signature.rs create mode 100644 crates/tinytools-agent/src/codecall/test.rs create mode 100644 crates/tinytools-agent/src/codecall/types.rs create mode 100644 crates/tinytools-agent/src/dialect/code.rs diff --git a/crates/tinytools-agent/README.md b/crates/tinytools-agent/README.md index 5943ddd..b41a0b5 100644 --- a/crates/tinytools-agent/README.md +++ b/crates/tinytools-agent/README.md @@ -18,7 +18,8 @@ and the unknown-tool policy remain the consuming harness's or host's. | `repair` | `json::recover_object` (relaxed / damaged JSON), `name::resolve` (damaged tool names against the offered set), `args` (aliases, envelopes, schema-guided coercion) | | `stream` | `StreamScrubber`: the same grammars applied to a live text stream, releasing safe text and completed calls as they arrive | | `render` | the catalogue, the protocol block for each dialect, the `` envelope and transcript replay | -| `dialect` | `ToolDialect` binding one rendering to one parser: `XmlDialect`, `PFormatDialect`, `NativeDialect` | +| `codecall` | `parse_calls(body, ®istry)`: Python / TypeScript function calls, and `render_code_signature` for the catalogue | +| `dialect` | `ToolDialect` binding one rendering to one parser: `XmlDialect`, `PFormatDialect`, `CodeDialect`, `NativeDialect` | | `types` | `ParsedToolCall`, `CallSource`, `ParseOptions`, `ParseOutcome`, `ParseDiagnostic` | ## Grammars @@ -33,6 +34,7 @@ and the unknown-tool policy remain the consuming harness's or host's. | `Glm` | `tool/param>value` lines | GLM | | `BareJson` | the whole response is one object / `tool_calls` envelope | Minimax gateways, `llama3.2:3b` under `tool_choice: required` | | `PFormat` | `name[0\|value]` inside a tag, registry-gated | any prompted model | +| `Code` | `name(arg="value")` / `name({arg: "value"})` inside a tag, registry-gated; literals only, all-or-nothing per body | any code-trained model | Adding a grammar is one file under `src/parse/grammar/` and one entry in `GRAMMARS`; batch parsing, streaming, and every dialect pick it up. diff --git a/crates/tinytools-agent/src/codecall/literal.rs b/crates/tinytools-agent/src/codecall/literal.rs new file mode 100644 index 0000000..9dd0257 --- /dev/null +++ b/crates/tinytools-agent/src/codecall/literal.rs @@ -0,0 +1,355 @@ +//! The character-level scanner: whitespace and comments, identifiers, and +//! the literal values an argument can be. +//! +//! Nothing here knows about tools or registries. It reads Python and +//! JavaScript/TypeScript literals with one grammar — the two languages agree +//! on everything a tool argument needs except the spelling of booleans and +//! null, and both spellings are accepted everywhere. + +use serde_json::{Map, Number, Value}; + +use super::types::{Literal, Refuse}; + +/// A position in the source text, with the small vocabulary of lookahead +/// the grammar needs. +#[derive(Debug)] +pub(crate) struct Cursor<'a> { + src: &'a str, + pos: usize, +} + +impl<'a> Cursor<'a> { + /// A cursor at the start of `src`. + pub(crate) fn new(src: &'a str) -> Self { + Self { src, pos: 0 } + } + + /// Whether every character has been consumed. + pub(crate) fn at_end(&self) -> bool { + self.pos >= self.src.len() + } + + /// The unread remainder. + pub(crate) fn rest(&self) -> &'a str { + &self.src[self.pos..] + } + + /// The next character without consuming it. + pub(crate) fn peek(&self) -> Option { + self.rest().chars().next() + } + + /// Consumes and returns the next character. + pub(crate) fn bump(&mut self) -> Option { + let c = self.peek()?; + self.pos += c.len_utf8(); + Some(c) + } + + /// Consumes `literal` if the remainder starts with it. + pub(crate) fn eat(&mut self, literal: &str) -> bool { + if self.rest().starts_with(literal) { + self.pos += literal.len(); + true + } else { + false + } + } + + /// Skips spaces, tabs, and comments — but **not** newlines, which separate + /// statements at the top level. A comment runs to the end of its line + /// (`#`, `//`) or to its closer (`/* … */`); the newline that ends a line + /// comment is left for the caller. + pub(crate) fn skip_inline_trivia(&mut self) { + loop { + match self.peek() { + Some(' ' | '\t' | '\r') => { + self.bump(); + } + Some('#') => self.skip_line_comment(), + Some('/') if self.rest().starts_with("//") => self.skip_line_comment(), + Some('/') if self.rest().starts_with("/*") => { + self.pos += 2; + match self.rest().find("*/") { + Some(end) => self.pos += end + 2, + None => self.pos = self.src.len(), + } + } + _ => return, + } + } + } + + /// Skips everything [`Self::skip_inline_trivia`] does plus newlines and + /// stray `;` — the trivia *between* statements and inside brackets. + pub(crate) fn skip_trivia(&mut self) { + loop { + self.skip_inline_trivia(); + match self.peek() { + Some('\n' | ';') => { + self.bump(); + } + _ => return, + } + } + } + + fn skip_line_comment(&mut self) { + match self.rest().find('\n') { + Some(end) => self.pos += end, + None => self.pos = self.src.len(), + } + } + + /// Reads an identifier (`[A-Za-z_$][A-Za-z0-9_$]*`), or `None` when the + /// remainder does not start with one. + pub(crate) fn identifier(&mut self) -> Option<&'a str> { + let rest = self.rest(); + let mut end = 0; + for (idx, c) in rest.char_indices() { + let ok = if idx == 0 { + c.is_alphabetic() || c == '_' || c == '$' + } else { + c.is_alphanumeric() || c == '_' || c == '$' + }; + if !ok { + break; + } + end = idx + c.len_utf8(); + } + if end == 0 { + return None; + } + self.pos += end; + Some(&rest[..end]) + } + + /// Whether the remainder starts with a single `=` — an assignment or a + /// keyword argument — as opposed to `==`. + pub(crate) fn at_single_equals(&self) -> bool { + self.rest().starts_with('=') && !self.rest().starts_with("==") + } + + /// Reads one literal value: a string, number, boolean, null, list, + /// tuple, or dict. Anything else — a bare identifier, an expression — is + /// refused. + /// + /// # Errors + /// + /// [`Refuse`] when no literal starts here or the one that does is + /// unterminated. + pub(crate) fn literal(&mut self) -> Result { + self.skip_trivia(); + match self.peek().ok_or(Refuse)? { + '"' | '\'' | '`' => self.string(false).map(Literal::Str), + '[' => self.sequence('[', ']').map(Literal::List), + '(' => self.sequence('(', ')').map(Literal::List), + '{' => self.dict().map(Literal::Dict), + c if c == '-' || c == '+' || c.is_ascii_digit() => self.number(), + c if c.is_alphabetic() || c == '_' => self.word(), + _ => Err(Refuse), + } + } + + /// A bare word in literal position: a boolean / null spelling, or a + /// string prefix (`r"…"`, `b"…"`, `f"…"`) — never a variable. + fn word(&mut self) -> Result { + let start = self.pos; + let word = self.identifier().ok_or(Refuse)?; + match word { + "True" | "true" => Ok(Literal::Bool(true)), + "False" | "false" => Ok(Literal::Bool(false)), + "None" | "null" | "undefined" => Ok(Literal::Null), + "r" | "R" | "b" | "f" | "u" | "rb" | "br" | "fr" | "rf" + if matches!(self.peek(), Some('"' | '\'')) => + { + let raw = word.contains(['r', 'R']); + self.string(raw).map(Literal::Str) + } + _ => { + self.pos = start; + Err(Refuse) + } + } + } + + /// A quoted string: `"…"`, `'…'`, a backtick template (no interpolation), + /// or a triple-quoted `"""…"""` / `'''…'''`. Escapes are decoded unless + /// `raw`. + fn string(&mut self, raw: bool) -> Result { + let quote = self.peek().ok_or(Refuse)?; + let triple = quote != '`' && self.rest().starts_with("e.to_string().repeat(3)); + if triple { + self.pos += 3; + } else { + self.bump(); + } + let mut out = String::new(); + loop { + if triple { + if self.rest().starts_with("e.to_string().repeat(3)) { + self.pos += 3; + return Ok(out); + } + } else if self.peek() == Some(quote) { + self.bump(); + return Ok(out); + } + let c = self.bump().ok_or(Refuse)?; + if c == '\\' && !raw { + self.escape(&mut out)?; + } else { + out.push(c); + } + } + } + + /// Decodes the escape after a backslash. Unknown escapes keep the + /// backslash so a Windows path or a regex survives verbatim. + fn escape(&mut self, out: &mut String) -> Result<(), Refuse> { + let c = self.bump().ok_or(Refuse)?; + match c { + 'n' => out.push('\n'), + 't' => out.push('\t'), + 'r' => out.push('\r'), + '0' => out.push('\0'), + '\\' | '\'' | '"' | '`' | '/' => out.push(c), + '\n' => {} + 'x' => { + let hex = self.rest().get(..2).ok_or(Refuse)?; + if let Some(decoded) = u32::from_str_radix(hex, 16).ok().and_then(char::from_u32) { + self.pos += 2; + out.push(decoded); + } else { + out.push('\\'); + out.push('x'); + } + } + 'u' => { + let (hex, consumed) = if self.rest().starts_with('{') { + let end = self.rest().find('}').ok_or(Refuse)?; + (&self.rest()[1..end], end + 1) + } else { + (self.rest().get(..4).ok_or(Refuse)?, 4) + }; + if let Some(decoded) = u32::from_str_radix(hex, 16).ok().and_then(char::from_u32) { + self.pos += consumed; + out.push(decoded); + } else { + out.push('\\'); + out.push('u'); + } + } + other => { + out.push('\\'); + out.push(other); + } + } + Ok(()) + } + + /// An integer or float in either language's spelling, with an optional + /// sign. Integers that overflow `i64` become floats. + fn number(&mut self) -> Result { + let rest = self.rest(); + let mut end = 0; + let mut is_float = false; + for (idx, c) in rest.char_indices() { + let ok = match c { + '0'..='9' | '_' => true, + '+' | '-' => idx == 0 || matches!(rest[..idx].chars().last(), Some('e' | 'E')), + '.' | 'e' | 'E' => { + is_float = true; + true + } + _ => false, + }; + if !ok { + break; + } + end = idx + c.len_utf8(); + } + let text: String = rest[..end].chars().filter(|c| *c != '_').collect(); + if text.is_empty() || text == "-" || text == "+" { + return Err(Refuse); + } + self.pos += end; + if !is_float && let Ok(n) = text.parse::() { + return Ok(Literal::Int(n)); + } + text.parse::().map(Literal::Float).map_err(|_| Refuse) + } + + /// A bracketed, comma-separated sequence with an optional trailing comma. + fn sequence(&mut self, open: char, close: char) -> Result, Refuse> { + if self.bump() != Some(open) { + return Err(Refuse); + } + let mut items = Vec::new(); + loop { + self.skip_trivia(); + if self.peek() == Some(close) { + self.bump(); + return Ok(items); + } + items.push(self.literal()?); + self.skip_trivia(); + match self.bump() { + Some(',') => {} + Some(c) if c == close => return Ok(items), + _ => return Err(Refuse), + } + } + } + + /// `{key: value, …}` with quoted-string or bare-identifier keys. + fn dict(&mut self) -> Result, Refuse> { + if self.bump() != Some('{') { + return Err(Refuse); + } + let mut entries = Vec::new(); + loop { + self.skip_trivia(); + if self.peek() == Some('}') { + self.bump(); + return Ok(entries); + } + let key = match self.peek() { + Some('"' | '\'') => self.string(false)?, + _ => self.identifier().ok_or(Refuse)?.to_string(), + }; + self.skip_trivia(); + if self.bump() != Some(':') { + return Err(Refuse); + } + let value = self.literal()?; + entries.push((key, value)); + self.skip_trivia(); + match self.bump() { + Some(',') => {} + Some('}') => return Ok(entries), + _ => return Err(Refuse), + } + } + } +} + +impl From for Value { + fn from(literal: Literal) -> Self { + match literal { + Literal::Str(s) => Value::String(s), + Literal::Int(n) => Value::Number(n.into()), + Literal::Float(f) => Number::from_f64(f).map_or(Value::Null, Value::Number), + Literal::Bool(b) => Value::Bool(b), + Literal::Null => Value::Null, + Literal::List(items) => Value::Array(items.into_iter().map(Value::from).collect()), + Literal::Dict(entries) => { + let mut map = Map::with_capacity(entries.len()); + for (key, value) in entries { + map.insert(key, Value::from(value)); + } + Value::Object(map) + } + } + } +} diff --git a/crates/tinytools-agent/src/codecall/mod.rs b/crates/tinytools-agent/src/codecall/mod.rs new file mode 100644 index 0000000..3dc117b --- /dev/null +++ b/crates/tinytools-agent/src/codecall/mod.rs @@ -0,0 +1,306 @@ +//! Code-style tool calls: `read_file(path="src/main.rs", limit=20)`. +//! +//! # Why +//! +//! A JSON schema is the right wire format for native tool calling and a poor +//! thing to paste into a prompt; P-Format is compact but invented, and a +//! small model has seen none of it in pretraining. A function signature is +//! both compact *and* the one form every code-trained model already writes +//! fluently: +//! +//! ```text +//! def read_file(path: str, limit: int = None) -> str # Read a file +//! ``` +//! +//! is the whole catalogue entry, and the call the model writes back is plain +//! Python (or TypeScript — one grammar reads both). +//! +//! # Spec +//! +//! - One or more calls per `` body, separated by newlines or `;`. +//! - A call is `NAME(args)`, optionally prefixed by `await`, `x =`, or +//! `const x =`. Arguments are positional (bound in the catalogue's order — +//! required first, then optional alphabetically, from +//! [`PFormatToolParams::from_schema`]), keyword (`name=value`), or both; +//! positional after keyword is refused as in Python. +//! - Values are literals only: quoted strings (both quote styles, triple +//! quotes, backticks, `r"…"`), numbers, `True`/`true`, `False`/`false`, +//! `None`/`null`, lists, dicts. A bare identifier is a variable, and a call +//! that needs one cannot run — refused. +//! - The TypeScript single-object form `f({a: 1, b: 2})` is unpacked into +//! keyword arguments when every key is a declared parameter; otherwise, when +//! the tool has exactly one parameter, the object is that parameter's value; +//! otherwise refused. +//! - A top-level `None`/`null` omits the argument, the same retraction rule +//! P-Format uses for an empty slot. +//! - **All or nothing per body.** Every non-blank, non-comment statement must +//! be a call to a tool in the registry. Prose in the tag — `I'll call +//! read_file(path="x") now` — is not a call, and a body containing it is +//! refused whole; arguments are never lifted out of narrative. +//! - Refused, never guessed: unknown tool, unknown keyword, duplicate binding, +//! positional overflow, unbalanced brackets, trailing tokens after `)`. +//! +//! # Where it sits +//! +//! The grammar has no marker of its own. It decodes the body of the tagged +//! grammar (``, ```` ```tool_call ````) after P-Format has declined +//! and before the JSON paths, so every consumer — batch, streaming, every +//! dialect — sees it, and a top-level ```` ```python ```` fence stays what it +//! is everywhere else in this crate: an example, not a call. + +mod literal; +mod signature; +mod types; + +#[cfg(test)] +mod test; + +use serde_json::{Map, Value}; + +use crate::pformat::{PFormatParamType, PFormatRegistry, PFormatToolParams, coerce_value}; +use literal::Cursor; +use types::{Call, Literal, Refuse}; + +/// Positional and keyword arguments as parsed, before binding. +type Arguments = (Vec, Vec<(String, Literal)>); + +pub use signature::{render_code_signature, render_code_type}; +pub use types::CodeStyle; + +/// Reads every call in a `` body, or none. +/// +/// Returns `(tool_name, arguments)` pairs in source order, bound and coerced +/// against `registry`. Empty when the body is not entirely code calls to +/// known tools — see the module docs for what is and is not accepted. +#[must_use] +pub fn parse_calls(body: &str, registry: &PFormatRegistry) -> Vec<(String, Value)> { + parse_all(body, registry).unwrap_or_default() +} + +fn parse_all(body: &str, registry: &PFormatRegistry) -> Result, Refuse> { + let mut cursor = Cursor::new(body); + let mut calls = Vec::new(); + loop { + cursor.skip_trivia(); + if cursor.at_end() { + break; + } + let call = statement(&mut cursor)?; + let (name, params) = lookup(registry, &call.name)?; + let arguments = bind(call, params)?; + calls.push((name.to_string(), Value::Object(arguments))); + } + if calls.is_empty() { + return Err(Refuse); + } + crate::telemetry::debug!( + calls = calls.len(), + "[codecall] parsed code-style tool calls" + ); + Ok(calls) +} + +/// One statement: optional prefixes, then `NAME(args)`, then end of line. +fn statement(cursor: &mut Cursor<'_>) -> Result { + let name = callee(cursor)?; + if cursor.bump() != Some('(') { + return Err(Refuse); + } + let (positional, keywords) = arguments(cursor)?; + // Only trivia may follow the call on its line; `f(1) + 1` is an + // expression, not a call. + cursor.skip_inline_trivia(); + match cursor.peek() { + None | Some('\n' | ';') => Ok(Call { + name, + positional, + keywords, + }), + Some(_) => Err(Refuse), + } +} + +/// Reads the callee, skipping `await`, `const|let|var x =`, and `x =` +/// prefixes. Stops with the cursor on the `(`. +fn callee(cursor: &mut Cursor<'_>) -> Result { + loop { + cursor.skip_inline_trivia(); + let word = cursor.identifier().ok_or(Refuse)?; + cursor.skip_inline_trivia(); + match word { + "await" => continue, + "const" | "let" | "var" => { + cursor.identifier().ok_or(Refuse)?; + cursor.skip_inline_trivia(); + if !cursor.at_single_equals() { + return Err(Refuse); + } + cursor.bump(); + continue; + } + _ => {} + } + if cursor.at_single_equals() { + cursor.bump(); + continue; + } + let mut name = word.to_string(); + while cursor.eat(".") { + name.push('.'); + name.push_str(cursor.identifier().ok_or(Refuse)?); + } + cursor.skip_inline_trivia(); + return match cursor.peek() { + Some('(') => Ok(name), + _ => Err(Refuse), + }; + } +} + +/// The argument list after `(`, through the closing `)`. +fn arguments(cursor: &mut Cursor<'_>) -> Result { + let mut positional = Vec::new(); + let mut keywords: Vec<(String, Literal)> = Vec::new(); + loop { + cursor.skip_trivia(); + if cursor.peek() == Some(')') { + cursor.bump(); + return Ok((positional, keywords)); + } + if let Some(key) = keyword_name(cursor) { + if keywords.iter().any(|(existing, _)| *existing == key) { + return Err(Refuse); + } + keywords.push((key, cursor.literal()?)); + } else { + if !keywords.is_empty() { + return Err(Refuse); + } + positional.push(cursor.literal()?); + } + cursor.skip_trivia(); + match cursor.bump() { + Some(',') => {} + Some(')') => return Ok((positional, keywords)), + _ => return Err(Refuse), + } + } +} + +/// `name=` at the cursor, consumed only when it really is a keyword argument; +/// otherwise the cursor is left where it was so the value can be read as a +/// positional literal (`True`, `None`, …). +fn keyword_name(cursor: &mut Cursor<'_>) -> Option { + let probe = cursor.rest(); + let mut lookahead = Cursor::new(probe); + let word = lookahead.identifier()?; + lookahead.skip_inline_trivia(); + if !lookahead.at_single_equals() { + return None; + } + lookahead.bump(); + let consumed = probe.len() - lookahead.rest().len(); + // The lookahead walked a prefix of `probe`; consume the same bytes here. + cursor.eat(&probe[..consumed]); + Some(word.to_string()) +} + +/// Resolves the callee against the registry, falling back to the last dotted +/// segment (`functions.read_file` → `read_file`). +fn lookup<'r>( + registry: &'r PFormatRegistry, + name: &str, +) -> Result<(&'r str, &'r PFormatToolParams), Refuse> { + if let Some((key, params)) = registry.get_key_value(name) { + return Ok((key.as_str(), params)); + } + if let Some(last) = name.rsplit('.').next() + && last != name + && let Some((key, params)) = registry.get_key_value(last) + { + return Ok((key.as_str(), params)); + } + crate::telemetry::debug!(name_len = name.len(), "[codecall] unknown tool — refusing"); + Err(Refuse) +} + +/// Binds positional and keyword arguments to parameter names and coerces +/// them to the schema's primitive types. +fn bind(call: Call, params: &PFormatToolParams) -> Result, Refuse> { + let Call { + positional, + keywords, + .. + } = call; + let (positional, mut keywords) = unpack_single_object(positional, keywords, params)?; + + let mut bound: Vec<(String, Literal)> = Vec::with_capacity(positional.len() + keywords.len()); + for (slot, value) in positional.into_iter().enumerate() { + let name = params.names.get(slot).ok_or(Refuse)?; + bound.push((name.clone(), value)); + } + for (key, value) in keywords.drain(..) { + if !params.names.contains(&key) { + crate::telemetry::debug!(key_len = key.len(), "[codecall] unknown keyword — refusing"); + return Err(Refuse); + } + if bound.iter().any(|(name, _)| *name == key) { + return Err(Refuse); + } + bound.push((key, value)); + } + + let mut out = Map::with_capacity(bound.len()); + for (name, value) in bound { + if value == Literal::Null { + // A top-level `None` is the model saying "not sending this one". + continue; + } + let ty = params + .names + .iter() + .position(|n| *n == name) + .and_then(|slot| params.types.get(slot).copied()) + .unwrap_or(PFormatParamType::String); + let json = match value { + Literal::Str(raw) => coerce_value(&raw, ty), + other => Value::from(other), + }; + out.insert(name, json); + } + Ok(out) +} + +/// The TypeScript single-object rule: `f({a: 1})` is keyword arguments when +/// every key is a parameter, that one parameter's value when the tool has +/// exactly one, and otherwise refused — binding a foreign-keyed object to +/// whichever parameter happens to come first would be a guess. +fn unpack_single_object( + positional: Vec, + keywords: Vec<(String, Literal)>, + params: &PFormatToolParams, +) -> Result { + if !keywords.is_empty() || positional.len() != 1 { + return Ok((positional, keywords)); + } + let Some(Literal::Dict(entries)) = positional.first() else { + return Ok((positional, keywords)); + }; + let all_declared = entries + .iter() + .all(|(key, _)| params.names.iter().any(|name| name == key)); + if all_declared { + let Some(Literal::Dict(entries)) = positional.into_iter().next() else { + return Ok((Vec::new(), Vec::new())); + }; + return Ok((Vec::new(), entries)); + } + if params.names.len() == 1 { + return Ok((positional, keywords)); + } + crate::telemetry::debug!( + keys = entries.len(), + "[codecall] object argument names no parameter — refusing" + ); + Err(Refuse) +} diff --git a/crates/tinytools-agent/src/codecall/signature.rs b/crates/tinytools-agent/src/codecall/signature.rs new file mode 100644 index 0000000..6dac336 --- /dev/null +++ b/crates/tinytools-agent/src/codecall/signature.rs @@ -0,0 +1,245 @@ +//! A tool's JSON schema rendered as a function signature. +//! +//! ```text +//! def read_file(path: str, limit: int = None) -> str +//! function read_file(path: string, limit?: number): string; +//! ``` +//! +//! Parameter order is [`PFormatToolParams::from_schema`]'s — required first +//! in schema order, then optional alphabetically — which is also the order +//! [`super::parse_calls`] binds positional arguments in. The two read the +//! same function, so they cannot disagree. +//! +//! The type rendering is lossy on purpose: constraints (`minimum`, +//! `pattern`), nested descriptions, and anything past [`MAX_DEPTH`] are +//! dropped. The native path never uses it; a prompt-guided model only needs +//! the shape. + +use std::fmt::Write as _; + +use serde_json::Value; + +use super::CodeStyle; +use crate::pformat::PFormatToolParams; + +/// Nesting past this depth renders as a bare `dict` / `object`. +pub(crate) const MAX_DEPTH: usize = 4; +/// Properties past this count in one nested object render as `…`. +pub(crate) const MAX_PROPERTIES: usize = 16; + +/// Renders `name` with `schema`'s parameters as a signature in `style`. +/// +/// Zero-parameter tools render as `def name() -> str` / `function name(): string;`. +#[must_use] +pub fn render_code_signature(name: &str, schema: &Value, style: CodeStyle) -> String { + let params = PFormatToolParams::from_schema(schema); + let properties = schema.get("properties").and_then(Value::as_object); + let required: Vec<&str> = schema + .get("required") + .and_then(Value::as_array) + .map(|names| names.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default(); + + let mut out = String::new(); + match style { + CodeStyle::Python => out.push_str("def "), + CodeStyle::TypeScript => out.push_str("function "), + } + out.push_str(name); + out.push('('); + for (index, param) in params.names.iter().enumerate() { + if index > 0 { + out.push_str(", "); + } + let property = properties + .and_then(|props| props.get(param)) + .unwrap_or(&Value::Null); + let ty = render_code_type(property, style); + let is_required = required.contains(¶m.as_str()); + // Infallible: writing to a String never errors. + let _ = match (style, is_required) { + (CodeStyle::Python, true) => write!(out, "{param}: {ty}"), + (CodeStyle::Python, false) => write!(out, "{param}: {ty} = None"), + (CodeStyle::TypeScript, true) => write!(out, "{}: {ty}", ts_name(param)), + (CodeStyle::TypeScript, false) => write!(out, "{}?: {ty}", ts_name(param)), + }; + } + match style { + CodeStyle::Python => out.push_str(") -> str"), + CodeStyle::TypeScript => out.push_str("): string;"), + } + out +} + +/// Renders one JSON-Schema value as a type in `style`. +#[must_use] +pub fn render_code_type(schema: &Value, style: CodeStyle) -> String { + render(schema, style, 0) +} + +fn render(schema: &Value, style: CodeStyle, depth: usize) -> String { + let unknown = || match style { + CodeStyle::Python => "Any".to_string(), + CodeStyle::TypeScript => "unknown".to_string(), + }; + let Some(object) = schema.as_object() else { + return unknown(); + }; + if let Some(values) = object.get("enum").and_then(Value::as_array) { + let members: Vec = values.iter().map(literal).collect(); + return match style { + CodeStyle::Python => format!("Literal[{}]", members.join(", ")), + CodeStyle::TypeScript => members.join(" | "), + }; + } + if let Some(value) = object.get("const") { + return match style { + CodeStyle::Python => format!("Literal[{}]", literal(value)), + CodeStyle::TypeScript => literal(value), + }; + } + for key in ["anyOf", "oneOf"] { + if let Some(variants) = object.get(key).and_then(Value::as_array) + && !variants.is_empty() + { + let mut seen = Vec::new(); + for variant in variants { + let rendered = render(variant, style, depth); + if !seen.contains(&rendered) { + seen.push(rendered); + } + } + return seen.join(" | "); + } + } + if let Some(variants) = object.get("allOf").and_then(Value::as_array) + && let Some(first) = variants.first() + { + return render(first, style, depth); + } + + let kind = match object.get("type") { + Some(Value::String(kind)) => kind.clone(), + Some(Value::Array(kinds)) => { + return kinds + .iter() + .filter_map(Value::as_str) + .map(|kind| { + let mut single = object.clone(); + single.insert("type".to_string(), Value::String(kind.to_string())); + render(&Value::Object(single), style, depth) + }) + .collect::>() + .join(" | "); + } + _ if object.contains_key("properties") => "object".to_string(), + _ if object.contains_key("items") => "array".to_string(), + _ => return unknown(), + }; + + match (kind.as_str(), style) { + ("string", CodeStyle::Python) => "str".to_string(), + ("string", CodeStyle::TypeScript) => "string".to_string(), + ("integer", CodeStyle::Python) => "int".to_string(), + ("number", CodeStyle::Python) => "float".to_string(), + ("integer" | "number", CodeStyle::TypeScript) => "number".to_string(), + ("boolean", CodeStyle::Python) => "bool".to_string(), + ("boolean", CodeStyle::TypeScript) => "boolean".to_string(), + ("null", CodeStyle::Python) => "None".to_string(), + ("null", CodeStyle::TypeScript) => "null".to_string(), + ("object", _) => render_object(object, style, depth), + ("array", _) => { + let items = object.get("items").map_or_else(unknown, |items| { + if depth >= MAX_DEPTH { + unknown() + } else { + render(items, style, depth + 1) + } + }); + match style { + CodeStyle::Python => format!("list[{items}]"), + CodeStyle::TypeScript if items.contains(' ') || items.contains('|') => { + format!("Array<{items}>") + } + CodeStyle::TypeScript => format!("{items}[]"), + } + } + _ => unknown(), + } +} + +fn render_object( + object: &serde_json::Map, + style: CodeStyle, + depth: usize, +) -> String { + let bare = || match style { + CodeStyle::Python => "dict".to_string(), + CodeStyle::TypeScript => "object".to_string(), + }; + let Some(properties) = object.get("properties").and_then(Value::as_object) else { + return bare(); + }; + if properties.is_empty() || depth >= MAX_DEPTH { + return bare(); + } + // Python has no inline object type worth the tokens; `dict` plus the + // key names is what a model needs to write the literal. + if style == CodeStyle::Python { + return bare(); + } + let required: Vec<&str> = object + .get("required") + .and_then(Value::as_array) + .map(|names| names.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default(); + let mut out = String::from("{"); + for (index, (name, property)) in properties.iter().enumerate() { + if index == MAX_PROPERTIES { + out.push_str(", …"); + break; + } + if index > 0 { + out.push_str(", "); + } + let optional = if required.contains(&name.as_str()) { + "" + } else { + "?" + }; + // Infallible: writing to a String never errors. + let _ = write!( + out, + "{}{optional}: {}", + ts_name(name), + render(property, style, depth + 1) + ); + } + out.push('}'); + out +} + +/// A JSON value spelled as a type-level literal (`"a"`, `1`, `true`). +fn literal(value: &Value) -> String { + match value { + Value::String(s) => format!("{s:?}"), + other => other.to_string(), + } +} + +/// A property name as a TypeScript member: bare when it is an identifier, +/// JSON-quoted otherwise (`{"file-path": string}`). +fn ts_name(name: &str) -> String { + let is_identifier = name + .chars() + .next() + .is_some_and(|first| first.is_alphabetic() || first == '_' || first == '$') + && name + .chars() + .all(|ch| ch.is_alphanumeric() || ch == '_' || ch == '$'); + if is_identifier { + name.to_string() + } else { + serde_json::to_string(name).unwrap_or_else(|_| format!("{name:?}")) + } +} diff --git a/crates/tinytools-agent/src/codecall/test.rs b/crates/tinytools-agent/src/codecall/test.rs new file mode 100644 index 0000000..bce00d7 --- /dev/null +++ b/crates/tinytools-agent/src/codecall/test.rs @@ -0,0 +1,458 @@ +//! Unit tests for the code-call grammar: literals, binding, refusals, and +//! the signature renderer. +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +use serde_json::{Value, json}; + +use super::{CodeStyle, parse_calls, render_code_signature, render_code_type}; +use crate::pformat::{PFormatRegistry, build_registry}; + +fn read_file_schema() -> Value { + json!({ + "type": "object", + "properties": { + "path": {"type": "string", "description": "File to read"}, + "limit": {"type": "integer", "description": "Max lines"} + }, + "required": ["path"] + }) +} + +fn shell_schema() -> Value { + json!({ + "type": "object", + "properties": { + "command": {"type": "string"}, + "background": {"type": "boolean"}, + "timeout": {"type": "number"} + }, + "required": ["command"] + }) +} + +fn configure_schema() -> Value { + json!({ + "type": "object", + "properties": { "config": {"type": "object"} }, + "required": ["config"] + }) +} + +fn registry() -> PFormatRegistry { + build_registry([ + ("read_file", read_file_schema()), + ("shell", shell_schema()), + ("configure", configure_schema()), + ("list_dir", json!({"type": "object", "properties": {}})), + ]) +} + +fn one(body: &str) -> (String, Value) { + let calls = parse_calls(body, ®istry()); + assert_eq!( + calls.len(), + 1, + "expected exactly one call from {body:?}: {calls:?}" + ); + calls.into_iter().next().unwrap() +} + +fn refused(body: &str) { + let calls = parse_calls(body, ®istry()); + assert!( + calls.is_empty(), + "expected refusal for {body:?}, got {calls:?}" + ); +} + +// ── binding ────────────────────────────────────────────────────────────── + +#[test] +fn keyword_arguments_bind_by_name() { + let (name, args) = one(r#"read_file(path="src/main.rs", limit=20)"#); + assert_eq!(name, "read_file"); + assert_eq!(args, json!({"path": "src/main.rs", "limit": 20})); +} + +#[test] +fn positional_arguments_bind_in_signature_order() { + // required first (command), then optional alphabetically (background, timeout) + let (_, args) = one(r#"shell("ls", True, 2.5)"#); + assert_eq!( + args, + json!({"command": "ls", "background": true, "timeout": 2.5}) + ); +} + +#[test] +fn mixed_positional_then_keyword() { + let (_, args) = one(r#"read_file("a.txt", limit=3)"#); + assert_eq!(args, json!({"path": "a.txt", "limit": 3})); +} + +#[test] +fn positional_after_keyword_is_refused() { + refused(r#"read_file(limit=3, "a.txt")"#); +} + +#[test] +fn duplicate_keyword_is_refused() { + refused(r#"read_file(path="a", path="b")"#); +} + +#[test] +fn positional_and_keyword_collision_is_refused() { + refused(r#"read_file("a", path="b")"#); +} + +#[test] +fn unknown_keyword_is_refused() { + refused(r#"read_file(path="a", encoding="utf8")"#); +} + +#[test] +fn positional_overflow_is_refused() { + refused(r#"read_file("a", 1, 2)"#); +} + +#[test] +fn unknown_tool_is_refused() { + refused(r#"delete_everything(path="/")"#); +} + +#[test] +fn dotted_callee_resolves_to_the_last_segment() { + let (name, args) = one(r#"functions.read_file(path="x")"#); + assert_eq!(name, "read_file"); + assert_eq!(args, json!({"path": "x"})); +} + +#[test] +fn zero_argument_call() { + let (name, args) = one("list_dir()"); + assert_eq!(name, "list_dir"); + assert_eq!(args, json!({})); + let (_, args) = one("list_dir( )"); + assert_eq!(args, json!({})); +} + +#[test] +fn a_bare_name_is_not_a_call() { + refused("read_file"); + refused("read_file;"); + refused("list_dir"); +} + +#[test] +fn unbalanced_brackets_are_refused() { + refused(r#"read_file(path="a""#); + refused(r#"read_file(path="a"))"#); + refused(r#"read_file(path=["a")"#); +} + +#[test] +fn null_at_top_level_omits_the_argument() { + let (_, args) = one(r#"read_file(path="a", limit=None)"#); + assert_eq!(args, json!({"path": "a"})); + let (_, args) = one(r#"read_file("a", null)"#); + assert_eq!(args, json!({"path": "a"})); +} + +#[test] +fn nested_null_is_kept() { + let (_, args) = one(r#"configure(config={"a": None})"#); + assert_eq!(args, json!({"config": {"a": null}})); +} + +// ── coercion ───────────────────────────────────────────────────────────── + +#[test] +fn quoted_numbers_and_booleans_coerce_by_schema_type() { + let (_, args) = one(r#"read_file(path="a", limit="5")"#); + assert_eq!(args["limit"], 5); + let (_, args) = one(r#"shell("ls", background="yes", timeout="1.5")"#); + assert_eq!(args["background"], true); + assert_eq!(args["timeout"], 1.5); +} + +#[test] +fn a_number_on_a_string_parameter_stays_a_number() { + let (_, args) = one("read_file(path=5)"); + assert_eq!(args["path"], 5); +} + +#[test] +fn a_float_on_an_integer_parameter_is_kept() { + let (_, args) = one(r#"read_file(path="a", limit=5.0)"#); + assert_eq!(args["limit"], 5.0); +} + +// ── literals ───────────────────────────────────────────────────────────── + +#[test] +fn delimiters_inside_strings_do_not_split() { + let (_, args) = one(r#"read_file(path="a,b)=c(d", limit=1)"#); + assert_eq!(args["path"], "a,b)=c(d"); + let (_, args) = one("read_file(path='it;s')"); + assert_eq!(args["path"], "it;s"); +} + +#[test] +fn escaped_quotes_and_escapes_decode() { + let (_, args) = one(r#"read_file(path="a \"b\" c\n")"#); + assert_eq!(args["path"], "a \"b\" c\n"); + let (_, args) = one(r"read_file(path='it\'s')"); + assert_eq!(args["path"], "it's"); + let (_, args) = one(r#"read_file(path="\u0041\x42")"#); + assert_eq!(args["path"], "AB"); +} + +#[test] +fn unknown_escapes_keep_the_backslash() { + let (_, args) = one(r#"read_file(path="C:\dir\file")"#); + assert_eq!(args["path"], "C:\\dir\\file"); +} + +#[test] +fn raw_and_template_strings() { + let (_, args) = one(r#"read_file(path=r"C:\new\table")"#); + assert_eq!(args["path"], "C:\\new\\table"); + let (_, args) = one("read_file(path=`src/x.rs`)"); + assert_eq!(args["path"], "src/x.rs"); +} + +#[test] +fn triple_quoted_strings_hold_newlines_and_quotes() { + let (_, args) = one("shell(command=\"\"\"echo \"hi\"\nls\"\"\")"); + assert_eq!(args["command"], "echo \"hi\"\nls"); +} + +#[test] +fn nested_lists_and_dicts() { + let (_, args) = one(r#"configure(config={"a": [1, {"b": [None, True]}], c: "d",})"#); + assert_eq!( + args["config"], + json!({"a": [1, {"b": [null, true]}], "c": "d"}) + ); +} + +#[test] +fn tuples_read_as_lists_and_trailing_commas_are_fine() { + let (_, args) = one(r#"configure(config={"a": (1, 2,),},)"#); + assert_eq!(args["config"], json!({"a": [1, 2]})); +} + +#[test] +fn leading_or_double_commas_are_refused() { + refused(r#"read_file(, path="a")"#); + refused(r#"read_file(path="a",, limit=1)"#); +} + +#[test] +fn numbers_in_both_spellings() { + let (_, args) = one(r#"shell("x", timeout=1e3)"#); + assert_eq!(args["timeout"], 1000.0); + let (_, args) = one(r#"shell("x", timeout=-2)"#); + assert_eq!(args["timeout"], -2); + let (_, args) = one(r#"read_file("x", limit=1_000)"#); + assert_eq!(args["limit"], 1000); +} + +#[test] +fn a_variable_reference_is_refused() { + refused("read_file(path=filename)"); + refused("read_file(path=os.getcwd())"); +} + +// ── statements ─────────────────────────────────────────────────────────── + +#[test] +fn several_calls_per_body_by_newline_or_semicolon() { + let calls = parse_calls( + "read_file(path=\"a\")\nlist_dir(); shell(command=\"ls\")\n", + ®istry(), + ); + let names: Vec<&str> = calls.iter().map(|(name, _)| name.as_str()).collect(); + assert_eq!(names, ["read_file", "list_dir", "shell"]); +} + +#[test] +fn prefixes_are_stripped() { + let (_, args) = one(r#"await read_file(path="a")"#); + assert_eq!(args["path"], "a"); + let (_, args) = one(r#"result = read_file(path="a")"#); + assert_eq!(args["path"], "a"); + let (_, args) = one(r#"const r = await read_file({path: "a"})"#); + assert_eq!(args["path"], "a"); +} + +#[test] +fn trailing_tokens_after_the_call_are_refused() { + refused(r#"read_file(path="a") + 1"#); + refused(r#"read_file(path="a").decode()"#); +} + +#[test] +fn comments_are_skipped_outside_strings() { + let (_, args) = one("read_file(path=\"a#b\") # see (note)\n"); + assert_eq!(args["path"], "a#b"); + let (_, args) = one("// comment first\nread_file(path=\"a\") // trailing (paren)"); + assert_eq!(args["path"], "a"); + let (_, args) = one("read_file(/* inline */ path=\"a\")"); + assert_eq!(args["path"], "a"); +} + +#[test] +fn a_body_of_only_comments_is_not_a_call() { + refused("# nothing here\n// or here"); + refused(""); +} + +#[test] +fn prose_in_the_body_refuses_the_whole_body() { + refused(r#"I will call read_file(path="a") now"#); + refused("Let me look.\nread_file(path=\"a\")"); + refused("read_file(path=\"a\")\nThen I'll summarise."); +} + +#[test] +fn pformat_json_and_glm_bodies_are_not_code_calls() { + refused("read_file[0|a]"); + refused(r#"{"name": "read_file", "arguments": {"path": "a"}}"#); + refused("read_file/path>a"); + refused("read_file{path: \"a\"}"); +} + +// ── single-object rule ─────────────────────────────────────────────────── + +#[test] +fn single_object_with_declared_keys_unpacks() { + let (_, args) = one(r#"read_file({path: "a", limit: 2})"#); + assert_eq!(args, json!({"path": "a", "limit": 2})); + let (_, args) = one(r#"read_file({"path": "a"})"#); + assert_eq!(args, json!({"path": "a"})); +} + +#[test] +fn single_object_on_a_single_parameter_tool_binds_to_it() { + let (_, args) = one("configure({retries: 3})"); + assert_eq!(args, json!({"config": {"retries": 3}})); +} + +#[test] +fn single_object_naming_the_single_parameter_unpacks() { + let (_, args) = one("configure({config: {retries: 3}})"); + assert_eq!(args, json!({"config": {"retries": 3}})); +} + +#[test] +fn single_object_with_foreign_keys_on_a_multi_parameter_tool_is_refused() { + refused(r#"read_file({file: "a"})"#); +} + +#[test] +fn empty_object_unpacks_to_no_arguments() { + let (_, args) = one("list_dir({})"); + assert_eq!(args, json!({})); + let (_, args) = one("read_file({})"); + assert_eq!(args, json!({})); +} + +// ── signatures ─────────────────────────────────────────────────────────── + +#[test] +fn python_signature_orders_required_then_optional_alphabetically() { + assert_eq!( + render_code_signature("shell", &shell_schema(), CodeStyle::Python), + "def shell(command: str, background: bool = None, timeout: float = None) -> str" + ); + assert_eq!( + render_code_signature("read_file", &read_file_schema(), CodeStyle::Python), + "def read_file(path: str, limit: int = None) -> str" + ); + assert_eq!( + render_code_signature("list_dir", &json!({"type": "object"}), CodeStyle::Python), + "def list_dir() -> str" + ); +} + +#[test] +fn typescript_signature_marks_optionals_with_a_question_mark() { + assert_eq!( + render_code_signature("shell", &shell_schema(), CodeStyle::TypeScript), + "function shell(command: string, background?: boolean, timeout?: number): string;" + ); + assert_eq!( + render_code_signature("list_dir", &json!({}), CodeStyle::TypeScript), + "function list_dir(): string;" + ); +} + +#[test] +fn signature_order_is_the_binding_order() { + // The catalogue tells the model `command, background, timeout`; a + // positional call in that order must land on those names. + let signature = render_code_signature("shell", &shell_schema(), CodeStyle::Python); + assert!(signature.starts_with("def shell(command")); + let (_, args) = one(r#"shell("ls", False, 3)"#); + assert_eq!( + args, + json!({"command": "ls", "background": false, "timeout": 3}) + ); +} + +#[test] +fn types_render_enums_arrays_unions_and_nesting() { + let unit = json!({"type": "string", "enum": ["metric", "imperial"]}); + assert_eq!( + render_code_type(&unit, CodeStyle::Python), + r#"Literal["metric", "imperial"]"# + ); + assert_eq!( + render_code_type(&unit, CodeStyle::TypeScript), + r#""metric" | "imperial""# + ); + + let tags = json!({"type": "array", "items": {"type": "string"}}); + assert_eq!(render_code_type(&tags, CodeStyle::Python), "list[str]"); + assert_eq!(render_code_type(&tags, CodeStyle::TypeScript), "string[]"); + + let either = json!({"anyOf": [{"type": "string"}, {"type": "integer"}]}); + assert_eq!(render_code_type(&either, CodeStyle::Python), "str | int"); + assert_eq!( + render_code_type(&either, CodeStyle::TypeScript), + "string | number" + ); + + let nested = json!({ + "type": "object", + "properties": {"a": {"type": "string"}, "file-path": {"type": "boolean"}}, + "required": ["a"] + }); + assert_eq!(render_code_type(&nested, CodeStyle::Python), "dict"); + assert_eq!( + render_code_type(&nested, CodeStyle::TypeScript), + r#"{a: string, "file-path"?: boolean}"# + ); + + assert_eq!(render_code_type(&json!({}), CodeStyle::Python), "Any"); + assert_eq!( + render_code_type(&json!({}), CodeStyle::TypeScript), + "unknown" + ); + assert_eq!( + render_code_type(&json!({"type": ["string", "null"]}), CodeStyle::Python), + "str | None" + ); +} + +#[test] +fn deep_nesting_collapses_to_a_bare_object() { + let mut schema = json!({"type": "string"}); + for _ in 0..6 { + schema = json!({"type": "object", "properties": {"x": schema}}); + } + let rendered = render_code_type(&schema, CodeStyle::TypeScript); + assert!(rendered.contains("object"), "{rendered}"); + assert!(!rendered.contains("string"), "{rendered}"); +} diff --git a/crates/tinytools-agent/src/codecall/types.rs b/crates/tinytools-agent/src/codecall/types.rs new file mode 100644 index 0000000..ff961a0 --- /dev/null +++ b/crates/tinytools-agent/src/codecall/types.rs @@ -0,0 +1,69 @@ +//! The vocabulary of the code-call grammar: the two surface styles, the +//! literal values a call can carry, and the call itself before binding. + +/// Which programming language the catalogue is spelled in. +/// +/// The parser is the same for both — a Python call and a TypeScript call are +/// read by one grammar — so this only decides what the model *reads*: the +/// signature syntax, the type words, and the protocol block's example. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +pub enum CodeStyle { + /// `def read_file(path: str, limit: int = None) -> str`, called as + /// `read_file(path="src/main.rs", limit=20)`. + #[default] + Python, + /// `function read_file(path: string, limit?: number): string;`, called as + /// `read_file({path: "src/main.rs", limit: 20})`. + TypeScript, +} + +impl CodeStyle { + /// The name a config or a log line uses for this style. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + CodeStyle::Python => "python", + CodeStyle::TypeScript => "typescript", + } + } +} + +/// A literal argument value as the model wrote it, before it becomes JSON. +/// +/// Deliberately a closed set: a bare identifier that is not one of the +/// boolean / null spellings is a variable reference, and a call that depends +/// on a variable cannot be executed — it is refused, never guessed at. +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum Literal { + /// A quoted string, escapes already decoded. + Str(String), + /// An integer that fits `i64`. + Int(i64), + /// Any other number. + Float(f64), + /// `True` / `False` / `true` / `false`. + Bool(bool), + /// `None` / `null` / `undefined`. + Null, + /// `[…]` or a Python tuple `(…)`. + List(Vec), + /// `{…}` with string or bare-identifier keys, in source order. + Dict(Vec<(String, Literal)>), +} + +/// One `name(args)` statement as parsed, before the registry binds it. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct Call { + /// The callee, dots included (`functions.read_file`). + pub(crate) name: String, + /// Arguments given by position, in order. + pub(crate) positional: Vec, + /// Arguments given by keyword, in order. + pub(crate) keywords: Vec<(String, Literal)>, +} + +/// The parser's one failure: the text is not a code call this grammar will +/// accept. It carries no detail on purpose — the caller falls through to the +/// next decoder, and the reason is logged where it is decided. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Refuse; diff --git a/crates/tinytools-agent/src/dialect/code.rs b/crates/tinytools-agent/src/dialect/code.rs new file mode 100644 index 0000000..8e81d5e --- /dev/null +++ b/crates/tinytools-agent/src/dialect/code.rs @@ -0,0 +1,109 @@ +//! The code-style dialect: `read_file(path="src/main.rs")`. +//! +//! The catalogue is a list of function signatures and the call is a function +//! call — the two things a code-trained model has written most. Cheaper than +//! JSON on both sides, and unlike P-Format not a syntax the model has to be +//! taught. See [`crate::codecall`] for the grammar. +//! +//! Like P-Format it degrades rather than fails: a tag body that is not a code +//! call falls through to the JSON parser, so a model that mixes forms — or +//! ignores the protocol and emits JSON — is still understood. + +use std::sync::Arc; + +use super::ToolDialect; +use super::types::{DialectMessage, DialectResponse, ToolCallFormat, ToolOutcome, TranscriptEntry}; +use crate::codecall::CodeStyle; +use crate::parse::parse_text; +use crate::render; +use crate::types::ParseOptions; +use crate::{PFormatRegistry, ParsedToolCall}; +use tinytools::ToolSpec; + +/// Code-style tool calling, in Python or TypeScript spelling, driven by the +/// same registry of parameter layouts P-Format uses. +#[derive(Debug, Clone)] +pub struct CodeDialect { + style: CodeStyle, + /// Name → parameter layout, built once from the agent's real tools. The + /// same safety boundary as [`super::PFormatDialect`]: the parser refuses + /// to bind arguments for a tool it does not know. + registry: Arc, +} + +impl CodeDialect { + /// Build the dialect over a prepared registry. + #[must_use] + pub fn new(style: CodeStyle, registry: PFormatRegistry) -> Self { + Self { + style, + registry: Arc::new(registry), + } + } + + /// Share an already-`Arc`'d registry rather than cloning the map. + #[must_use] + pub fn from_shared(style: CodeStyle, registry: Arc) -> Self { + Self { style, registry } + } + + /// The spelling this dialect renders. + #[must_use] + pub fn style(&self) -> CodeStyle { + self.style + } + + /// The registry backing this dialect. + #[must_use] + pub fn registry(&self) -> &PFormatRegistry { + self.registry.as_ref() + } + + /// The protocol block — **protocol only**, no catalogue. The signatures + /// live in the prompt's tool section, rendered by + /// [`crate::render::render_code_catalogue`] from the same schemas this + /// dialect parses against. + #[must_use] + pub fn instructions(style: CodeStyle) -> String { + render::code_instructions(style) + } +} + +impl ToolDialect for CodeDialect { + fn parse_response(&self, response: &DialectResponse) -> (String, Vec) { + let options = ParseOptions::new().with_registry(self.registry.as_ref()); + let (text, calls) = parse_text(response.text_or_empty(), &options).into_parts(); + crate::telemetry::debug!( + parse_mode = "code_combined", + style = self.style.as_str(), + parsed_tool_calls = calls.len(), + "code dialect parsed response" + ); + (text, calls) + } + + fn format_results(&self, results: &[ToolOutcome]) -> Vec { + render::format_results(results) + } + + fn prompt_instructions(&self, _tools: &[ToolSpec]) -> String { + Self::instructions(self.style) + } + + fn to_provider_messages(&self, history: &[TranscriptEntry]) -> Vec { + render::to_provider_messages(history) + } + + fn should_send_tool_specs(&self) -> bool { + // Text protocol: the model never sees a structured spec, only the + // catalogue in the system prompt. + false + } + + fn tool_call_format(&self) -> ToolCallFormat { + match self.style { + CodeStyle::Python => ToolCallFormat::Python, + CodeStyle::TypeScript => ToolCallFormat::TypeScript, + } + } +} diff --git a/crates/tinytools-agent/src/dialect/mod.rs b/crates/tinytools-agent/src/dialect/mod.rs index f662ebb..c9c440c 100644 --- a/crates/tinytools-agent/src/dialect/mod.rs +++ b/crates/tinytools-agent/src/dialect/mod.rs @@ -8,9 +8,9 @@ //! advertises one grammar and a parser that expects another is a silent //! whole-turn failure with no error anywhere. //! -//! Three dialects ship: [`XmlDialect`] (JSON in a tag), [`PFormatDialect`] -//! (compact positional), and [`NativeDialect`] (the provider's structured -//! channel). +//! Four dialects ship: [`XmlDialect`] (JSON in a tag), [`PFormatDialect`] +//! (compact positional), [`CodeDialect`] (a Python or TypeScript function +//! call), and [`NativeDialect`] (the provider's structured channel). //! //! This module speaks [`TranscriptEntry`], a deliberately thin record shape a //! host maps onto in a few `From` impls. It does not assume a provider message @@ -29,15 +29,19 @@ //! never decides what is allowed to *happen*. That boundary is what keeps a //! host's security policy in the host, where it can be audited. +mod code; mod native; mod pairing; mod pformat; mod types; mod xml; +pub use crate::codecall::CodeStyle; pub use crate::render::{ - CATALOGUE_HEADING, TOOL_RESULTS_PREFIX, render_json_catalogue, render_pformat_catalogue, + CATALOGUE_HEADING, TOOL_RESULTS_PREFIX, render_code_catalogue, render_json_catalogue, + render_pformat_catalogue, }; +pub use code::CodeDialect; pub use native::NativeDialect; pub use pairing::pair_tool_cycles; pub use pformat::PFormatDialect; diff --git a/crates/tinytools-agent/src/dialect/test.rs b/crates/tinytools-agent/src/dialect/test.rs index 5e51eae..c3adce2 100644 --- a/crates/tinytools-agent/src/dialect/test.rs +++ b/crates/tinytools-agent/src/dialect/test.rs @@ -3,7 +3,9 @@ use serde_json::{Value, json}; use super::*; +use crate::types::CallSource; use crate::{PFormatRegistry, build_registry}; +use std::sync::Arc; use tinytools::ToolSpec; /// The single transcript record a round of results almost always produces. @@ -835,3 +837,165 @@ fn json_call_rendering_round_trips_through_the_parser() { assert_eq!(calls[0].arguments["path"], "a.txt"); assert_eq!(calls[1].arguments, serde_json::json!({})); } + +// ── Code dialect ──────────────────────────────────────────────────────────── + +fn code_registry() -> PFormatRegistry { + build_registry([("get_weather", weather_schema().parameters)]) +} + +#[test] +fn code_dialect_parses_a_python_call() { + let dialect = CodeDialect::new(CodeStyle::Python, code_registry()); + let (text, calls) = dialect.parse_response(&response( + "Checking.\n\nget_weather(location=\"London\", unit=\"metric\")\n", + )); + assert_eq!(text, "Checking."); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "get_weather"); + assert_eq!(calls[0].arguments["location"], "London"); + assert_eq!(calls[0].arguments["unit"], "metric"); + assert_eq!(calls[0].source, CallSource::Code); +} + +#[test] +fn code_dialect_parses_a_typescript_object_call() { + let dialect = CodeDialect::new(CodeStyle::TypeScript, code_registry()); + let (_text, calls) = dialect.parse_response(&response( + "get_weather({location: \"London\", unit: \"metric\"})", + )); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].arguments["location"], "London"); + assert_eq!(calls[0].arguments["unit"], "metric"); +} + +#[test] +fn code_dialect_falls_back_to_json_and_pformat_per_tag() { + let dialect = CodeDialect::new(CodeStyle::Python, code_registry()); + let (_text, calls) = dialect.parse_response(&response( + "get_weather(\"London\")\n\ + get_weather[0|Paris]\n\ + {\"name\": \"other_tool\", \"arguments\": {\"x\": 1}}", + )); + let sources: Vec = calls.iter().map(|c| c.source).collect(); + assert_eq!( + sources, + [ + CallSource::Code, + CallSource::PFormat, + CallSource::TaggedJson + ] + ); +} + +#[test] +fn code_dialect_leaves_the_catalogue_to_the_prompt() { + let dialect = CodeDialect::new(CodeStyle::Python, PFormatRegistry::new()); + let instructions = dialect.prompt_instructions(&[weather_schema()]); + assert!(instructions.starts_with("## Tool Use Protocol")); + assert!(instructions.contains("read_file(path=\"src/main.rs\", limit=20)")); + assert!(!instructions.contains("Look up the weather")); + assert!(!instructions.contains("get_weather")); + assert!(!dialect.embeds_tool_catalogue()); + assert!(!dialect.should_send_tool_specs()); + assert_eq!(dialect.style(), CodeStyle::Python); + assert!(dialect.registry().is_empty()); + + let ts = CodeDialect::from_shared(CodeStyle::TypeScript, Arc::new(PFormatRegistry::new())); + assert!( + ts.prompt_instructions(&[]) + .contains("read_file({path: \"src/main.rs\", limit: 20})") + ); + assert_eq!(ts.tool_call_format(), ToolCallFormat::TypeScript); + assert_eq!(dialect.tool_call_format(), ToolCallFormat::Python); +} + +#[test] +fn code_dialect_delegates_results_and_replay_to_the_text_renderer() { + let dialect = CodeDialect::new(CodeStyle::Python, PFormatRegistry::new()); + let pformat = PFormatDialect::new(PFormatRegistry::new()); + let outcomes = [ToolOutcome::ok("get_weather", "sunny")]; + assert_eq!( + dialect.format_results(&outcomes), + pformat.format_results(&outcomes) + ); + let history = vec![TranscriptEntry::Chat(DialectMessage::user("hi"))]; + assert_eq!( + dialect.to_provider_messages(&history), + pformat.to_provider_messages(&history) + ); +} + +#[test] +fn code_catalogue_is_one_signature_per_line_in_binding_order() { + let tools = [ + weather_schema(), + schema( + "shell", + "Run a shell command.\nMulti-line description.", + json!({ + "type": "object", + "properties": { + "command": {"type": "string"}, + "background": {"type": "boolean"}, + "timeout": {"type": "number"} + }, + "required": ["command"] + }), + ), + schema("list_dir", "", json!({"type": "object", "properties": {}})), + ]; + + let python = render_code_catalogue(&tools, CodeStyle::Python); + assert_eq!( + python, + "## Tools\n\n\ + def get_weather(location: str = None, unit: str = None) -> str # Look up the weather\n\ + def shell(command: str, background: bool = None, timeout: float = None) -> str # Run a shell command. Multi-line description.\n\ + def list_dir() -> str\n" + ); + + let typescript = render_code_catalogue(&tools, CodeStyle::TypeScript); + assert_eq!( + typescript, + "## Tools\n\n\ + function get_weather(location?: string, unit?: string): string; // Look up the weather\n\ + function shell(command: string, background?: boolean, timeout?: number): string; // Run a shell command. Multi-line description.\n\ + function list_dir(): string;\n" + ); + + // The catalogue order is the order the parser binds, not a coincidence. + let dialect = CodeDialect::new( + CodeStyle::Python, + build_registry(tools.iter().map(|t| (t.name.clone(), t.parameters.clone()))), + ); + let (_text, calls) = + dialect.parse_response(&response("shell(\"ls\", True, 2)")); + assert_eq!( + calls[0].arguments, + json!({"command": "ls", "background": true, "timeout": 2}) + ); +} + +#[test] +fn code_catalogue_is_smaller_than_the_json_catalogue() { + let tools = [weather_schema()]; + let json_len = render_json_catalogue(&tools).len(); + let python_len = render_code_catalogue(&tools, CodeStyle::Python).len(); + let typescript_len = render_code_catalogue(&tools, CodeStyle::TypeScript).len(); + assert!( + python_len < json_len, + "python {python_len} vs json {json_len}" + ); + assert!( + typescript_len < json_len, + "typescript {typescript_len} vs json {json_len}" + ); + // The protocol block is where most of the per-turn saving lives. + let code_block = CodeDialect::instructions(CodeStyle::Python).len(); + let pformat_block = PFormatDialect::instructions().len(); + assert!( + code_block < pformat_block / 2, + "code block {code_block} vs pformat block {pformat_block}" + ); +} diff --git a/crates/tinytools-agent/src/dialect/types.rs b/crates/tinytools-agent/src/dialect/types.rs index 5bd17b3..b2ca7bf 100644 --- a/crates/tinytools-agent/src/dialect/types.rs +++ b/crates/tinytools-agent/src/dialect/types.rs @@ -295,4 +295,9 @@ pub enum ToolCallFormat { Json, /// The provider supplies structured calls; the catalogue is informational. Native, + /// A Python function call inside a tag, with `def` signatures in the prompt. + Python, + /// A TypeScript function call inside a tag, with `function` signatures in + /// the prompt. + TypeScript, } diff --git a/crates/tinytools-agent/src/lib.rs b/crates/tinytools-agent/src/lib.rs index 835e076..624aee0 100644 --- a/crates/tinytools-agent/src/lib.rs +++ b/crates/tinytools-agent/src/lib.rs @@ -9,6 +9,8 @@ //! * [`repair`] recovers damaged JSON, damaged tool names, and mis-shaped //! arguments after a call has been located; //! * [`stream`] scrubs the same markup from a live text stream; +//! * [`codecall`] reads code-style calls — `read_file(path="x")` — the +//! form a code-trained model already writes; //! * [`render`] produces what the model reads: the catalogue, the protocol //! block, the result envelope; //! * [`dialect`] binds one rendering to one parser so they cannot drift. @@ -26,6 +28,7 @@ //! can be audited. See [`parse`] for the bounds on how forgiving the parsers //! are and why. +pub mod codecall; pub mod dialect; pub mod parse; pub(crate) mod pformat; @@ -39,6 +42,7 @@ pub mod types; /// a consumer that only speaks the protocol need not name `tinytools` itself. pub use tinytools; +pub use codecall::{CodeStyle, parse_calls as parse_code_calls, render_code_signature}; pub use parse::{ extract_json_values, parse_arguments_value, parse_glm_style_tool_calls, parse_text, parse_tool_call_value, parse_tool_calls, parse_tool_calls_from_json_value, diff --git a/crates/tinytools-agent/src/parse/grammar/tagged.rs b/crates/tinytools-agent/src/parse/grammar/tagged.rs index 0075afa..66d3dbc 100644 --- a/crates/tinytools-agent/src/parse/grammar/tagged.rs +++ b/crates/tinytools-agent/src/parse/grammar/tagged.rs @@ -286,10 +286,20 @@ pub(crate) fn decode_body(body: &str, options: &ParseOptions<'_>) -> Vec\ndone"; + let (narrative, calls) = parse_tool_calls_with_pformat(response, &echo_registry()); + assert_eq!(narrative, "Looking.\ndone"); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "echo"); + assert_eq!(calls[0].arguments, serde_json::json!({"value": "hello"})); + assert_eq!(calls[0].source, CallSource::Code); + assert!(calls[0].id.is_none(), "text calls never carry an id"); +} + +#[test] +fn code_calls_need_a_registry() { + // Without a registry there is no layout to bind against, so the body is + // a recognised block with no call in it — exactly the P-Format rule. + let (_, calls) = parse("echo(value=\"hello\")"); + assert!(calls.is_empty()); +} + +#[test] +fn several_code_calls_in_one_tag_keep_source_order() { + let response = "\necho(\"a\")\necho(value=\"b\");\n"; + let (_, calls) = parse_tool_calls_with_pformat(response, &echo_registry()); + let values: Vec<&str> = calls + .iter() + .map(|c| c.arguments["value"].as_str().unwrap()) + .collect(); + assert_eq!(values, ["a", "b"]); + assert!(calls.iter().all(|c| c.source == CallSource::Code)); +} + +#[test] +fn a_code_call_wrapped_in_a_python_fence_inside_the_tag_is_unwrapped() { + let response = "\n```python\necho(value=\"hi\")\n```\n"; + let (_, calls) = parse_tool_calls_with_pformat(response, &echo_registry()); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].source, CallSource::Code); +} + +#[test] +fn a_top_level_python_fence_is_an_example_not_a_call() { + let response = "Like this:\n```python\necho(value=\"hi\")\n```\n"; + let (text, calls) = parse_tool_calls_with_pformat(response, &echo_registry()); + assert!(calls.is_empty()); + assert!(text.contains("echo(value=\"hi\")")); +} + +#[test] +fn a_fenced_tool_call_block_holds_a_code_call() { + let response = "```tool_call\necho(value=\"hi\")\n```"; + let (_, calls) = parse_tool_calls_with_pformat(response, &echo_registry()); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].source, CallSource::Code); +} + +#[test] +fn code_pformat_json_and_glm_siblings_all_survive() { + let response = "echo(value=\"c\")\n\ + echo[0|p]\n\ + {\"name\": \"shell\", \"arguments\": {\"command\": \"ls\"}}\n\ + shell/command>pwd"; + let (_, calls) = parse_tool_calls_with_pformat(response, &echo_registry()); + let sources: Vec = calls.iter().map(|c| c.source).collect(); + assert_eq!( + sources, + [ + CallSource::Code, + CallSource::PFormat, + CallSource::TaggedJson, + CallSource::Glm + ] + ); +} + +#[test] +fn a_prose_body_mentioning_a_call_is_malformed_not_a_call() { + let response = "I will call echo(value=\"hi\") now"; + let (_, calls) = parse_tool_calls_with_pformat(response, &echo_registry()); + assert!(calls.is_empty()); +} + +#[test] +fn a_code_call_to_an_unknown_tool_is_not_a_call() { + let response = "rm_rf(path=\"/\")"; + let (_, calls) = parse_tool_calls_with_pformat(response, &echo_registry()); + assert!(calls.is_empty()); +} diff --git a/crates/tinytools-agent/src/pformat.rs b/crates/tinytools-agent/src/pformat.rs index fe83bdb..62c2b21 100644 --- a/crates/tinytools-agent/src/pformat.rs +++ b/crates/tinytools-agent/src/pformat.rs @@ -415,7 +415,7 @@ fn split_pipes(input: &str) -> Vec { /// Coerce a raw string argument into the JSON type the schema expects. /// Falls back to `Value::String` for any failed coercion so the model /// still gets a usable value into the tool argument map. -fn coerce_value(raw: &str, ty: PFormatParamType) -> Value { +pub(crate) fn coerce_value(raw: &str, ty: PFormatParamType) -> Value { match ty { PFormatParamType::Integer => raw.trim().parse::().map_or_else( |_| Value::String(raw.to_string()), diff --git a/crates/tinytools-agent/src/render/catalogue.rs b/crates/tinytools-agent/src/render/catalogue.rs index b59766a..b424478 100644 --- a/crates/tinytools-agent/src/render/catalogue.rs +++ b/crates/tinytools-agent/src/render/catalogue.rs @@ -8,6 +8,9 @@ //! * [`render_json_catalogue`] gives it the **whole parameter schema**, which //! is what a JSON-in-tag dialect needs because the model has to name each //! argument itself. +//! * [`render_code_catalogue`] gives it a **function signature** in Python or +//! TypeScript — one line per tool, the shape a code-trained model has seen +//! most — for the code-call dialect. //! //! Neither is the "informational" case: when the provider receives real tool //! specs in the request, repeating them in the prompt is pure token bloat, so @@ -15,6 +18,7 @@ use std::fmt::Write as _; +use crate::codecall::{CodeStyle, render_code_signature}; use crate::pformat::render_signature_from_schema; use tinytools::ToolSpec; @@ -59,3 +63,39 @@ pub fn render_json_catalogue(tools: &[ToolSpec]) -> String { } out } + +/// Render the code-style catalogue: one signature per line, the description +/// as a trailing comment. +/// +/// ```text +/// ## Tools +/// +/// def read_file(path: str, limit: int = None) -> str # Read a file +/// ``` +/// +/// Parameter order comes from the same `from_schema` the parser binds +/// positional arguments with, so the catalogue and the parser agree by +/// construction. Descriptions are collapsed onto one line: a newline inside +/// a comment would end it. +#[must_use] +pub fn render_code_catalogue(tools: &[ToolSpec], style: CodeStyle) -> String { + let marker = match style { + CodeStyle::Python => "#", + CodeStyle::TypeScript => "//", + }; + let mut out = String::from(CATALOGUE_HEADING); + for tool in tools { + let signature = render_code_signature(&tool.name, &tool.parameters, style); + let description = tool + .description + .split_whitespace() + .collect::>() + .join(" "); + if description.is_empty() { + let _ = writeln!(out, "{signature}"); + } else { + let _ = writeln!(out, "{signature} {marker} {description}"); + } + } + out +} diff --git a/crates/tinytools-agent/src/render/instructions.rs b/crates/tinytools-agent/src/render/instructions.rs index 9438016..cd63fbe 100644 --- a/crates/tinytools-agent/src/render/instructions.rs +++ b/crates/tinytools-agent/src/render/instructions.rs @@ -3,13 +3,14 @@ //! One place, so the wording a model reads and the grammar the parser //! expects cannot drift apart. The JSON block embeds its catalogue because the //! schemas *are* the protocol for a model writing argument names by hand; the -//! P-Format block does not, because its signatures live in the prompt's tool -//! section next to the descriptions; the native block carries no catalogue at -//! all, because the request does. +//! P-Format and code blocks do not, because their signatures live in the +//! prompt's tool section next to the descriptions; the native block carries no +//! catalogue at all, because the request does. use tinytools::ToolSpec; use super::catalogue::render_json_catalogue; +use crate::codecall::CodeStyle; /// The JSON-in-tag protocol block plus the full-schema catalogue. #[must_use] @@ -78,3 +79,51 @@ pub fn native_instructions() -> String { ] .join("\n") } + +/// The code-call protocol block — protocol only, no catalogue. +/// +/// Short on purpose: the whole point of the dialect is that a code-trained +/// model already knows how to write a function call, so the block only has to +/// say where to put it and what a value may be. +#[must_use] +pub fn code_instructions(style: CodeStyle) -> String { + let mut out = String::new(); + out.push_str("## Tool Use Protocol\n\n"); + match style { + CodeStyle::Python => { + out.push_str( + "Call a tool by writing a Python function call inside `` tags, \ + one call per line:\n\n", + ); + out.push_str("```\n\nread_file(path=\"src/main.rs\", limit=20)\n\n```\n\n"); + out.push_str( + "- Use the signatures in `## Tools`. Prefer keyword arguments; positional \ + arguments follow the signature order.\n\ + - Values are Python literals only: quoted strings, numbers, True/False/None, \ + lists, dicts. Omit optional arguments you do not need.\n", + ); + } + CodeStyle::TypeScript => { + out.push_str( + "Call a tool by writing a function call inside `` tags, one call \ + per line, passing the arguments as one object:\n\n", + ); + out.push_str( + "```\n\nread_file({path: \"src/main.rs\", limit: 20})\n\n```\n\n", + ); + out.push_str( + "- Use the signatures in `## Tools`. Keys are the parameter names; positional \ + arguments in signature order also work.\n\ + - Values are literals only: quoted strings, numbers, true/false/null, arrays, \ + objects. Omit optional arguments you do not need.\n", + ); + } + } + out.push_str( + "- Write only calls inside the tags: no prose, no code fences. For several calls, \ + use one line each or one `` block each.\n\ + - After execution, results appear in `` tags. Continue reasoning with \ + the results until you can give a final answer.\n\n", + ); + out +} diff --git a/crates/tinytools-agent/src/render/mod.rs b/crates/tinytools-agent/src/render/mod.rs index f63562a..228f5e7 100644 --- a/crates/tinytools-agent/src/render/mod.rs +++ b/crates/tinytools-agent/src/render/mod.rs @@ -21,6 +21,10 @@ pub mod instructions; pub mod results; pub use calls::{render_json_call, render_json_calls}; -pub use catalogue::{CATALOGUE_HEADING, render_json_catalogue, render_pformat_catalogue}; -pub use instructions::{json_instructions, native_instructions, pformat_instructions}; +pub use catalogue::{ + CATALOGUE_HEADING, render_code_catalogue, render_json_catalogue, render_pformat_catalogue, +}; +pub use instructions::{ + code_instructions, json_instructions, native_instructions, pformat_instructions, +}; pub use results::{TOOL_RESULTS_PREFIX, format_results, to_provider_messages}; diff --git a/crates/tinytools-agent/src/stream/test.rs b/crates/tinytools-agent/src/stream/test.rs index c49f63f..270e899 100644 --- a/crates/tinytools-agent/src/stream/test.rs +++ b/crates/tinytools-agent/src/stream/test.rs @@ -222,3 +222,33 @@ fn known_tools_repair_streamed_names() { let step = s.feed("{\"name\":\"functions.read_file\",\"arguments\":{}}"); assert_eq!(step.calls[0].name, "read_file"); } + +#[test] +fn a_code_call_split_mid_string_is_released_once_and_never_shown() { + let registry = crate::build_registry([( + "echo", + serde_json::json!({"type": "object", "properties": {"value": {"type": "string"}}}), + )]); + let mut s = StreamScrubber::new().with_registry(std::sync::Arc::new(registry)); + let mut out = String::new(); + let mut calls = Vec::new(); + for f in [ + "Sure. \necho(value=\"he", + "llo)\")\n done", + ] { + let step = s.feed(f); + out.push_str(&step.text); + calls.extend(step.calls); + } + let step = s.flush(); + out.push_str(&step.text); + calls.extend(step.calls); + + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "echo"); + assert_eq!(calls[0].arguments["value"], "hello)"); + assert_eq!(calls[0].source, crate::types::CallSource::Code); + assert!(!out.contains("echo("), "markup leaked: {out:?}"); + assert!(out.contains("Sure.") && out.contains("done")); +} diff --git a/crates/tinytools-agent/src/types.rs b/crates/tinytools-agent/src/types.rs index d3c9a29..7d61500 100644 --- a/crates/tinytools-agent/src/types.rs +++ b/crates/tinytools-agent/src/types.rs @@ -34,6 +34,8 @@ pub enum CallSource { BareJson, /// P-Format `name[index|value]` inside a tag. PFormat, + /// A code-style call `name(arg="value")` inside a tag, registry-gated. + Code, } /// One model-requested tool invocation recovered from text or structured data. diff --git a/docs/specs/agent-tool-protocols.md b/docs/specs/agent-tool-protocols.md index 5e19529..faa8643 100644 --- a/docs/specs/agent-tool-protocols.md +++ b/docs/specs/agent-tool-protocols.md @@ -29,6 +29,28 @@ matching one is looking at a bug to fix here. optionally `tracing`. It never depends on an inference runtime, a transport, or TinyAgents. `tinyinference-llm` and `tinyagents-harness` depend on it. +## Text dialects + +Three text dialects share one parser and one result envelope and differ only +in what the model reads and how it spells a call: + +| Dialect | Catalogue | Call | Registry | +| --- | --- | --- | --- | +| `XmlDialect` | full JSON schemas, embedded in the protocol block | `{"name":…,"arguments":{…}}` | no | +| `PFormatDialect` | `name[0\|\|1\|]` signatures in the prompt's tool section | `name[0\|value]` | yes | +| `CodeDialect` | `def name(a: str, b: int = None) -> str` or `function name(a: string, b?: number): string;` signatures in the prompt's tool section | `name(a="value")` or `name({a: "value"})` | yes | + +The code dialect exists for small, code-trained models: a function signature +is both shorter than a schema and a form the model has written millions of +times, where P-Format is a syntax it has never seen. Its grammar accepts +literals only, binds positional arguments in the catalogue's order (required +first, then optional alphabetically — the same `from_schema` order P-Format +uses), unpacks a single object argument when every key is a declared +parameter, and refuses whole bodies that contain anything but calls to known +tools. A top-level ```` ```python ```` fence remains an example everywhere in +this crate; only the `` tag and the ```` ```tool_call ```` fence +carry calls. + ## Compatibility `parse_tool_calls`, `parse_tool_calls_with_pformat`, `parse_tool_call_value`, @@ -36,3 +58,6 @@ or TinyAgents. `tinyinference-llm` and `tinyagents-harness` depend on it. `parse_arguments_value`, `parse_glm_style_tool_calls` and the `dialect` API keep their signatures. `ParsedToolCall` gained a `source: CallSource` field and a `new` / `native` constructor; construct it through those. +`CallSource` is `#[non_exhaustive]`; `dialect::ToolCallFormat` is not, and +gained `Python` and `TypeScript`, so a consumer matching it exhaustively +must add the arms when it takes this version. From c186f9dc18313d2de888b411ba012107a28af445 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 04:54:45 +0300 Subject: [PATCH 2/5] feat(agent): harden code-call literal parsing and signature rendering The literal parser now enforces a maximum nesting depth of 64, rejects malformed numbers with leading zeros or trailing separators, and refuses f-strings containing interpolation braces. Tool names that are not valid identifiers now render as an explicit unsupported-tool comment, and invalid parameter names fall back to a generic `args: dict` signature. Empty argument objects are no longer treated as a single unpacked object, and the `$` character is accepted in bare words. Auto-committed-on: dragonfly Co-authored-by: Medulla --- Cargo.lock | 4 +- Cargo.toml | 2 +- crates/tinytools-agent/Cargo.toml | 2 +- .../tinytools-agent/src/codecall/literal.rs | 108 +++++++++++++----- crates/tinytools-agent/src/codecall/mod.rs | 2 +- .../tinytools-agent/src/codecall/signature.rs | 76 +++++++++++- crates/tinytools-agent/src/codecall/test.rs | 72 +++++++++++- 7 files changed, 229 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index acb14a9..9993f63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -149,7 +149,7 @@ dependencies = [ [[package]] name = "tinytools" -version = "0.3.0" +version = "0.4.0" dependencies = [ "anyhow", "async-trait", @@ -160,7 +160,7 @@ dependencies = [ [[package]] name = "tinytools-agent" -version = "0.3.0" +version = "0.4.0" dependencies = [ "regex", "serde", diff --git a/Cargo.toml b/Cargo.toml index c8b237f..3d41a24 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ exclude = ["worktrees"] # true`, so the version the release workflow bumps is written in exactly one # place and every crate moves together. [workspace.package] -version = "0.3.0" +version = "0.4.0" edition = "2024" rust-version = "1.88" license = "GPL-3.0-only" diff --git a/crates/tinytools-agent/Cargo.toml b/crates/tinytools-agent/Cargo.toml index e8f0cf7..66a2a52 100644 --- a/crates/tinytools-agent/Cargo.toml +++ b/crates/tinytools-agent/Cargo.toml @@ -14,7 +14,7 @@ readme = "README.md" regex = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -tinytools = { path = "../tinytools", version = "0.3.0" } +tinytools = { path = "../tinytools", version = "0.4.0" } tracing = { workspace = true, optional = true } [features] diff --git a/crates/tinytools-agent/src/codecall/literal.rs b/crates/tinytools-agent/src/codecall/literal.rs index 9dd0257..bddc979 100644 --- a/crates/tinytools-agent/src/codecall/literal.rs +++ b/crates/tinytools-agent/src/codecall/literal.rs @@ -10,6 +10,8 @@ use serde_json::{Map, Number, Value}; use super::types::{Literal, Refuse}; +const MAX_LITERAL_DEPTH: usize = 64; + /// A position in the source text, with the small vocabulary of lookahead /// the grammar needs. #[derive(Debug)] @@ -139,14 +141,21 @@ impl<'a> Cursor<'a> { /// [`Refuse`] when no literal starts here or the one that does is /// unterminated. pub(crate) fn literal(&mut self) -> Result { + self.literal_at_depth(0) + } + + fn literal_at_depth(&mut self, depth: usize) -> Result { + if depth > MAX_LITERAL_DEPTH { + return Err(Refuse); + } self.skip_trivia(); match self.peek().ok_or(Refuse)? { '"' | '\'' | '`' => self.string(false).map(Literal::Str), - '[' => self.sequence('[', ']').map(Literal::List), - '(' => self.sequence('(', ')').map(Literal::List), - '{' => self.dict().map(Literal::Dict), + '[' => self.sequence('[', ']', depth).map(Literal::List), + '(' => self.sequence('(', ')', depth).map(Literal::List), + '{' => self.dict(depth).map(Literal::Dict), c if c == '-' || c == '+' || c.is_ascii_digit() => self.number(), - c if c.is_alphabetic() || c == '_' => self.word(), + c if c.is_alphabetic() || c == '_' || c == '$' => self.word(), _ => Err(Refuse), } } @@ -164,7 +173,13 @@ impl<'a> Cursor<'a> { if matches!(self.peek(), Some('"' | '\'')) => { let raw = word.contains(['r', 'R']); - self.string(raw).map(Literal::Str) + let formatted = word.contains('f'); + let value = self.string(raw)?; + if formatted && value.contains(['{', '}']) { + self.pos = start; + return Err(Refuse); + } + Ok(Literal::Str(value)) } _ => { self.pos = start; @@ -252,36 +267,52 @@ impl<'a> Cursor<'a> { /// sign. Integers that overflow `i64` become floats. fn number(&mut self) -> Result { let rest = self.rest(); - let mut end = 0; + let bytes = rest.as_bytes(); + let mut end = usize::from(matches!(bytes.first(), Some(b'+' | b'-'))); + let integer_start = end; + consume_digits(bytes, &mut end)?; + if bytes.get(integer_start) == Some(&b'0') + && bytes.get(integer_start + 1).is_some_and(u8::is_ascii_digit) + { + return Err(Refuse); + } let mut is_float = false; - for (idx, c) in rest.char_indices() { - let ok = match c { - '0'..='9' | '_' => true, - '+' | '-' => idx == 0 || matches!(rest[..idx].chars().last(), Some('e' | 'E')), - '.' | 'e' | 'E' => { - is_float = true; - true - } - _ => false, - }; - if !ok { - break; + if bytes.get(end) == Some(&b'.') { + is_float = true; + end += 1; + if bytes.get(end).is_some_and(u8::is_ascii_digit) { + consume_digits(bytes, &mut end)?; } - end = idx + c.len_utf8(); } - let text: String = rest[..end].chars().filter(|c| *c != '_').collect(); - if text.is_empty() || text == "-" || text == "+" { + if matches!(bytes.get(end), Some(b'e' | b'E')) { + is_float = true; + end += 1; + if matches!(bytes.get(end), Some(b'+' | b'-')) { + end += 1; + } + consume_digits(bytes, &mut end)?; + } + if bytes + .get(end) + .is_some_and(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.')) + { return Err(Refuse); } - self.pos += end; + let text: String = rest[..end].chars().filter(|c| *c != '_').collect(); if !is_float && let Ok(n) = text.parse::() { + self.pos += end; return Ok(Literal::Int(n)); } - text.parse::().map(Literal::Float).map_err(|_| Refuse) + let value = text.parse::().map_err(|_| Refuse)?; + if !value.is_finite() { + return Err(Refuse); + } + self.pos += end; + Ok(Literal::Float(value)) } /// A bracketed, comma-separated sequence with an optional trailing comma. - fn sequence(&mut self, open: char, close: char) -> Result, Refuse> { + fn sequence(&mut self, open: char, close: char, depth: usize) -> Result, Refuse> { if self.bump() != Some(open) { return Err(Refuse); } @@ -292,7 +323,7 @@ impl<'a> Cursor<'a> { self.bump(); return Ok(items); } - items.push(self.literal()?); + items.push(self.literal_at_depth(depth + 1)?); self.skip_trivia(); match self.bump() { Some(',') => {} @@ -303,7 +334,7 @@ impl<'a> Cursor<'a> { } /// `{key: value, …}` with quoted-string or bare-identifier keys. - fn dict(&mut self) -> Result, Refuse> { + fn dict(&mut self, depth: usize) -> Result, Refuse> { if self.bump() != Some('{') { return Err(Refuse); } @@ -322,7 +353,7 @@ impl<'a> Cursor<'a> { if self.bump() != Some(':') { return Err(Refuse); } - let value = self.literal()?; + let value = self.literal_at_depth(depth + 1)?; entries.push((key, value)); self.skip_trivia(); match self.bump() { @@ -334,6 +365,29 @@ impl<'a> Cursor<'a> { } } +fn consume_digits(bytes: &[u8], end: &mut usize) -> Result<(), Refuse> { + let start = *end; + let mut previous_was_digit = false; + while let Some(byte) = bytes.get(*end) { + if byte.is_ascii_digit() { + previous_was_digit = true; + *end += 1; + } else if *byte == b'_' + && previous_was_digit + && bytes.get(*end + 1).is_some_and(u8::is_ascii_digit) + { + previous_was_digit = false; + *end += 1; + } else { + break; + } + } + if *end == start || !previous_was_digit { + return Err(Refuse); + } + Ok(()) +} + impl From for Value { fn from(literal: Literal) -> Self { match literal { diff --git a/crates/tinytools-agent/src/codecall/mod.rs b/crates/tinytools-agent/src/codecall/mod.rs index 3dc117b..8407794 100644 --- a/crates/tinytools-agent/src/codecall/mod.rs +++ b/crates/tinytools-agent/src/codecall/mod.rs @@ -289,7 +289,7 @@ fn unpack_single_object( let all_declared = entries .iter() .all(|(key, _)| params.names.iter().any(|name| name == key)); - if all_declared { + if !entries.is_empty() && all_declared { let Some(Literal::Dict(entries)) = positional.into_iter().next() else { return Ok((Vec::new(), Vec::new())); }; diff --git a/crates/tinytools-agent/src/codecall/signature.rs b/crates/tinytools-agent/src/codecall/signature.rs index 6dac336..b134b95 100644 --- a/crates/tinytools-agent/src/codecall/signature.rs +++ b/crates/tinytools-agent/src/codecall/signature.rs @@ -32,6 +32,12 @@ pub(crate) const MAX_PROPERTIES: usize = 16; /// Zero-parameter tools render as `def name() -> str` / `function name(): string;`. #[must_use] pub fn render_code_signature(name: &str, schema: &Value, style: CodeStyle) -> String { + if !is_identifier(name, style) { + return format!( + "# unsupported tool name: {}", + Value::String(name.to_owned()) + ); + } let params = PFormatToolParams::from_schema(schema); let properties = schema.get("properties").and_then(Value::as_object); let required: Vec<&str> = schema @@ -40,6 +46,20 @@ pub fn render_code_signature(name: &str, schema: &Value, style: CodeStyle) -> St .map(|names| names.iter().filter_map(Value::as_str).collect()) .unwrap_or_default(); + let has_invalid_param = params + .names + .iter() + .any(|param| !is_identifier(param, style)); + if has_invalid_param { + return match style { + CodeStyle::Python => format!("def {name}(args: dict) -> str"), + CodeStyle::TypeScript => format!( + "function {name}(args: {}): string;", + render_code_type(schema, style) + ), + }; + } + let mut out = String::new(); match style { CodeStyle::Python => out.push_str("def "), @@ -222,11 +242,65 @@ fn render_object( /// A JSON value spelled as a type-level literal (`"a"`, `1`, `true`). fn literal(value: &Value) -> String { match value { - Value::String(s) => format!("{s:?}"), + Value::String(s) => Value::String(s.clone()).to_string(), other => other.to_string(), } } +fn is_identifier(name: &str, style: CodeStyle) -> bool { + let syntactically_valid = name + .chars() + .next() + .is_some_and(|first| first.is_alphabetic() || first == '_' || first == '$') + && name + .chars() + .all(|ch| ch.is_alphanumeric() || ch == '_' || ch == '$'); + if !syntactically_valid { + return false; + } + match style { + CodeStyle::Python => !matches!( + name, + "False" + | "None" + | "True" + | "and" + | "as" + | "assert" + | "async" + | "await" + | "break" + | "class" + | "continue" + | "def" + | "del" + | "elif" + | "else" + | "except" + | "finally" + | "for" + | "from" + | "global" + | "if" + | "import" + | "in" + | "is" + | "lambda" + | "nonlocal" + | "not" + | "or" + | "pass" + | "raise" + | "return" + | "try" + | "while" + | "with" + | "yield" + ), + CodeStyle::TypeScript => true, + } +} + /// A property name as a TypeScript member: bare when it is an identifier, /// JSON-quoted otherwise (`{"file-path": string}`). fn ts_name(name: &str) -> String { diff --git a/crates/tinytools-agent/src/codecall/test.rs b/crates/tinytools-agent/src/codecall/test.rs index bce00d7..9ef3216 100644 --- a/crates/tinytools-agent/src/codecall/test.rs +++ b/crates/tinytools-agent/src/codecall/test.rs @@ -1,7 +1,5 @@ //! Unit tests for the code-call grammar: literals, binding, refusals, and //! the signature renderer. -#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] - use serde_json::{Value, json}; use super::{CodeStyle, parse_calls, render_code_signature, render_code_type}; @@ -54,7 +52,7 @@ fn one(body: &str) -> (String, Value) { 1, "expected exactly one call from {body:?}: {calls:?}" ); - calls.into_iter().next().unwrap() + calls.remove(0) } fn refused(body: &str) { @@ -221,6 +219,12 @@ fn raw_and_template_strings() { assert_eq!(args["path"], "src/x.rs"); } +#[test] +fn interpolated_strings_are_refused() { + refused(r#"read_file(path=f"{root}/data")"#); + refused(r#"read_file(path=fr"{root}/data")"#); +} + #[test] fn triple_quoted_strings_hold_newlines_and_quotes() { let (_, args) = one("shell(command=\"\"\"echo \"hi\"\nls\"\"\")"); @@ -258,6 +262,21 @@ fn numbers_in_both_spellings() { assert_eq!(args["limit"], 1000); } +#[test] +fn malformed_and_non_finite_numbers_are_refused() { + refused(r#"shell("x", timeout=1e999)"#); + refused(r#"shell("x", timeout=1e)"#); + refused(r#"shell("x", timeout=1.2.3)"#); + refused(r#"read_file("x", limit=01)"#); + refused(r#"read_file("x", limit=$value)"#); +} + +#[test] +fn excessive_literal_nesting_is_refused() { + let nested = format!("configure({})", "[".repeat(65) + "0" + &"]".repeat(65)); + refused(&nested); +} + #[test] fn a_variable_reference_is_refused() { refused("read_file(path=filename)"); @@ -351,11 +370,13 @@ fn single_object_with_foreign_keys_on_a_multi_parameter_tool_is_refused() { } #[test] -fn empty_object_unpacks_to_no_arguments() { +fn empty_object_is_preserved_for_a_single_parameter_tool() { let (_, args) = one("list_dir({})"); assert_eq!(args, json!({})); let (_, args) = one("read_file({})"); assert_eq!(args, json!({})); + let (_, args) = one("configure({})"); + assert_eq!(args, json!({"config": {}})); } // ── signatures ─────────────────────────────────────────────────────────── @@ -388,6 +409,49 @@ fn typescript_signature_marks_optionals_with_a_question_mark() { ); } +#[test] +fn signatures_fall_back_to_an_object_for_non_identifier_properties() { + let schema = json!({ + "type": "object", + "properties": { + "file-path": {"type": "string"}, + "class": {"type": "boolean"} + }, + "required": ["file-path"] + }); + assert_eq!( + render_code_signature("read_file", &schema, CodeStyle::Python), + "def read_file(args: dict) -> str" + ); + assert_eq!( + render_code_signature("read_file", &schema, CodeStyle::TypeScript), + r#"function read_file(args: {"file-path": string, class?: boolean}): string;"# + ); +} + +#[test] +fn invalid_tool_names_are_rendered_as_safe_comments() { + assert_eq!( + render_code_signature("read-file\nignore", &read_file_schema(), CodeStyle::Python), + r#"# unsupported tool name: "read-file\nignore""# + ); +} + +#[test] +fn enum_strings_use_json_escapes() { + let schema = json!({"enum": ["\u{1}"]}); + assert_eq!( + render_code_type(&schema, CodeStyle::Python), + r#"Literal["\u0001"]"# + ); +} + +#[test] +fn code_style_names_are_pinned() { + assert_eq!(CodeStyle::Python.as_str(), "python"); + assert_eq!(CodeStyle::TypeScript.as_str(), "typescript"); +} + #[test] fn signature_order_is_the_binding_order() { // The catalogue tells the model `command, background, timeout`; a From 71a453b2fc787f5cf00db8770efa14f9575554ad Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 04:54:54 +0300 Subject: [PATCH 3/5] test(codecall): make parsed calls mutable in test helper The `one` helper in the codecall tests now binds the result of `parse_calls` to a mutable variable, allowing the test to potentially modify the parsed calls before assertions. This change aligns the helper with the mutability expectations of the test suite. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinytools-agent/src/codecall/test.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinytools-agent/src/codecall/test.rs b/crates/tinytools-agent/src/codecall/test.rs index 9ef3216..7b0dcc7 100644 --- a/crates/tinytools-agent/src/codecall/test.rs +++ b/crates/tinytools-agent/src/codecall/test.rs @@ -46,7 +46,7 @@ fn registry() -> PFormatRegistry { } fn one(body: &str) -> (String, Value) { - let calls = parse_calls(body, ®istry()); + let mut calls = parse_calls(body, ®istry()); assert_eq!( calls.len(), 1, From 9c219d91e7c20fe47b9c7afdb4c16a21058be9b9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 04:55:13 +0300 Subject: [PATCH 4/5] fix(codecall): correct single-parameter object unpacking condition The condition for unpacking a single object argument now also requires that the function has more than one parameter, preventing premature unpacking when only one parameter is declared. This fixes the case where a single-parameter function with a dict argument was incorrectly treated as an object to unpack. The test expectation is updated to reflect the corrected ordering of properties in the rendered signature. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinytools-agent/src/codecall/mod.rs | 2 +- crates/tinytools-agent/src/codecall/test.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tinytools-agent/src/codecall/mod.rs b/crates/tinytools-agent/src/codecall/mod.rs index 8407794..6c923ad 100644 --- a/crates/tinytools-agent/src/codecall/mod.rs +++ b/crates/tinytools-agent/src/codecall/mod.rs @@ -289,7 +289,7 @@ fn unpack_single_object( let all_declared = entries .iter() .all(|(key, _)| params.names.iter().any(|name| name == key)); - if !entries.is_empty() && all_declared { + if (!entries.is_empty() || params.names.len() != 1) && all_declared { let Some(Literal::Dict(entries)) = positional.into_iter().next() else { return Ok((Vec::new(), Vec::new())); }; diff --git a/crates/tinytools-agent/src/codecall/test.rs b/crates/tinytools-agent/src/codecall/test.rs index 7b0dcc7..1ae41c0 100644 --- a/crates/tinytools-agent/src/codecall/test.rs +++ b/crates/tinytools-agent/src/codecall/test.rs @@ -425,7 +425,7 @@ fn signatures_fall_back_to_an_object_for_non_identifier_properties() { ); assert_eq!( render_code_signature("read_file", &schema, CodeStyle::TypeScript), - r#"function read_file(args: {"file-path": string, class?: boolean}): string;"# + r#"function read_file(args: {class?: boolean, "file-path": string}): string;"# ); } From 369cd3dde2159ddb8071231bfd2ff5caeb314065 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 05:02:45 +0300 Subject: [PATCH 5/5] fix(codecall): enforce language-specific identifier rules The identifier validation now respects the target language's syntax rules, using ASCII-only checks for both Python and TypeScript, and rejecting reserved words in TypeScript. This prevents generating invalid code signatures for properties like "$value" in Python or "class" in TypeScript, with tests covering these cases. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinytools-agent/src/codecall/signature.rs | 64 ++++++++++++++++--- crates/tinytools-agent/src/codecall/test.rs | 26 ++++++++ 2 files changed, 82 insertions(+), 8 deletions(-) diff --git a/crates/tinytools-agent/src/codecall/signature.rs b/crates/tinytools-agent/src/codecall/signature.rs index b134b95..2270d58 100644 --- a/crates/tinytools-agent/src/codecall/signature.rs +++ b/crates/tinytools-agent/src/codecall/signature.rs @@ -248,13 +248,21 @@ fn literal(value: &Value) -> String { } fn is_identifier(name: &str, style: CodeStyle) -> bool { - let syntactically_valid = name - .chars() - .next() - .is_some_and(|first| first.is_alphabetic() || first == '_' || first == '$') - && name - .chars() - .all(|ch| ch.is_alphanumeric() || ch == '_' || ch == '$'); + let mut chars = name.chars(); + let syntactically_valid = match style { + CodeStyle::Python => { + chars + .next() + .is_some_and(|first| first.is_ascii_alphabetic() || first == '_') + && chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_') + } + CodeStyle::TypeScript => { + chars + .next() + .is_some_and(|first| first.is_ascii_alphabetic() || matches!(first, '_' | '$')) + && chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '$')) + } + }; if !syntactically_valid { return false; } @@ -297,7 +305,47 @@ fn is_identifier(name: &str, style: CodeStyle) -> bool { | "with" | "yield" ), - CodeStyle::TypeScript => true, + CodeStyle::TypeScript => !matches!( + name, + "await" + | "break" + | "case" + | "catch" + | "class" + | "const" + | "continue" + | "debugger" + | "default" + | "delete" + | "do" + | "else" + | "enum" + | "export" + | "extends" + | "false" + | "finally" + | "for" + | "function" + | "if" + | "import" + | "in" + | "instanceof" + | "new" + | "null" + | "return" + | "super" + | "switch" + | "this" + | "throw" + | "true" + | "try" + | "typeof" + | "var" + | "void" + | "while" + | "with" + | "yield" + ), } } diff --git a/crates/tinytools-agent/src/codecall/test.rs b/crates/tinytools-agent/src/codecall/test.rs index 1ae41c0..546204b 100644 --- a/crates/tinytools-agent/src/codecall/test.rs +++ b/crates/tinytools-agent/src/codecall/test.rs @@ -427,6 +427,28 @@ fn signatures_fall_back_to_an_object_for_non_identifier_properties() { render_code_signature("read_file", &schema, CodeStyle::TypeScript), r#"function read_file(args: {class?: boolean, "file-path": string}): string;"# ); + + let language_specific = json!({ + "type": "object", + "properties": {"$value": {"type": "string"}} + }); + assert_eq!( + render_code_signature("read_file", &language_specific, CodeStyle::Python), + "def read_file(args: dict) -> str" + ); + assert_eq!( + render_code_signature("read_file", &language_specific, CodeStyle::TypeScript), + "function read_file($value?: string): string;" + ); + + let reserved = json!({ + "type": "object", + "properties": {"class": {"type": "boolean"}} + }); + assert_eq!( + render_code_signature("read_file", &reserved, CodeStyle::TypeScript), + "function read_file(args: {class?: boolean}): string;" + ); } #[test] @@ -435,6 +457,10 @@ fn invalid_tool_names_are_rendered_as_safe_comments() { render_code_signature("read-file\nignore", &read_file_schema(), CodeStyle::Python), r#"# unsupported tool name: "read-file\nignore""# ); + assert!( + render_code_signature("class", &read_file_schema(), CodeStyle::TypeScript) + .starts_with("# unsupported tool name:") + ); } #[test]