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}