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}