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 neutral tool specs for every registered tool, unordered.
177 #[must_use]
178 pub fn specs(&self) -> Vec<ToolSpec> {
179 self.tools
180 .iter()
181 .map(|(name, tool)| ToolSpec {
182 name: name.clone(),
183 description: tool.description().to_owned(),
184 input: tool.input_format(),
185 })
186 .collect()
187 }
188
189 /// The single door every tool call funnels through (ADR-0008).
190 ///
191 /// Resolves `name`, decodes `raw_args`, runs the tool, and returns both the
192 /// history `tool_result` and the report [`ToolCallRecord`]. An unknown tool and
193 /// bad args are **soft** ([`ToolError::Respond`] shape); a [`ToolError::Fatal`]
194 /// still yields a paired result but sets [`Dispatched::fatal`]. `ctx.call_id`
195 /// supplies the pairing id.
196 pub async fn dispatch(&self, name: &str, raw_args: Value, ctx: &ToolCtx) -> Dispatched {
197 let id = ctx.call_id.clone();
198
199 let Some(tool) = self.tools.get(name) else {
200 let message = format!("unknown tool: {name}");
201 return Dispatched {
202 tool_result: error_result(&id, &message),
203 record: record(&id, name, ToolKind::Other, raw_args, false, Value::Null),
204 fatal: None,
205 };
206 };
207
208 let kind = tool.kind();
209 match tool.call(ctx, raw_args.clone()).await {
210 Ok(ToolRunResult {
211 output,
212 prompt_text,
213 }) => Dispatched {
214 tool_result: ok_result(&id, &prompt_text),
215 record: record(&id, name, kind, raw_args, true, output),
216 fatal: None,
217 },
218 Err(ToolError::Respond(message)) => Dispatched {
219 tool_result: error_result(&id, &message),
220 record: record(&id, name, kind, raw_args, false, Value::Null),
221 fatal: None,
222 },
223 Err(ToolError::Fatal(message)) => Dispatched {
224 tool_result: error_result(&id, &message),
225 record: record(&id, name, kind, raw_args, false, Value::Null),
226 fatal: Some(message),
227 },
228 }
229 }
230}
231
232/// Build a `tool_result` for a successful call. The shared model-facing
233/// truncation applies HERE — the dispatch door — so every result is bounded
234/// centrally regardless of any tool's own caps (ADR-0008 amendment).
235fn ok_result(id: &str, text: &str) -> ContentBlock {
236 let (text, _) = crate::truncate_for_model(text, crate::MODEL_OUTPUT_BUDGET);
237 ContentBlock::ToolResult {
238 tool_use_id: id.to_owned(),
239 content: vec![ResultChunk::Text { text }],
240 is_error: false,
241 }
242}
243
244/// Build an `is_error` `tool_result` the model can recover from (same central
245/// truncation — error payloads can carry tool output too).
246fn error_result(id: &str, message: &str) -> ContentBlock {
247 let (text, _) = crate::truncate_for_model(message, crate::MODEL_OUTPUT_BUDGET);
248 ContentBlock::ToolResult {
249 tool_use_id: id.to_owned(),
250 content: vec![ResultChunk::Text { text }],
251 is_error: true,
252 }
253}
254
255/// Build the report-side record for a call.
256fn record(
257 id: &str,
258 name: &str,
259 kind: ToolKind,
260 args: Value,
261 ok: bool,
262 output: Value,
263) -> ToolCallRecord {
264 ToolCallRecord {
265 id: id.to_owned(),
266 name: name.to_owned(),
267 kind: kind.as_str().to_owned(),
268 args,
269 ok,
270 output,
271 }
272}