Skip to main content

locode_tools/
registry.rs

1//! The registry and the single `dispatch` door (ADR-0003, ADR-0004, ADR-0008).
2//!
3//! # The tool-identity model
4//!
5//! A tool has three distinct names; do not conflate them:
6//! - its **Rust type name** (e.g. `GrokReadFile`) — compile-time identity only,
7//!   invisible to both the model and the registry;
8//! - its **wire name** (e.g. `read_file`) — what the model calls and the key this
9//!   registry stores it under; assigned by the harness pack at registration time,
10//!   which is why [`crate::Tool`] has no `name()` method;
11//! - its [`ToolKind`] tag — for cross-pack A/B alignment only.
12//!
13//! Only one pack is active per run, so a registry holds exactly that pack's tools
14//! under that pack's wire names — there is no cross-pack key collision to guard,
15//! only duplicates *within* a pack (a wiring bug → panic at startup).
16
17use std::collections::HashMap;
18
19use async_trait::async_trait;
20use locode_protocol::{ContentBlock, ResultChunk, ToolCallRecord, ToolInputFormat, ToolSpec};
21use serde_json::Value;
22
23use crate::ctx::ToolCtx;
24use crate::error::ToolError;
25use crate::tool::{Tool, ToolKind, ToolOutput};
26
27/// The two faces of a successful tool call, produced by erasure.
28///
29/// Mirrors Grok Build's `ToolRunResult`: `output` is the structured value for the
30/// report; `prompt_text` is the text the model reads back in history (ADR-0003).
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct ToolRunResult {
33    /// The structured output value (into the report's `tool_calls[]`).
34    pub output: Value,
35    /// The model-facing rendering (into the transcript).
36    pub prompt_text: String,
37}
38
39/// The object-safe, type-erased tool the registry stores.
40///
41/// Every typed [`Tool`] becomes one of these via the internal `TypedTool` adapter.
42/// It is also the seam for **dynamically described tools** with no compile-time
43/// `Args` type — MCP tools implement `DynTool` directly (schema fetched from the
44/// server, `kind` = [`ToolKind::Other`]) and register via
45/// [`Registry::register_dyn`].
46#[async_trait]
47pub trait DynTool: Send + Sync {
48    /// The cross-pack classification tag.
49    fn kind(&self) -> ToolKind;
50    /// The description offered to the model.
51    fn description(&self) -> &str;
52    /// The JSON Schema for this tool's arguments.
53    fn parameters_schema(&self) -> Value;
54    /// How the tool's input is specified to the model (ADR-0003 amendment
55    /// 2026-07-19). Defaults to the derived JSON schema; a freeform tool
56    /// (raw-text args constrained by a grammar, e.g. codex `apply_patch`)
57    /// overrides this — and still rides the one dispatch door with
58    /// `Value::String` args.
59    fn input_format(&self) -> ToolInputFormat {
60        ToolInputFormat::JsonSchema {
61            parameters: self.parameters_schema(),
62        }
63    }
64    /// Decode raw JSON args, run, and re-serialize into both result faces.
65    ///
66    /// # Errors
67    /// [`ToolError::Respond`] for bad args or any recoverable failure;
68    /// [`ToolError::Fatal`] when the transcript is unrecoverable.
69    async fn call(&self, ctx: &ToolCtx, raw_args: Value) -> Result<ToolRunResult, ToolError>;
70}
71
72/// Adapter that erases a concrete [`Tool`] into a [`DynTool`].
73///
74/// A wrapper (rather than a blanket `impl<T: Tool> DynTool for T`) is deliberate:
75/// the blanket form would forbid any manual `impl DynTool for McpTool` under
76/// Rust's coherence rules, closing the MCP seam.
77struct TypedTool<T: Tool>(T);
78
79#[async_trait]
80impl<T: Tool> DynTool for TypedTool<T> {
81    fn kind(&self) -> ToolKind {
82        self.0.kind()
83    }
84
85    fn description(&self) -> &str {
86        self.0.description()
87    }
88
89    fn parameters_schema(&self) -> Value {
90        self.0.parameters_schema()
91    }
92
93    fn input_format(&self) -> ToolInputFormat {
94        self.0.input_format()
95    }
96
97    async fn call(&self, ctx: &ToolCtx, raw_args: Value) -> Result<ToolRunResult, ToolError> {
98        // Bad args are soft (ADR-0004): the model reads the decode error and retries.
99        let args: T::Args = serde_json::from_value(raw_args)
100            .map_err(|e| ToolError::Respond(format!("invalid arguments: {e}")))?;
101        let output = self.0.run(ctx, args).await?;
102        let prompt_text = output.to_prompt_text();
103        let output = serde_json::to_value(&output)
104            .map_err(|e| ToolError::Fatal(format!("failed to serialize tool output: {e}")))?;
105        Ok(ToolRunResult {
106            output,
107            prompt_text,
108        })
109    }
110}
111
112/// The outcome of one [`Registry::dispatch`]: both the history and report views,
113/// plus a fatal signal.
114///
115/// `tool_result` is **always** present and paired to the call id — even on a fatal
116/// error — so the transcript-validity invariant holds regardless of outcome
117/// (ADR-0004). A `Some(fatal)` tells the loop to append the result and then abort.
118#[derive(Debug, Clone, PartialEq)]
119pub struct Dispatched {
120    /// The `tool_result` block to append to history (paired to the call id).
121    pub tool_result: ContentBlock,
122    /// The structured record for the report's `tool_calls[]`.
123    pub record: ToolCallRecord,
124    /// `Some(message)` iff the tool returned [`ToolError::Fatal`]: append, then abort.
125    pub fatal: Option<String>,
126}
127
128/// A set of tools keyed by their model-facing wire names, plus the one `dispatch`
129/// door every call funnels through (ADR-0008).
130#[derive(Default)]
131pub struct Registry {
132    tools: HashMap<String, Box<dyn DynTool>>,
133}
134
135impl Registry {
136    /// An empty registry.
137    #[must_use]
138    pub fn new() -> Self {
139        Self::default()
140    }
141
142    /// Register a typed [`Tool`] under its pack-assigned wire `name`.
143    ///
144    /// # Panics
145    /// Panics if `name` is already registered — a startup wiring bug, not runtime
146    /// input.
147    pub fn register<T: Tool + 'static>(&mut self, name: impl Into<String>, tool: T) {
148        self.register_dyn(name, Box::new(TypedTool(tool)));
149    }
150
151    /// Register an already-erased [`DynTool`] — the door for MCP and other
152    /// dynamically-described tools with no compile-time `Args` type.
153    ///
154    /// # Panics
155    /// Panics if `name` is already registered.
156    pub fn register_dyn(&mut self, name: impl Into<String>, tool: Box<dyn DynTool>) {
157        let name = name.into();
158        assert!(
159            !self.tools.contains_key(&name),
160            "duplicate tool registration for name `{name}`"
161        );
162        self.tools.insert(name, tool);
163    }
164
165    /// Whether a tool is registered under `name`.
166    #[must_use]
167    pub fn contains(&self, name: &str) -> bool {
168        self.tools.contains_key(name)
169    }
170
171    /// The registered wire names, unordered.
172    pub fn names(&self) -> impl Iterator<Item = &str> {
173        self.tools.keys().map(String::as_str)
174    }
175
176    /// The [`ToolKind`] registered under `name`, or `None` for an unknown tool.
177    ///
178    /// The pre-dispatch lookup the approval seam uses (ADR-0017): an approver
179    /// can auto-allow read-only kinds without knowing pack-specific tool names.
180    #[must_use]
181    pub fn kind_of(&self, name: &str) -> Option<ToolKind> {
182        self.tools.get(name).map(|tool| tool.kind())
183    }
184
185    /// The neutral tool specs for every registered tool, unordered.
186    #[must_use]
187    pub fn specs(&self) -> Vec<ToolSpec> {
188        self.tools
189            .iter()
190            .map(|(name, tool)| ToolSpec {
191                name: name.clone(),
192                description: tool.description().to_owned(),
193                input: tool.input_format(),
194            })
195            .collect()
196    }
197
198    /// The single door every tool call funnels through (ADR-0008).
199    ///
200    /// Resolves `name`, decodes `raw_args`, runs the tool, and returns both the
201    /// history `tool_result` and the report [`ToolCallRecord`]. An unknown tool and
202    /// bad args are **soft** ([`ToolError::Respond`] shape); a [`ToolError::Fatal`]
203    /// still yields a paired result but sets [`Dispatched::fatal`]. `ctx.call_id`
204    /// supplies the pairing id.
205    pub async fn dispatch(&self, name: &str, raw_args: Value, ctx: &ToolCtx) -> Dispatched {
206        let id = ctx.call_id.clone();
207
208        let Some(tool) = self.tools.get(name) else {
209            let message = format!("unknown tool: {name}");
210            return Dispatched {
211                tool_result: error_result(&id, &message),
212                record: record(&id, name, ToolKind::Other, raw_args, false, Value::Null),
213                fatal: None,
214            };
215        };
216
217        let kind = tool.kind();
218        match tool.call(ctx, raw_args.clone()).await {
219            Ok(ToolRunResult {
220                output,
221                prompt_text,
222            }) => Dispatched {
223                tool_result: ok_result(&id, &prompt_text),
224                record: record(&id, name, kind, raw_args, true, output),
225                fatal: None,
226            },
227            Err(ToolError::Respond(message)) => Dispatched {
228                tool_result: error_result(&id, &message),
229                record: record(&id, name, kind, raw_args, false, Value::Null),
230                fatal: None,
231            },
232            Err(ToolError::Fatal(message)) => Dispatched {
233                tool_result: error_result(&id, &message),
234                record: record(&id, name, kind, raw_args, false, Value::Null),
235                fatal: Some(message),
236            },
237        }
238    }
239}
240
241/// Build a `tool_result` for a successful call. The shared model-facing
242/// truncation applies HERE — the dispatch door — so every result is bounded
243/// centrally regardless of any tool's own caps (ADR-0008 amendment).
244fn ok_result(id: &str, text: &str) -> ContentBlock {
245    let (text, _) = crate::truncate_for_model(text, crate::MODEL_OUTPUT_BUDGET);
246    ContentBlock::ToolResult {
247        tool_use_id: id.to_owned(),
248        content: vec![ResultChunk::Text { text }],
249        is_error: false,
250    }
251}
252
253/// Build an `is_error` `tool_result` the model can recover from (same central
254/// truncation — error payloads can carry tool output too).
255fn error_result(id: &str, message: &str) -> ContentBlock {
256    let (text, _) = crate::truncate_for_model(message, crate::MODEL_OUTPUT_BUDGET);
257    ContentBlock::ToolResult {
258        tool_use_id: id.to_owned(),
259        content: vec![ResultChunk::Text { text }],
260        is_error: true,
261    }
262}
263
264/// Build the report-side record for a call.
265fn record(
266    id: &str,
267    name: &str,
268    kind: ToolKind,
269    args: Value,
270    ok: bool,
271    output: Value,
272) -> ToolCallRecord {
273    ToolCallRecord {
274        id: id.to_owned(),
275        name: name.to_owned(),
276        kind: kind.as_str().to_owned(),
277        args,
278        ok,
279        // Never set here: `denial_reason` belongs exclusively to the engine's
280        // approver-deny path (ADR-0017) — dispatch failures are not denials.
281        denial_reason: None,
282        output,
283    }
284}