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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ is sufficient, whether a timeout applies, whether an external effect needs
approval — it belongs to the host, whose threat model and configuration the
decision depends on.

`crates/tinytools-std` holds host-independent tool building blocks (cross-agent
file staleness tracking, SSRF-safe URL validation, PATH probing). It is not part of the dependency-light vocabulary crate and may pull
in `tokio`, `parking_lot` and `log`.

Add a crate by creating `crates/<name>/` — `members = ["crates/*"]` picks it up
by existing. Inherit `version`, `edition`, `rust-version`, `license`, and
`repository` from `[workspace.package]`, take shared dependencies from
Expand Down
97 changes: 97 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/tinytools-agent/src/parse/test/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ mod function_call;
mod glm;
mod harmony_mistral;
mod invoke_xml;
mod regressions;
mod sentinel;
mod tagged;

Expand Down
132 changes: 132 additions & 0 deletions crates/tinytools-agent/src/parse/test/regressions.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
//! Host-reported regressions: parser behaviors an embedding host pinned with
//! its own tests before the parsers lived here.

use super::parse;
use crate::types::CallSource;

#[test]
fn very_large_arguments_still_parse() {
let large_arg = "x".repeat(100_000);
let response = format!(
r#"<tool_call>{{"name":"echo","arguments":{{"message":"{large_arg}"}}}}</tool_call>"#
);
let (_text, calls) = parse(&response);
assert_eq!(calls.len(), 1, "large arguments should still parse");
assert_eq!(calls[0].name, "echo");
}

#[test]
fn special_characters_in_arguments_survive() {
let response = r#"<tool_call>{"name":"echo","arguments":{"message":"hello \"world\" <>&'\n\t"}}</tool_call>"#;
let (_text, calls) = parse(response);
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "echo");
assert_eq!(calls[0].arguments["message"], "hello \"world\" <>&'\n\t");
}

#[test]
fn cross_alias_closing_tags_are_recovered() {
let response =
"<toolcall>\n{\"name\": \"shell\", \"arguments\": {\"command\": \"date\"}}\n</tool_call>";
let (text, calls) = parse(response);
assert!(text.is_empty());
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "shell");
}

#[test]
fn raw_tool_json_without_a_wrapper_is_not_a_call() {
// SECURITY: JSON that merely resembles a call, with no wrapper, must not
// execute; otherwise injected content could mimic a tool call.
let response = "Sure, creating the file now.\n{\"name\": \"file_write\", \"arguments\": {\"path\": \"hello.py\", \"content\": \"print('hello')\"}}";
let (text, calls) = parse(response);
assert!(text.contains("Sure, creating the file now."));
assert!(calls.is_empty(), "raw JSON without wrappers must not parse");
}

#[test]
fn an_empty_tool_result_block_is_not_a_call() {
let response = "I'll run that command.\n<tool_result name=\"shell\">\n\n</tool_result>\nDone.";
let (text, calls) = parse(response);
assert!(text.contains("Done."));
assert!(calls.is_empty());
}

#[test]
fn an_empty_tool_calls_array_is_returned_as_text() {
let response = r#"{"content": "Hello", "tool_calls": []}"#;
let (text, calls) = parse(response);
assert!(text.contains("Hello"));
assert!(calls.is_empty());
}

#[test]
fn invoke_tag_with_a_json_body_parses_like_tool_call() {
let input = "Some text\n<invoke>{\"name\":\"echo\",\"arguments\":{\"value\":\"hi\"}}</invoke>\ntrailing";
let (text, calls) = parse(input);
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "echo");
assert_eq!(calls[0].arguments, serde_json::json!({"value": "hi"}));
assert!(text.contains("Some text"));
assert!(text.contains("trailing"));
}

#[test]
fn invoke_attribute_form_does_not_leak_markup() {
let input =
"Sure.\n<invoke name=\"echo\">\n<parameter name=\"value\">hi</parameter>\n</invoke>\ndone";
let (text, calls) = parse(input);
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].arguments, serde_json::json!({"value": "hi"}));
assert!(text.contains("Sure.") && text.contains("done"));
assert!(!text.contains("<invoke") && !text.contains("<parameter"));
}

#[test]
fn invoke_attribute_form_scalar_policy_and_empty_names() {
let input = concat!(
"<invoke name=\"search\">\n",
"<parameter name=\"query\">rust parsers</parameter>\n",
"<parameter name=\"limit\">5</parameter>\n",
"<parameter name=\"fuzzy\">true</parameter>\n",
"<parameter name=\"\">ignored</parameter>\n",
"</invoke>"
);
let (_text, calls) = parse(input);
assert_eq!(calls.len(), 1);
assert_eq!(
calls[0].arguments,
serde_json::json!({"query": "rust parsers", "limit": 5, "fuzzy": true})
);
}

#[test]
fn invoke_without_a_name_attribute_is_not_a_call() {
let input = "<invoke foo=\"bar\">\n<parameter name=\"v\">hi</parameter>\n</invoke>";
let (_text, calls) = parse(input);
assert!(calls.is_empty());
}

#[test]
fn tool_call_json_and_invoke_attribute_blocks_mix_in_source_order() {
let input = concat!(
"<tool_call>{\"name\":\"first\",\"arguments\":{\"a\":1}}</tool_call>\n",
"<invoke name=\"second\">\n<parameter name=\"b\">two</parameter>\n</invoke>"
);
let (_text, calls) = parse(input);
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].name, "first");
assert_eq!(calls[0].arguments, serde_json::json!({"a": 1}));
assert_eq!(calls[1].name, "second");
assert_eq!(calls[1].arguments, serde_json::json!({"b": "two"}));
}

#[test]
fn markdown_fence_with_a_json_body_parses() {
let input = "preamble\n```tool_call\n{\"name\":\"ping\",\"arguments\":{}}\n```\npostamble";
let (text, calls) = parse(input);
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "ping");
assert!(calls[0].source != CallSource::Native);
assert!(text.contains("preamble") && text.contains("postamble"));
}
40 changes: 40 additions & 0 deletions crates/tinytools-agent/src/pformat_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -312,3 +312,43 @@ fn signature_round_trips_with_parser() {
assert_eq!(args["location"], json!("Berlin"));
assert_eq!(args["unit"], json!("imperial"));
}

fn echo_schema() -> serde_json::Value {
json!({
"type": "object",
"properties": {
"value": { "type": "string" },
"count": { "type": "integer" }
}
})
}

#[test]
fn build_registry_keys_on_the_tools_own_names() {
let reg = build_registry([("echo", echo_schema()), ("shell", echo_schema())]);
assert!(reg.contains_key("echo"));
assert!(reg.contains_key("shell"));
assert_eq!(reg.len(), 2);
}

#[test]
fn a_tool_absent_from_the_registry_cannot_be_called_by_guessing_its_name() {
// The parser must not invent argument names for a tool it does not know,
// or a model could tunnel arbitrary JSON through by guessing a name.
let reg = build_registry([("echo", echo_schema())]);
assert!(parse_call("shell[rm -rf /]", &reg).is_none());
}

#[test]
fn a_built_registry_parses_positionally_with_schema_ordered_slots() {
let reg = build_registry([("echo", echo_schema())]);
let (name, args) = parse_call("echo[0|3|1|hi]", &reg).expect("known tool parses");
assert_eq!(name, "echo");
// Schema properties are ordered alphabetically: count, value.
assert_eq!(args["count"], 3);
assert_eq!(args["value"], "hi");
assert_eq!(
render_signature_from_schema("echo", &echo_schema()),
"echo[0|<count>|1|<value>]"
);
}
27 changes: 27 additions & 0 deletions crates/tinytools-std/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
[package]
name = "tinytools-std"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Host-independent building blocks for agent tools: cross-agent file staleness tracking, SSRF-safe URL validation, and PATH probing."
documentation = "https://docs.rs/tinytools-std"
readme = "README.md"
publish = false

[dependencies]
anyhow = { workspace = true }
async-trait = { workspace = true }
log = "0.4"
parking_lot = "0.12"
serde_json = { workspace = true }
tinytools = { path = "../tinytools", version = "0.4.1" }
tokio = { version = "1", default-features = false, features = ["rt", "sync"] }
tracing = { workspace = true }

[dev-dependencies]
tokio = { workspace = true }

[lints]
workspace = true
11 changes: 11 additions & 0 deletions crates/tinytools-std/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# tinytools-std

Host-independent building blocks for agent tools, extracted from OpenHuman.

| Module | What it is |
| --- | --- |
| `file_state` | Process-wide read/write stamps so parallel agents detect stale or partial reads before overwriting a file. The host decides whether the guard is on (`init_global(enabled)`). |
| `url_guard` | URL validation with SSRF and DNS-rebinding checks for outbound network tools. |
| `detect_tools` | `find_on_path` and the read-only `detect_tools` tool. |

No enforcement of host policy lives here; the crate only supplies mechanisms.
Loading
Loading