diff --git a/crates/tinytools/src/classification/mod.rs b/crates/tinytools/src/classification/mod.rs index b2ff7f9..0121a1d 100644 --- a/crates/tinytools/src/classification/mod.rs +++ b/crates/tinytools/src/classification/mod.rs @@ -2,7 +2,7 @@ mod types; -pub use types::{ToolCategory, ToolScope}; +pub use types::{ToolCategory, ToolExposure, ToolScope}; #[cfg(test)] mod test; diff --git a/crates/tinytools/src/classification/types.rs b/crates/tinytools/src/classification/types.rs index 1a9ec5b..ebac294 100644 --- a/crates/tinytools/src/classification/types.rs +++ b/crates/tinytools/src/classification/types.rs @@ -20,6 +20,62 @@ pub enum ToolScope { CliRpcOnly, } +/// Where a tool is exposed to the model. +/// +/// Every tool a host registers is dispatchable. This says which of them the +/// model is *told about* up front, and it is a property of the tool rather than +/// of a config posture, because the answer rarely varies by deployment: a tool +/// the model needs on most turns is direct, and one it needs on a handful of +/// turns a week is not, whoever is running the host. +/// +/// The distinction exists because tool schemas are a fixed per-turn cost paid +/// on every request, and on a large tool surface they dominate it — measured on +/// OpenHuman's orchestrator, 45 KB of schema against 34 KB of system prompt. +/// A schema the model reads on one turn in five hundred is not worth its place +/// on the other four hundred and ninety-nine. +/// +/// Modelled on Codex's `ToolExposure` (`codex-rs/tools/src/tool_executor.rs`), +/// which pairs `Deferred` with a BM25-indexed `tool_search`. This enum is +/// deliberately the smaller half of that design: Codex additionally +/// distinguishes its Code Mode surface, which has no equivalent here yet. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ToolExposure { + /// Advertise the tool's schema on every request. + /// + /// The default, and deliberately so: a tool that has thought about its own + /// exposure will say so, and one that has not should keep behaving exactly + /// as it did before this existed. + #[default] + Direct, + /// Register the tool and keep its schema off the wire, reachable through + /// the host's tool-search facility. + /// + /// A host that offers no such facility must treat this as [`Self::Direct`] + /// rather than hiding the tool — a capability the model cannot see *and* + /// cannot look up is simply gone, which is a bigger regression than the + /// tokens it saves. + Deferred, + /// Keep the tool dispatchable but never show it to the model. + /// + /// For tools a host calls on the model's behalf, or that exist only to be + /// invoked by another tool. + Hidden, +} + +impl ToolExposure { + /// Whether this tool's schema belongs in the initial tool list. + pub fn is_direct(self) -> bool { + matches!(self, Self::Direct) + } + + /// Whether tool search may surface this tool. + pub fn is_searchable(self) -> bool { + matches!(self, Self::Deferred) + } +} + + /// Category of a tool — used to scope which tools a given sub-agent may see. /// /// The distinction is about *where the work happens*: a [`Self::System`] tool diff --git a/crates/tinytools/src/lib.rs b/crates/tinytools/src/lib.rs index 3ee4448..0cfd9c3 100644 --- a/crates/tinytools/src/lib.rs +++ b/crates/tinytools/src/lib.rs @@ -24,7 +24,7 @@ //! a tool hands back. //! - [`spec`] — [`ToolSpec`], the declaration a model is shown. //! - [`permission`] — [`PermissionLevel`], the privilege ladder. -//! - [`classification`] — [`ToolScope`] and [`ToolCategory`]. +//! - [`classification`] — [`ToolScope`], [`ToolCategory`] and [`ToolExposure`]. //! - [`call`] — [`ToolCallOptions`] and [`ToolTimeout`], the per-invocation //! inputs that are not arguments. //! - [`context`] — [`ToolRunContext`], the narrow seam onto a live run. @@ -107,7 +107,7 @@ pub mod tool; pub mod workspace; pub use call::{ToolCallOptions, ToolTimeout}; -pub use classification::{ToolCategory, ToolScope}; +pub use classification::{ToolCategory, ToolExposure, ToolScope}; pub use context::ToolRunContext; pub use naming::{ ContextDetailOptions, context_detail_from_args, context_detail_from_args_with, diff --git a/crates/tinytools/src/tool/types.rs b/crates/tinytools/src/tool/types.rs index 207f1be..111d48c 100644 --- a/crates/tinytools/src/tool/types.rs +++ b/crates/tinytools/src/tool/types.rs @@ -6,7 +6,7 @@ use async_trait::async_trait; use serde_json::Value; use crate::call::{ToolCallOptions, ToolTimeout}; -use crate::classification::{ToolCategory, ToolScope}; +use crate::classification::{ToolCategory, ToolExposure, ToolScope}; use crate::context::ToolRunContext; use crate::naming::{context_detail_from_args, humanize_tool_name}; use crate::permission::PermissionLevel; @@ -123,6 +123,17 @@ pub trait Tool: Send + Sync { ToolCategory::System } + /// Where this tool is exposed to the model. + /// + /// Defaults to [`ToolExposure::Direct`] so a tool that has not considered + /// the question behaves exactly as it did before this method existed. + /// Override it on a tool whose schema is large relative to how often the + /// model reaches for it — that is the trade this is here to make, and the + /// host's own budget report is the place to find the candidates. + fn exposure(&self) -> ToolExposure { + ToolExposure::Direct + } + /// Whether two concurrent invocations are safe to run in parallel within a /// single model turn. ///