Skip to main content

locode_tools/
tool.rs

1//! The typed [`Tool`] contract, its [`ToolKind`] tag, and the [`ToolOutput`] face
2//! for model-facing text (ADR-0003).
3
4use async_trait::async_trait;
5use schemars::JsonSchema;
6use serde::de::DeserializeOwned;
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10use crate::ctx::ToolCtx;
11use crate::error::ToolError;
12
13/// The model-facing rendering of a tool's output.
14///
15/// A tool result has two faces (ADR-0003): the structured [`Tool::Output`] value
16/// goes into the report's `tool_calls[]`, while `to_prompt_text()` renders the
17/// text the model sees in history. They are independent renderings of one call —
18/// e.g. a read tool reports `{path, lines, truncated}` but shows the file body.
19pub trait ToolOutput {
20    /// Render this output as the text the model reads back in the transcript.
21    fn to_prompt_text(&self) -> String;
22}
23
24/// A canonical classification tag for a tool, used only to **align comparable
25/// tools across harness packs** for A/B analysis (ADR-0003) — it is *not* the
26/// wire name (that is assigned per-pack at registration) and *not* the Rust type
27/// name.
28///
29/// Closed for v0 but carries an [`ToolKind::Other`] catch-all (via
30/// `#[serde(other)]`) so tools without a cross-pack analog — MCP tools, and
31/// harness-unique specials — still classify. Grok Build's real `ToolKind` has the
32/// same shape (a fixed set plus `Other`); we start small and grow the canonical
33/// set as packs need it.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum ToolKind {
37    /// Runs a shell command.
38    Shell,
39    /// Reads a file's contents.
40    Read,
41    /// Creates or overwrites a file.
42    Write,
43    /// Edits part of an existing file (e.g. exact-string replace).
44    Edit,
45    /// Expands a path/glob pattern to a list of files.
46    Glob,
47    /// Searches file contents (e.g. ripgrep).
48    Grep,
49    /// No cross-pack analog: MCP tools and harness-unique specials.
50    #[serde(other)]
51    Other,
52}
53
54impl ToolKind {
55    /// The stable `snake_case` key for this kind (what lands in the report record).
56    #[must_use]
57    pub fn as_str(self) -> &'static str {
58        match self {
59            ToolKind::Shell => "shell",
60            ToolKind::Read => "read",
61            ToolKind::Write => "write",
62            ToolKind::Edit => "edit",
63            ToolKind::Glob => "glob",
64            ToolKind::Grep => "grep",
65            ToolKind::Other => "other",
66        }
67    }
68}
69
70/// A tool authored against concrete types; the wire schema is *derived* from
71/// [`Tool::Args`], never hand-written (ADR-0003).
72///
73/// The tool has **no name of its own** — its model-facing wire name is assigned by
74/// the harness pack when it registers the tool into a [`crate::Registry`]
75/// (ADR-0006/0012). Author tools with concrete `Args`/`Output` types; the registry
76/// erases them to `Box<dyn `[`DynTool`](crate::DynTool)`>` at its boundary.
77#[async_trait]
78pub trait Tool: Send + Sync {
79    /// The deserializable, schema-deriving argument type the model must supply.
80    type Args: DeserializeOwned + JsonSchema + Send;
81    /// The structured output; also rendered to model-facing text via [`ToolOutput`].
82    type Output: Serialize + ToolOutput + Send;
83
84    /// The cross-pack classification tag (see [`ToolKind`]).
85    fn kind(&self) -> ToolKind;
86
87    /// A human-readable description offered to the model alongside the schema.
88    fn description(&self) -> &str;
89
90    /// The JSON Schema for [`Tool::Args`], derived via `schemars` (ADR-0003).
91    ///
92    /// The default derives it from the type and should rarely be overridden.
93    /// Subschemas are **inlined** (no `$defs`/`$ref`) so every wire can ship the
94    /// schema as a self-contained `input_schema` — Grok Build generates tool
95    /// schemas the same way (`registry/types.rs` `generate_schema`,
96    /// `inline_subschemas = true`). Flat `Args` structs are unaffected; this
97    /// guards future enum/nested args.
98    #[must_use]
99    fn parameters_schema(&self) -> Value {
100        let settings = schemars::generate::SchemaSettings::draft2020_12().with(|s| {
101            s.inline_subschemas = true;
102        });
103        let schema = settings
104            .into_generator()
105            .into_root_schema_for::<Self::Args>();
106        // An already-JSON schema converting to a `Value` cannot realistically
107        // fail, but we avoid `unwrap`/`expect` and degrade to an empty object.
108        serde_json::to_value(schema).unwrap_or_else(|_| Value::Object(serde_json::Map::new()))
109    }
110
111    /// How the tool's input is specified to the model. Defaults to the derived
112    /// JSON schema (every tool today); a freeform tool (codex `apply_patch`)
113    /// overrides with `ToolInputFormat::Freeform` and takes raw-text args
114    /// (`Args` decodable from a JSON string).
115    #[must_use]
116    fn input_format(&self) -> locode_protocol::ToolInputFormat {
117        locode_protocol::ToolInputFormat::JsonSchema {
118            parameters: self.parameters_schema(),
119        }
120    }
121
122    /// Execute the call.
123    ///
124    /// # Errors
125    /// Returns [`ToolError::Respond`] for any recoverable failure (the model
126    /// retries) and [`ToolError::Fatal`] only when the transcript is
127    /// unrecoverable (ADR-0004).
128    async fn run(&self, ctx: &ToolCtx, args: Self::Args) -> Result<Self::Output, ToolError>;
129}