Skip to content

Commit 1eb3dad

Browse files
authored
Merge pull request #5 from tinyhumansai/tool-call-protocol
Add agent tool protocol crate
2 parents b25dbf3 + db93cae commit 1eb3dad

21 files changed

Lines changed: 4822 additions & 0 deletions

‎Cargo.lock‎

Lines changed: 65 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ async-trait = "0.1"
3131
serde = { version = "1", features = ["derive"] }
3232
# Tool arguments and JSON Schema parameter shapes are untyped by nature.
3333
serde_json = "1"
34+
# Text-mode tool-call grammars recognize tolerant XML/tag variants.
35+
regex = "1"
36+
# Agent protocol diagnostics are optional so the vocabulary crate stays lean.
37+
tracing = { version = "0.1", default-features = false }
3438
# `Tool::execute` returns `anyhow::Result` because a tool body calls arbitrary
3539
# host code and has no useful closed error set of its own.
3640
anyhow = "1"

‎README.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,11 @@ compiles neither the harness nor the host.
6565
| `workspace` | `WorkspaceDescriptor`, `SandboxMode` — the root a tool may touch, and how strictly it is sandboxed |
6666
| `naming` | `humanize_tool_name`, `context_detail_from_args` — rendering a call for a human |
6767

68+
The workspace also contains `tinytools-agent`, a separate crate for
69+
model-facing tool-call parsing, dialects, catalogue/result rendering, and
70+
transcript replay. It builds on `ToolSpec` without adding agent-loop or provider
71+
dependencies to the base `tinytools` vocabulary crate.
72+
6873
## What is deliberately not here
6974

7075
**No enforcement.** Nothing in this crate checks a `PermissionLevel`, applies a

‎crates/tinytools-agent/Cargo.toml‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
[package]
2+
publish = false
3+
name = "tinytools-agent"
4+
version.workspace = true
5+
edition.workspace = true
6+
rust-version.workspace = true
7+
license.workspace = true
8+
repository.workspace = true
9+
description = "Agent-facing tool-call parsing, rendering, dialects, and transcript blocks."
10+
documentation = "https://docs.rs/tinytools-agent"
11+
readme = "README.md"
12+
13+
[dependencies]
14+
regex = { workspace = true }
15+
serde = { workspace = true }
16+
serde_json = { workspace = true }
17+
tinytools = { path = "../tinytools", version = "0.1.0" }
18+
tracing = { workspace = true, optional = true }
19+
20+
[features]
21+
default = []
22+
tracing = ["dep:tracing"]
23+
24+
[lints]
25+
workspace = true

‎crates/tinytools-agent/README.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# TinyTools Agent Protocols
2+
3+
`tinytools-agent` owns the model-facing protocol around a tool declaration:
4+
tool-call parsing, P-Format signatures, XML and native dialects, catalogue and
5+
result-block rendering, and safe transcript replay.
6+
7+
It consumes `tinytools::ToolSpec` and never executes a tool. Permission checks,
8+
approvals, sandboxing, timeouts, provider requests, and progress reporting
9+
remain responsibilities of the consuming harness or host.
10+
11+
The primary API is:
12+
13+
- root parsing and P-Format helpers;
14+
- `dialect::ToolDialect` and the XML, P-Format, and native implementations;
15+
- provider-neutral transcript, call, result, and message block types.
16+
17+
The crate has no dependency on TinyAgents or an inference/provider runtime.
18+
The optional `tracing` feature emits diagnostics for parser recovery and
19+
transcript repair decisions.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
//! Rendering a tool catalogue into a system prompt.
2+
//!
3+
//! Two shapes, because there are two things a model can be told:
4+
//!
5+
//! * [`render_pformat_catalogue`] gives it a **call signature** —
6+
//! `read_file[path]` — which is all a positional dialect needs and costs a
7+
//! handful of tokens per tool.
8+
//! * [`render_json_catalogue`] gives it the **whole parameter schema**, which
9+
//! is what a JSON-in-tag dialect needs because the model has to name each
10+
//! argument itself.
11+
//!
12+
//! Neither is the "informational" case: when the provider receives real tool
13+
//! specs in the request, repeating them in the prompt is pure token bloat, so
14+
//! the native dialect renders no catalogue at all.
15+
16+
use std::fmt::Write as _;
17+
18+
use crate::pformat::render_signature_from_schema;
19+
use tinytools::ToolSpec;
20+
21+
/// Heading the catalogue is rendered under.
22+
pub const CATALOGUE_HEADING: &str = "## Tools\n\n";
23+
24+
/// Render the compact positional catalogue: one line per tool carrying its
25+
/// description and its p-format call signature.
26+
///
27+
/// The signature comes straight from the parameter schema, through the same
28+
/// [`render_signature_from_schema`] the parser reconstructs arguments with — so
29+
/// the order the model reads is by construction the order the parser expects.
30+
/// Rendering it any other way is how a catalogue and a parser drift apart.
31+
#[must_use]
32+
pub fn render_pformat_catalogue(tools: &[ToolSpec]) -> String {
33+
let mut out = String::from(CATALOGUE_HEADING);
34+
for tool in tools {
35+
let signature = render_signature_from_schema(&tool.name, &tool.parameters);
36+
let _ = writeln!(
37+
out,
38+
"- **{}**: {}\n Call as: `{}`",
39+
tool.name, tool.description, signature
40+
);
41+
}
42+
out
43+
}
44+
45+
/// Render the full-schema catalogue: one line per tool carrying its description
46+
/// and its complete JSON-Schema parameter object.
47+
///
48+
/// Used by the JSON-in-tag dialect, where the model writes argument names out
49+
/// itself and therefore has to see them.
50+
#[must_use]
51+
pub fn render_json_catalogue(tools: &[ToolSpec]) -> String {
52+
let mut out = String::new();
53+
for tool in tools {
54+
let _ = writeln!(
55+
out,
56+
"- **{}**: {}\n Parameters: `{}`",
57+
tool.name, tool.description, tool.parameters
58+
);
59+
}
60+
out
61+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
//! End-to-end tool calling for a host that drives its own model loop.
2+
//!
3+
//! [`super`] answers *"what did the model ask for"*. This module answers
4+
//! everything around it: how the model is **told** to ask, how results are
5+
//! **rendered** back, and how a transcript is **replayed** onto the provider
6+
//! wire so the next iteration is well-formed. Together those are one thing —
7+
//! a **dialect** — and they have to move together, because a catalogue that
8+
//! advertises one grammar and a parser that expects another is a silent
9+
//! whole-turn failure with no error anywhere.
10+
//!
11+
//! Three dialects ship: [`XmlDialect`] (JSON in a tag), [`PFormatDialect`]
12+
//! (compact positional), and [`NativeDialect`] (the provider's structured
13+
//! channel).
14+
//!
15+
//! This module speaks [`TranscriptEntry`], a deliberately thin record shape a
16+
//! host maps onto in a few `From` impls. It does not assume a provider message
17+
//! type or anything about how and when the host calls the model.
18+
//!
19+
//! The second case is not a lesser one. A host with years of persisted
20+
//! transcripts, per-turn security policy, and provider quirks encoded in its
21+
//! own storage cannot adopt a foreign message model just to stop maintaining a
22+
//! tool-call parser — and the parser is the part that is genuinely universal.
23+
//! This module is the seam that lets it hand over the universal part alone.
24+
//!
25+
//! # What stays with the host
26+
//!
27+
//! Executing a tool: permission checks, sandboxing, approval gates, timeouts,
28+
//! progress events. A dialect decides what the model *reads and writes*; it
29+
//! never decides what is allowed to *happen*. That boundary is what keeps a
30+
//! host's security policy in the host, where it can be audited.
31+
32+
mod catalogue;
33+
mod native;
34+
mod pairing;
35+
mod pformat;
36+
mod text;
37+
mod types;
38+
mod xml;
39+
40+
pub use catalogue::{CATALOGUE_HEADING, render_json_catalogue, render_pformat_catalogue};
41+
pub use native::NativeDialect;
42+
pub use pairing::pair_tool_cycles;
43+
pub use pformat::PFormatDialect;
44+
pub use text::TOOL_RESULTS_PREFIX;
45+
pub use types::{
46+
DialectMessage, DialectResponse, DialectRole, NativeToolCall, ToolCallFormat, ToolOutcome,
47+
ToolResultEntry, TranscriptEntry,
48+
};
49+
pub use xml::XmlDialect;
50+
51+
use crate::ParsedToolCall;
52+
use tinytools::ToolSpec;
53+
54+
/// One complete way of speaking tools to a model.
55+
///
56+
/// Every method is a face of the same contract, which is why they are one
57+
/// trait: the catalogue the model reads, the calls it writes, the results it
58+
/// gets back, and the history it is re-shown all have to describe the same
59+
/// protocol. Splitting them into separate configurable pieces is how they drift.
60+
pub trait ToolDialect: Send + Sync {
61+
/// Split a model response into narrative text and the calls it requested.
62+
fn parse_response(&self, response: &DialectResponse) -> (String, Vec<ParsedToolCall>);
63+
64+
/// Render executed outcomes into the transcript records that follow the
65+
/// assistant turn.
66+
///
67+
/// Usually one record. It is a `Vec` because a
68+
/// [`trusted_verbatim`](ToolOutcome::trusted_verbatim) outcome must reach
69+
/// the model at byte 0 of its own message, which a batch cannot provide: a
70+
/// text dialect closes the batch before such an outcome and reopens it
71+
/// after. A dialect that never reshapes output — the native one — always
72+
/// returns exactly one record.
73+
fn format_results(&self, results: &[ToolOutcome]) -> Vec<TranscriptEntry>;
74+
75+
/// The protocol block for the system prompt.
76+
///
77+
/// Whether `tools` is used depends on the dialect: only a dialect that
78+
/// [embeds its own catalogue](Self::embeds_tool_catalogue) reads it. The
79+
/// others get their catalogue from the prompt's tool section, or need none
80+
/// at all.
81+
fn prompt_instructions(&self, tools: &[ToolSpec]) -> String;
82+
83+
/// Replay a transcript as flat provider messages.
84+
fn to_provider_messages(&self, history: &[TranscriptEntry]) -> Vec<DialectMessage>;
85+
86+
/// Whether structured tool specs belong in the API request.
87+
///
88+
/// `false` for the text dialects: sending specs a dialect cannot read back
89+
/// invites the model onto a channel nothing is listening on.
90+
fn should_send_tool_specs(&self) -> bool;
91+
92+
/// Whether [`Self::prompt_instructions`] already lists the tools.
93+
///
94+
/// A host uses this to avoid rendering the catalogue twice. Defaults to
95+
/// `false` — the common case, where the prompt's tool section owns it.
96+
fn embeds_tool_catalogue(&self) -> bool {
97+
false
98+
}
99+
100+
/// How the tool catalogue should spell each entry, so the prompt and this
101+
/// dialect's parser cannot disagree.
102+
fn tool_call_format(&self) -> ToolCallFormat;
103+
}
104+
105+
#[cfg(test)]
106+
#[path = "test.rs"]
107+
mod test;

0 commit comments

Comments
 (0)