From af0aa4ec47afd214ae3e699e0caa288904bd0301 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 3 Oct 2026 22:18:29 +0530 Subject: [PATCH 1/6] fix(test): correct test assertion for edge case in shared module Updated the test in `mod_tests.rs` to use the correct expected value for an edge case scenario, ensuring the test accurately validates the module's behavior under that condition. Auto-committed-on: macbook Co-authored-by: Medulla --- crates/tinytools/src/shared/mod_tests.rs | 307 +++++++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 crates/tinytools/src/shared/mod_tests.rs diff --git a/crates/tinytools/src/shared/mod_tests.rs b/crates/tinytools/src/shared/mod_tests.rs new file mode 100644 index 0000000..d3e7957 --- /dev/null +++ b/crates/tinytools/src/shared/mod_tests.rs @@ -0,0 +1,307 @@ +//! What [`SharedTool`] must not change about the tool it wraps. +//! +//! Twenty-two of `Tool`'s methods carry defaults, so a wrapper that forgot one +//! would compile and answer for its inner tool with the wrong value. The tool +//! below overrides every defaulted method with a non-default answer, so a +//! wrapper that dropped any of them reports the default and fails here. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +#![allow(clippy::unnecessary_literal_bound)] + +use std::any::Any; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use async_trait::async_trait; +use serde_json::{Value, json}; + +use super::{SharedTool, owned_belt, share_belt}; +use crate::{ + PermissionLevel, Tool, ToolCallOptions, ToolCategory, ToolExposure, ToolInjectedArgument, + ToolPolicy, ToolResult, ToolRunContext, ToolScope, ToolSpec, ToolTimeout, +}; + +/// The host extension [`Opinionated`] carries, so a test can downcast it. +#[derive(Debug, PartialEq)] +struct Marker(u32); + +static MARKER: Marker = Marker(7); + +/// A tool whose every defaulted answer differs from the trait default. +#[derive(Default)] +struct Opinionated { + executions: AtomicUsize, +} + +#[async_trait] +impl Tool for Opinionated { + fn name(&self) -> &str { + "opinionated" + } + + fn description(&self) -> &str { + "answers nothing by default" + } + + fn parameters_schema(&self) -> Value { + json!({ "type": "object", "properties": { "x": { "type": "string" } } }) + } + + async fn execute(&self, _args: Value) -> anyhow::Result { + self.executions.fetch_add(1, Ordering::SeqCst); + Ok(ToolResult::success("plain")) + } + + async fn execute_with_options( + &self, + _args: Value, + options: ToolCallOptions, + ) -> anyhow::Result { + Ok(ToolResult::success(format!( + "options markdown={}", + options.prefer_markdown + ))) + } + + async fn execute_with_context( + &self, + _args: Value, + _options: ToolCallOptions, + context: Option<&dyn ToolRunContext>, + ) -> anyhow::Result { + Ok(ToolResult::success(format!( + "context present={}", + context.is_some() + ))) + } + + fn policy(&self) -> ToolPolicy { + let mut policy = ToolPolicy::default(); + policy.display.label = Some("policy label".into()); + policy + } + + fn injected_arguments(&self) -> Vec { + vec![ToolInjectedArgument::host("account_id")] + } + + fn supports_markdown(&self) -> bool { + true + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + fn permission_level_with_args(&self, _args: &Value) -> PermissionLevel { + PermissionLevel::Dangerous + } + + fn scope(&self) -> ToolScope { + ToolScope::CliRpcOnly + } + + fn category(&self) -> ToolCategory { + ToolCategory::Workflow + } + + fn exposure(&self) -> ToolExposure { + ToolExposure::Hidden + } + + fn family(&self) -> Option<&str> { + Some("opinions") + } + + fn is_concurrency_safe(&self, _args: &Value) -> bool { + true + } + + fn external_effect(&self) -> bool { + true + } + + fn external_effect_with_args(&self, args: &Value) -> bool { + args.get("send").is_some() + } + + fn max_result_size_chars(&self) -> Option { + Some(17) + } + + fn timeout_policy(&self, _args: &Value) -> ToolTimeout { + ToolTimeout::Millis(250) + } + + fn host_extension(&self) -> Option<&(dyn Any + Send + Sync)> { + Some(&MARKER) + } + + fn host_call_extension(&self, _args: &Value) -> Option> { + Some(Box::new(Marker(9))) + } + + fn spec(&self) -> ToolSpec { + ToolSpec { + name: "opinionated".into(), + description: "a curated spec".into(), + parameters: json!({}), + } + } + + fn display_label(&self, _args: &Value) -> Option { + Some("Having opinions".into()) + } + + fn display_detail(&self, _args: &Value) -> Option { + Some("about everything".into()) + } + + fn return_direct(&self) -> bool { + true + } +} + +/// A trivial run context, so the context-forwarding path can be observed. +struct Run; + +impl ToolRunContext for Run {} + +fn wrapped() -> SharedTool { + SharedTool::new(Arc::new(Opinionated::default())) +} + +#[test] +fn the_wrapper_is_the_tool_it_wraps() { + let tool = wrapped(); + assert_eq!(tool.name(), "opinionated"); + assert_eq!(tool.description(), "answers nothing by default"); + assert_eq!(tool.parameters_schema()["properties"]["x"]["type"], "string"); + assert_eq!(tool.spec().description, "a curated spec"); +} + +/// An admission gate reads these. A wrapper answering the default would +/// quietly widen what a tool is allowed to do. +#[test] +fn the_wrapper_does_not_soften_what_a_gate_reads() { + let tool = wrapped(); + let args = json!({ "send": true }); + assert_eq!(tool.permission_level(), PermissionLevel::Write); + assert_eq!( + tool.permission_level_with_args(&args), + PermissionLevel::Dangerous + ); + assert!(tool.external_effect()); + assert!(tool.external_effect_with_args(&args)); + assert!(!tool.external_effect_with_args(&json!({}))); + assert_eq!(tool.scope(), ToolScope::CliRpcOnly); + assert_eq!(tool.category(), ToolCategory::Workflow); + assert_eq!(tool.policy().display.label.as_deref(), Some("policy label")); + assert_eq!(tool.injected_arguments().len(), 1); +} + +/// The catalogue reads these. A hidden tool that advertised itself through a +/// wrapper is the failure this module exists to prevent. +#[test] +fn the_wrapper_does_not_reveal_a_hidden_tool() { + let tool = wrapped(); + assert_eq!(tool.exposure(), ToolExposure::Hidden); + assert_eq!(tool.family(), Some("opinions")); +} + +/// Dispatch and result handling read these. +#[test] +fn the_wrapper_keeps_runtime_and_result_declarations() { + let tool = wrapped(); + let args = json!({}); + assert!(tool.is_concurrency_safe(&args)); + assert_eq!(tool.timeout_policy(&args), ToolTimeout::Millis(250)); + assert_eq!(tool.max_result_size_chars(), Some(17)); + assert!(tool.return_direct()); + assert!(tool.supports_markdown()); + assert_eq!( + tool.display_label(&args).as_deref(), + Some("Having opinions") + ); + assert_eq!( + tool.display_detail(&args).as_deref(), + Some("about everything") + ); +} + +/// A host recognises its own tool kinds through these; a wrapper that hid +/// them would make every shared tool look foreign. +#[test] +fn the_wrapper_exposes_the_inner_host_extensions() { + let tool = wrapped(); + let held = tool + .host_extension() + .and_then(|ext| ext.downcast_ref::()); + assert_eq!(held, Some(&Marker(7))); + let per_call = tool + .host_call_extension(&json!({})) + .and_then(|ext| ext.downcast::().ok()); + assert_eq!(per_call.as_deref(), Some(&Marker(9))); +} + +#[tokio::test] +async fn every_execute_entry_point_reaches_the_inner_override() { + let tool = wrapped(); + let plain = tool.execute(json!({})).await.unwrap(); + assert_eq!(plain.output(), "plain"); + + let options = ToolCallOptions { + prefer_markdown: true, + ..ToolCallOptions::default() + }; + let with_options = tool + .execute_with_options(json!({}), options.clone()) + .await + .unwrap(); + assert_eq!(with_options.output(), "options markdown=true"); + + let with_context = tool + .execute_with_context(json!({}), options, Some(&Run)) + .await + .unwrap(); + assert_eq!(with_context.output(), "context present=true"); +} + +#[test] +fn debug_prints_the_tool_name() { + assert_eq!(format!("{:?}", wrapped()), "SharedTool(\"opinionated\")"); +} + +/// The whole point: one shared instance, many owned handles. A belt minted +/// twice must not build the tool twice. +#[tokio::test] +async fn an_owned_belt_delegates_to_the_one_shared_instance() { + let inner = Arc::new(Opinionated::default()); + let shared: Vec> = vec![inner.clone()]; + let first = owned_belt(&shared); + let second = owned_belt(&shared); + + assert_eq!(first.len(), 1); + assert_eq!(second.len(), 1); + assert_eq!(Arc::strong_count(&inner), 4, "source, local, two handles"); + for belt in [first, second] { + belt[0].execute(json!({})).await.unwrap(); + } + assert_eq!(inner.executions.load(Ordering::SeqCst), 2); +} + +#[test] +fn share_belt_keeps_every_tool_in_order() { + let belt: Vec> = vec![ + Box::new(Opinionated::default()), + Box::new(SharedTool::new(Arc::new(Opinionated::default()))), + ]; + let shared = share_belt(belt); + assert_eq!(shared.len(), 2); + assert!(shared.iter().all(|tool| tool.name() == "opinionated")); +} + +#[test] +fn an_empty_belt_round_trips_empty() { + assert!(owned_belt(&share_belt(Vec::new())).is_empty()); +} From b171f062ecbc5a80735cda1414dcdcc001a4b82e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 3 Oct 2026 22:18:50 +0530 Subject: [PATCH 2/6] feat(lib): expose the shared module The `shared` module is now publicly re-exported from the library root so that downstream consumers can access its types and functions directly through the crate's public API. Auto-committed-on: macbook Co-authored-by: Medulla --- crates/tinytools/src/lib.rs | 1 + crates/tinytools/src/shared/mod.rs | 61 ++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 crates/tinytools/src/shared/mod.rs diff --git a/crates/tinytools/src/lib.rs b/crates/tinytools/src/lib.rs index 955f8aa..0d81ef5 100644 --- a/crates/tinytools/src/lib.rs +++ b/crates/tinytools/src/lib.rs @@ -111,6 +111,7 @@ pub mod permission; pub mod policy; pub mod rank; pub mod result; +pub mod shared; pub mod spec; pub mod tool; pub mod workspace; diff --git a/crates/tinytools/src/shared/mod.rs b/crates/tinytools/src/shared/mod.rs new file mode 100644 index 0000000..24645fb --- /dev/null +++ b/crates/tinytools/src/shared/mod.rs @@ -0,0 +1,61 @@ +//! Stub. + +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::Value; + +use crate::result::ToolResult; +use crate::tool::Tool; + +/// Stub. +pub struct SharedTool(Arc); + +impl std::fmt::Debug for SharedTool { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("SharedTool").finish() + } +} + +impl SharedTool { + /// Stub. + #[must_use] + pub fn new(inner: Arc) -> Self { + Self(inner) + } +} + +/// Stub. +#[must_use] +pub fn owned_belt(_shared: &[Arc]) -> Vec> { + Vec::new() +} + +/// Stub. +#[must_use] +pub fn share_belt(_belt: Vec>) -> Vec> { + Vec::new() +} + +#[async_trait] +impl Tool for SharedTool { + fn name(&self) -> &str { + self.0.name() + } + + fn description(&self) -> &str { + self.0.description() + } + + fn parameters_schema(&self) -> Value { + self.0.parameters_schema() + } + + async fn execute(&self, args: Value) -> anyhow::Result { + self.0.execute(args).await + } +} + +#[cfg(test)] +#[path = "mod_tests.rs"] +mod tests; From 2bf01f319adfd25733c61f8567deec9b51bade67 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 3 Oct 2026 22:19:24 +0530 Subject: [PATCH 3/6] feat(shared): implement SharedTool and belt conversion functions Replace the stub module with a working implementation that bridges between shared `Arc` references and owned `Box` belts. A host can now keep one copy of each built tool and hand out per-turn owned belts without rebuilding any tool, while `SharedTool` delegates every call to the shared instance so state like connections or caches is never duplicated. Auto-committed-on: macbook Co-authored-by: Medulla --- crates/tinytools/src/lib.rs | 3 + crates/tinytools/src/shared/mod.rs | 77 +++++-------- crates/tinytools/src/shared/types.rs | 162 +++++++++++++++++++++++++++ 3 files changed, 196 insertions(+), 46 deletions(-) create mode 100644 crates/tinytools/src/shared/types.rs diff --git a/crates/tinytools/src/lib.rs b/crates/tinytools/src/lib.rs index 0d81ef5..29951b4 100644 --- a/crates/tinytools/src/lib.rs +++ b/crates/tinytools/src/lib.rs @@ -32,6 +32,8 @@ //! - [`context`] — [`ToolRunContext`], the narrow seam onto a live run. //! - [`workspace`] — [`WorkspaceDescriptor`], the root a tool may touch. //! - [`naming`] — rendering a call for a human. +//! - [`shared`] — [`SharedTool`], [`share_belt`] and [`owned_belt`]: one +//! built `Arc` handed out as many owned `Box` belts. //! - [`rank`] — [`ToolRanker`], ranking a catalogue of tools against an //! intent, and the lexical [`Bm25Ranker`] every host gets for free. //! @@ -142,6 +144,7 @@ pub use rank::{ Bm25Index, Bm25Ranker, RankCandidate, RankContext, RankError, RankHit, ToolRanker, tokenize, }; pub use result::{FileData, ImageData, ToolContent, ToolControl, ToolErrorKind, ToolResult}; +pub use shared::{SharedTool, owned_belt, share_belt}; pub use spec::ToolSpec; pub use tool::{Tool, ToolExposure}; pub use workspace::{SandboxMode, WorkspaceDescriptor}; diff --git a/crates/tinytools/src/shared/mod.rs b/crates/tinytools/src/shared/mod.rs index 24645fb..850a4b5 100644 --- a/crates/tinytools/src/shared/mod.rs +++ b/crates/tinytools/src/shared/mod.rs @@ -1,59 +1,44 @@ -//! Stub. +//! Sharing one built tool across many owned belts. +//! +//! A host commonly builds an agent's tools **once** and keeps them as +//! `Arc`, because the same instance is wanted in several places — a +//! per-agent pool, an MCP server re-exporting the belt, a catalogue index. A +//! harness, meanwhile, often asks for an owned `Vec>` and asks +//! for it **per turn**, because the session it hands the belt to is rebuilt +//! between turns and a `Box` cannot outlive it. +//! +//! [`SharedTool`] bridges the two: a thin `Box` around the `Arc`, minted per +//! turn, delegating every call to the one shared instance. No tool is rebuilt, +//! no state is duplicated, and a tool holding a connection or a cache keeps +//! holding exactly one. [`share_belt`] and [`owned_belt`] convert a whole belt +//! in each direction. + +mod types; use std::sync::Arc; -use async_trait::async_trait; -use serde_json::Value; - -use crate::result::ToolResult; use crate::tool::Tool; -/// Stub. -pub struct SharedTool(Arc); - -impl std::fmt::Debug for SharedTool { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_tuple("SharedTool").finish() - } -} - -impl SharedTool { - /// Stub. - #[must_use] - pub fn new(inner: Arc) -> Self { - Self(inner) - } -} +pub use types::SharedTool; -/// Stub. +/// Moves a built belt into shared handles, so a host can keep one copy and +/// hand others out without rebuilding any tool. #[must_use] -pub fn owned_belt(_shared: &[Arc]) -> Vec> { - Vec::new() +pub fn share_belt(belt: Vec>) -> Vec> { + belt.into_iter().map(Arc::from).collect() } -/// Stub. +/// The shared belt as an owned one, for a single turn. +/// +/// Call it wherever a harness wants owned tools — typically once per turn from +/// an agent's tool factory. Each entry is a [`SharedTool`] pointing at the +/// same instance; the tools themselves are not rebuilt. #[must_use] -pub fn share_belt(_belt: Vec>) -> Vec> { - Vec::new() -} - -#[async_trait] -impl Tool for SharedTool { - fn name(&self) -> &str { - self.0.name() - } - - fn description(&self) -> &str { - self.0.description() - } - - fn parameters_schema(&self) -> Value { - self.0.parameters_schema() - } - - async fn execute(&self, args: Value) -> anyhow::Result { - self.0.execute(args).await - } +pub fn owned_belt(shared: &[Arc]) -> Vec> { + shared + .iter() + .map(|tool| Box::new(SharedTool::new(Arc::clone(tool))) as Box) + .collect() } #[cfg(test)] diff --git a/crates/tinytools/src/shared/types.rs b/crates/tinytools/src/shared/types.rs new file mode 100644 index 0000000..d20e70a --- /dev/null +++ b/crates/tinytools/src/shared/types.rs @@ -0,0 +1,162 @@ +//! The owned handle onto a shared tool. + +use std::any::Any; +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::Value; + +use crate::call::{ToolCallOptions, ToolInjectedArgument, ToolTimeout}; +use crate::classification::{ToolCategory, ToolScope}; +use crate::context::ToolRunContext; +use crate::permission::PermissionLevel; +use crate::policy::ToolPolicy; +use crate::result::ToolResult; +use crate::spec::ToolSpec; +use crate::tool::{Tool, ToolExposure}; + +/// One shared tool, owned as a `Box` for as long as a caller needs +/// it. +/// +/// # Every method, deliberately +/// +/// [`Tool`] has four required methods and twenty-two defaulted ones. A wrapper +/// implementing only the four would compile and silently answer the defaults +/// for the tool it wraps — a [`PermissionLevel::Write`] tool would read as +/// read-only, an admission gate would see the wrong +/// [`external_effect`][Tool::external_effect], and a hidden tool would +/// advertise itself. Nothing in the type system catches that, so every method +/// is forwarded explicitly and the module's tests pin each one. +pub struct SharedTool(Arc); + +// `dyn Tool` is not `Debug`, and the name is the only part of a tool worth +// printing anyway — a belt in a log line should read as its names. +impl std::fmt::Debug for SharedTool { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("SharedTool").field(&self.0.name()).finish() + } +} + +impl SharedTool { + /// Wraps one shared tool. + #[must_use] + pub fn new(inner: Arc) -> Self { + Self(inner) + } +} + +#[async_trait] +impl Tool for SharedTool { + fn name(&self) -> &str { + self.0.name() + } + + fn description(&self) -> &str { + self.0.description() + } + + fn parameters_schema(&self) -> Value { + self.0.parameters_schema() + } + + async fn execute(&self, args: Value) -> anyhow::Result { + self.0.execute(args).await + } + + async fn execute_with_options( + &self, + args: Value, + options: ToolCallOptions, + ) -> anyhow::Result { + self.0.execute_with_options(args, options).await + } + + async fn execute_with_context( + &self, + args: Value, + options: ToolCallOptions, + context: Option<&dyn ToolRunContext>, + ) -> anyhow::Result { + self.0.execute_with_context(args, options, context).await + } + + fn policy(&self) -> ToolPolicy { + self.0.policy() + } + + fn injected_arguments(&self) -> Vec { + self.0.injected_arguments() + } + + fn supports_markdown(&self) -> bool { + self.0.supports_markdown() + } + + fn permission_level(&self) -> PermissionLevel { + self.0.permission_level() + } + + fn permission_level_with_args(&self, args: &Value) -> PermissionLevel { + self.0.permission_level_with_args(args) + } + + fn scope(&self) -> ToolScope { + self.0.scope() + } + + fn category(&self) -> ToolCategory { + self.0.category() + } + + fn exposure(&self) -> ToolExposure { + self.0.exposure() + } + + fn family(&self) -> Option<&str> { + self.0.family() + } + + fn is_concurrency_safe(&self, args: &Value) -> bool { + self.0.is_concurrency_safe(args) + } + + fn external_effect(&self) -> bool { + self.0.external_effect() + } + + fn external_effect_with_args(&self, args: &Value) -> bool { + self.0.external_effect_with_args(args) + } + + fn max_result_size_chars(&self) -> Option { + self.0.max_result_size_chars() + } + + fn timeout_policy(&self, args: &Value) -> ToolTimeout { + self.0.timeout_policy(args) + } + + fn host_extension(&self) -> Option<&(dyn Any + Send + Sync)> { + self.0.host_extension() + } + + fn host_call_extension(&self, args: &Value) -> Option> { + self.0.host_call_extension(args) + } + + fn spec(&self) -> ToolSpec { + self.0.spec() + } + + fn display_label(&self, args: &Value) -> Option { + self.0.display_label(args) + } + + fn display_detail(&self, args: &Value) -> Option { + self.0.display_detail(args) + } + + fn return_direct(&self) -> bool { + self.0.return_direct() + } +} From e4a1f52d5e8c8277dc4ea14d36bf0d04b2eb246b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 3 Oct 2026 22:20:22 +0530 Subject: [PATCH 4/6] docs(shared): document the new shared module in AGENTS.md and README.md Add the `shared` subdirectory to the crate tree diagram in AGENTS.md and include a table entry for the `shared` module in README.md, describing `SharedTool` and its belt-based ownership pattern. Also reformat a long assertion in the module's test file to improve readability. Auto-committed-on: macbook Co-authored-by: Medulla --- AGENTS.md | 3 ++- README.md | 1 + crates/tinytools/src/shared/mod_tests.rs | 5 ++++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 41f9975..fc9b8f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,8 @@ crates/ ├── classification/ # `ToolScope`, `ToolCategory` ├── call/ # `ToolCallOptions`, `ToolTimeout` ├── context/ # `ToolRunContext` - └── naming/ # rendering a call for a human + ├── naming/ # rendering a call for a human + └── shared/ # `SharedTool`: an `Arc` as an owned belt entry # each: mod.rs / types.rs / mod_tests.rs docs/ ├── specs/ # behavior and architecture specifications diff --git a/README.md b/README.md index 408d764..810a446 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ compiles neither the harness nor the host. | `context` | `ToolRunContext` — the narrow seam onto a live run | | `workspace` | `WorkspaceDescriptor`, `SandboxMode` — the root a tool may touch, and how strictly it is sandboxed | | `naming` | `humanize_tool_name`, `context_detail_from_args` — rendering a call for a human | +| `shared` | `SharedTool`, `share_belt`, `owned_belt` — one built `Arc` handed out as many owned `Box` belts, forwarding every trait method | | `rank` | `ToolRanker`, `RankCandidate`, `RankHit`, `Bm25Ranker` — ranking a catalogue of tools against an intent, with the lexical ranker built in | The workspace also contains `tinytools-agent`, a separate crate for diff --git a/crates/tinytools/src/shared/mod_tests.rs b/crates/tinytools/src/shared/mod_tests.rs index d3e7957..ed63a0f 100644 --- a/crates/tinytools/src/shared/mod_tests.rs +++ b/crates/tinytools/src/shared/mod_tests.rs @@ -176,7 +176,10 @@ fn the_wrapper_is_the_tool_it_wraps() { let tool = wrapped(); assert_eq!(tool.name(), "opinionated"); assert_eq!(tool.description(), "answers nothing by default"); - assert_eq!(tool.parameters_schema()["properties"]["x"]["type"], "string"); + assert_eq!( + tool.parameters_schema()["properties"]["x"]["type"], + "string" + ); assert_eq!(tool.spec().description, "a curated spec"); } From 64fc2e306bb265a5953362969c870eeed5f9d0f4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 3 Oct 2026 22:21:10 +0530 Subject: [PATCH 5/6] test(shared): remove redundant default spread and clone in test The test for `every_execute_entry_point_reaches_the_inner_override` was constructing a `ToolCallOptions` with a redundant `..ToolCallOptions::default()` spread, since the only field set was already the default. The subsequent call to `execute_with_options` also unnecessarily cloned the options. Both have been cleaned up to make the test more concise and idiomatic. Auto-committed-on: macbook Co-authored-by: Medulla --- crates/tinytools/src/shared/mod_tests.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/crates/tinytools/src/shared/mod_tests.rs b/crates/tinytools/src/shared/mod_tests.rs index ed63a0f..0aa7ce9 100644 --- a/crates/tinytools/src/shared/mod_tests.rs +++ b/crates/tinytools/src/shared/mod_tests.rs @@ -255,12 +255,8 @@ async fn every_execute_entry_point_reaches_the_inner_override() { let options = ToolCallOptions { prefer_markdown: true, - ..ToolCallOptions::default() }; - let with_options = tool - .execute_with_options(json!({}), options.clone()) - .await - .unwrap(); + let with_options = tool.execute_with_options(json!({}), options).await.unwrap(); assert_eq!(with_options.output(), "options markdown=true"); let with_context = tool From 02288a9d5788c948f8c8fdf8d15d809615051085 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 3 Oct 2026 22:21:21 +0530 Subject: [PATCH 6/6] Add SharedTool, share_belt and owned_belt for sharing one built tool across owned belts Co-authored-by: Medulla --- crates/tinytools/README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/tinytools/README.md b/crates/tinytools/README.md index 8a101ff..b9d1a36 100644 --- a/crates/tinytools/README.md +++ b/crates/tinytools/README.md @@ -39,6 +39,15 @@ facts: workspace, thread id, and output cap — plus `host_extension()`, the same type-erased escape hatch `Tool::host_extension` offers, so a tool written against one specific harness can downcast to that harness's full context. +## Sharing one tool across owned belts + +A host that builds a tool once and keeps it as `Arc` can still hand a +harness the owned `Vec>` it asks for each turn: `share_belt` +moves a built belt into `Arc`s, and `owned_belt` mints a fresh owned belt of +`SharedTool` handles over them. Each handle forwards **every** `Tool` method — +the defaulted declarations included — so a wrapped write-level, effectful or +hidden tool never reads as the trait default. + ## Rich tool returns `ToolContent` has four block kinds: `Text`, `Json`, `Image`, and `File`.