1use super::artifacts::{ArtifactStore, ArtifactStoreLimits, ToolArtifact};
7use super::types::{Tool, ToolCapabilities, ToolContext, ToolOutput};
8use super::ToolResult;
9use super::{
10 merge_tool_output_artifact_metadata, tool_output_artifact, transform_tool_output_with_artifact,
11 ToolOutputArtifact, ToolResultTransformPolicyV1,
12};
13use crate::llm::ToolDefinition;
14use crate::trace::{InMemoryTraceSink, TraceEvent, TraceSink};
15use anyhow::Result;
16use std::collections::HashMap;
17use std::hash::{Hash, Hasher};
18use std::path::PathBuf;
19use std::sync::{Arc, RwLock};
20
21const MAX_TOOL_SCHEMA_BYTES: usize = 256 * 1024;
22const MAX_ARGUMENT_VALIDATION_ERRORS: usize = 8;
23const MAX_ARGUMENT_VALIDATION_MESSAGE_BYTES: usize = 4 * 1024;
24const MAX_INLINE_CHANGE_BYTES: usize = 64 * 1024;
25const CHANGE_SIDE_PREVIEW_BYTES: usize = 8 * 1024;
26const CHANGE_DIFF_PREVIEW_BYTES: usize = 32 * 1024;
27const MAX_DIFF_COMPUTE_BYTES: usize = 1024 * 1024;
28
29#[derive(Clone)]
30enum CachedArgumentValidator {
31 Valid(Arc<jsonschema::Validator>),
32 Invalid(String),
33}
34
35#[derive(Clone)]
36struct ArgumentValidatorCacheEntry {
37 schema_fingerprint: u64,
38 validator: CachedArgumentValidator,
39}
40
41pub struct ToolRegistry {
43 tools: RwLock<HashMap<String, Arc<dyn Tool>>>,
44 builtins: RwLock<std::collections::HashSet<String>>,
46 context: RwLock<ToolContext>,
47 artifact_store: ArtifactStore,
48 trace_sink: RwLock<Arc<dyn TraceSink>>,
49 argument_validators: RwLock<HashMap<String, ArgumentValidatorCacheEntry>>,
50 transform_policy: RwLock<ToolResultTransformPolicyV1>,
51}
52
53impl ToolRegistry {
54 pub fn new(workspace: PathBuf) -> Self {
56 Self::with_artifact_limits(workspace, ArtifactStoreLimits::default())
57 }
58
59 pub fn with_artifact_limits(workspace: PathBuf, artifact_limits: ArtifactStoreLimits) -> Self {
61 Self::with_artifact_limits_and_workspace_services(
62 workspace.clone(),
63 artifact_limits,
64 crate::workspace::WorkspaceServices::local(workspace),
65 )
66 }
67
68 pub fn with_artifact_limits_and_workspace_services(
70 workspace: PathBuf,
71 artifact_limits: ArtifactStoreLimits,
72 workspace_services: Arc<crate::workspace::WorkspaceServices>,
73 ) -> Self {
74 let context = ToolContext::new(workspace).with_workspace_services(workspace_services);
75 Self {
76 tools: RwLock::new(HashMap::new()),
77 builtins: RwLock::new(std::collections::HashSet::new()),
78 context: RwLock::new(context),
79 artifact_store: ArtifactStore::with_limits(artifact_limits),
80 trace_sink: RwLock::new(Arc::new(InMemoryTraceSink::default())),
81 argument_validators: RwLock::new(HashMap::new()),
82 transform_policy: RwLock::new(ToolResultTransformPolicyV1::default()),
83 }
84 }
85
86 pub(crate) fn set_tool_result_transform_policy(
87 &self,
88 policy: ToolResultTransformPolicyV1,
89 ) -> Result<()> {
90 policy.validate()?;
91 *self.transform_policy.write().unwrap() = policy;
92 Ok(())
93 }
94
95 pub fn register_builtin(&self, tool: Arc<dyn Tool>) {
97 let name = tool.name().to_string();
98 let mut tools = self.tools.write().unwrap();
99 let mut builtins = self.builtins.write().unwrap();
100 tracing::debug!("Registering builtin tool: {}", name);
101 tools.insert(name.clone(), tool);
102 builtins.insert(name);
103 }
104
105 pub fn register(&self, tool: Arc<dyn Tool>) {
110 let name = tool.name().to_string();
111 let mut tools = self.tools.write().unwrap();
115 let builtins = self.builtins.read().unwrap();
116 if builtins.contains(&name) {
117 tracing::warn!(
118 "Rejected registration of tool '{}': cannot shadow builtin",
119 name
120 );
121 return;
122 }
123 tracing::debug!("Registering tool: {}", name);
124 tools.insert(name, tool);
125 }
126
127 pub(crate) fn register_with_shadow(
134 &self,
135 tool: Arc<dyn Tool>,
136 ) -> (bool, Option<Arc<dyn Tool>>) {
137 let name = tool.name().to_string();
138 let mut tools = self.tools.write().unwrap();
139 let builtins = self.builtins.read().unwrap();
140 if builtins.contains(&name) {
141 tracing::warn!(
142 "Rejected registration of tool '{}': cannot shadow builtin",
143 name
144 );
145 return (false, None);
146 }
147 tracing::debug!("Registering owned dynamic tool: {}", name);
148 (true, tools.insert(name, tool))
149 }
150
151 pub(crate) fn restore_if_same(
157 &self,
158 name: &str,
159 expected: &Arc<dyn Tool>,
160 replacement: Option<Arc<dyn Tool>>,
161 ) -> bool {
162 let mut tools = self.tools.write().unwrap();
163 let Some(current) = tools.get(name) else {
164 return false;
165 };
166 if !Arc::ptr_eq(current, expected) {
167 return false;
168 }
169
170 match replacement {
171 Some(tool) => {
172 tools.insert(name.to_string(), tool);
173 }
174 None => {
175 tools.remove(name);
176 }
177 }
178 true
179 }
180
181 pub(crate) fn register_if_absent(&self, tool: Arc<dyn Tool>) -> bool {
183 let name = tool.name().to_string();
184 let mut tools = self.tools.write().unwrap();
185 if tools.contains_key(&name) {
186 return false;
187 }
188 tracing::debug!("Registering previously absent dynamic tool: {}", name);
189 tools.insert(name, tool);
190 true
191 }
192
193 pub fn unregister(&self, name: &str) -> bool {
197 let mut tools = self.tools.write().unwrap();
198 let builtins = self.builtins.read().unwrap();
199 if builtins.contains(name) {
200 tracing::warn!(
201 "Rejected unregister of tool '{}': builtin tools cannot be removed through dynamic unregister",
202 name
203 );
204 return false;
205 }
206 tracing::debug!("Unregistering tool: {}", name);
207 tools.remove(name).is_some()
208 }
209
210 pub fn unregister_by_prefix(&self, prefix: &str) {
212 let mut tools = self.tools.write().unwrap();
213 let builtins = self.builtins.read().unwrap();
214 tools.retain(|name, _| builtins.contains(name) || !name.starts_with(prefix));
215 tracing::debug!("Unregistered tools with prefix: {}", prefix);
216 }
217
218 pub fn get(&self, name: &str) -> Option<Arc<dyn Tool>> {
220 let tools = self.tools.read().unwrap();
221 tools.get(name).cloned()
222 }
223
224 pub(crate) fn capabilities(
225 &self,
226 name: &str,
227 args: &serde_json::Value,
228 ) -> Option<ToolCapabilities> {
229 self.get(name).map(|tool| tool.capabilities(args))
230 }
231
232 pub(crate) fn requires_confirmation(&self, name: &str, args: &serde_json::Value) -> bool {
233 self.get(name)
234 .is_some_and(|tool| tool.requires_confirmation(args))
235 }
236
237 pub fn contains(&self, name: &str) -> bool {
239 let tools = self.tools.read().unwrap();
240 tools.contains_key(name)
241 }
242
243 pub fn definitions(&self) -> Vec<ToolDefinition> {
245 let tools = self.tools.read().unwrap();
246 let mut definitions = tools
247 .values()
248 .filter(|tool| tool.is_model_visible())
249 .map(|tool| tool.definition())
250 .collect::<Vec<_>>();
251 definitions.sort_by(|a, b| a.name.cmp(&b.name));
252 definitions
253 }
254
255 pub fn list(&self) -> Vec<String> {
257 let tools = self.tools.read().unwrap();
258 let mut names = tools.keys().cloned().collect::<Vec<_>>();
259 names.sort();
260 names
261 }
262
263 pub(crate) fn validate_arguments(
270 &self,
271 name: &str,
272 args: &serde_json::Value,
273 ) -> std::result::Result<(), String> {
274 let Some(tool) = self.get(name) else {
275 return Ok(());
276 };
277 let schema = tool.parameters();
278 let schema_bytes = serde_json::to_vec(&schema)
279 .map_err(|error| format!("tool parameter schema is not serializable: {error}"))?;
280 if schema_bytes.len() > MAX_TOOL_SCHEMA_BYTES {
281 return Err(format!(
282 "tool parameter schema exceeds the {} byte safety limit",
283 MAX_TOOL_SCHEMA_BYTES
284 ));
285 }
286 let mut hasher = std::collections::hash_map::DefaultHasher::new();
287 schema_bytes.hash(&mut hasher);
288 let schema_fingerprint = hasher.finish();
289 let cached = self
290 .argument_validators
291 .read()
292 .unwrap()
293 .get(name)
294 .filter(|entry| entry.schema_fingerprint == schema_fingerprint)
295 .cloned();
296 let validator = match cached.map(|entry| entry.validator) {
297 Some(CachedArgumentValidator::Valid(validator)) => validator,
298 Some(CachedArgumentValidator::Invalid(error)) => return Err(error),
299 None => {
300 let compiled = match jsonschema::draft202012::options().build(&schema) {
301 Ok(validator) => CachedArgumentValidator::Valid(Arc::new(validator)),
302 Err(error) => CachedArgumentValidator::Invalid(format!(
303 "tool has an invalid parameter schema: {error}"
304 )),
305 };
306 self.argument_validators.write().unwrap().insert(
307 name.to_string(),
308 ArgumentValidatorCacheEntry {
309 schema_fingerprint,
310 validator: compiled.clone(),
311 },
312 );
313 match compiled {
314 CachedArgumentValidator::Valid(validator) => validator,
315 CachedArgumentValidator::Invalid(error) => return Err(error),
316 }
317 }
318 };
319 let mut errors = validator
320 .iter_errors(args)
321 .take(MAX_ARGUMENT_VALIDATION_ERRORS + 1)
322 .map(|error| {
323 let path = error.instance_path().to_string();
324 if path.is_empty() {
325 format!("$: {error}")
326 } else {
327 format!("{path}: {error}")
328 }
329 })
330 .collect::<Vec<_>>();
331 if errors.is_empty() {
332 return Ok(());
333 }
334
335 let omitted = errors.len() > MAX_ARGUMENT_VALIDATION_ERRORS;
336 errors.truncate(MAX_ARGUMENT_VALIDATION_ERRORS);
337 let mut message = errors.join("; ");
338 if omitted {
339 message.push_str("; additional validation errors omitted");
340 }
341 Err(crate::text::truncate_utf8(&message, MAX_ARGUMENT_VALIDATION_MESSAGE_BYTES).to_string())
342 }
343
344 pub fn len(&self) -> usize {
346 let tools = self.tools.read().unwrap();
347 tools.len()
348 }
349
350 pub fn is_empty(&self) -> bool {
352 self.len() == 0
353 }
354
355 pub fn context(&self) -> ToolContext {
357 self.context.read().unwrap().clone()
358 }
359
360 pub fn artifact_store(&self) -> ArtifactStore {
362 self.artifact_store.clone()
363 }
364
365 pub fn get_artifact(&self, artifact_uri: &str) -> Option<ToolArtifact> {
367 self.artifact_store.get(artifact_uri)
368 }
369
370 pub fn set_trace_sink(&self, sink: Arc<dyn TraceSink>) {
372 *self.trace_sink.write().unwrap() = sink;
373 }
374
375 pub fn trace_sink(&self) -> Arc<dyn TraceSink> {
377 Arc::clone(&self.trace_sink.read().unwrap())
378 }
379
380 pub fn set_search_config(&self, config: crate::config::SearchConfig) {
382 let mut ctx = self.context.write().unwrap();
383 *ctx = ctx.clone().with_search_config(config);
384 }
385
386 pub fn set_sandbox(&self, sandbox: std::sync::Arc<dyn crate::sandbox::BashSandbox>) {
389 let mut ctx = self.context.write().unwrap();
390 *ctx = ctx.clone().with_sandbox(sandbox);
391 }
392
393 pub fn set_command_env(&self, env: Arc<HashMap<String, String>>) {
396 let mut ctx = self.context.write().unwrap();
397 *ctx = ctx.clone().with_command_env(env);
398 }
399
400 pub async fn execute(&self, name: &str, args: &serde_json::Value) -> Result<ToolResult> {
406 let ctx = self.context();
407 self.execute_with_context(name, args, &ctx).await
408 }
409
410 pub async fn execute_with_context(
415 &self,
416 name: &str,
417 args: &serde_json::Value,
418 ctx: &ToolContext,
419 ) -> Result<ToolResult> {
420 let start = std::time::Instant::now();
421
422 let tool = self.get(name);
423
424 let mut result = match tool {
425 Some(tool) => {
426 let mut output = tool.execute(args, ctx).await?;
427 self.compact_change_metadata(name, &mut output.metadata);
428 let original_content = output.content.clone();
429 let policy = self.transform_policy.read().unwrap().clone();
430 let truncated = transform_tool_output_with_artifact(name, &output.content, &policy);
431 output.content = truncated.content;
432 let loss_mode = truncated.loss_mode;
433 if let Some(artifact) = truncated.artifact {
434 self.store_tool_artifact(name, &original_content, &artifact);
435 output.metadata = Some(merge_tool_output_artifact_metadata(
436 output.metadata,
437 &artifact,
438 ));
439 }
440 output.metadata = Some(super::attach_tool_result_evidence(
441 output.metadata,
442 &original_content,
443 &output.content,
444 loss_mode,
445 ));
446 Ok(ToolResult {
447 name: name.to_string(),
448 output: output.content,
449 exit_code: if output.success { 0 } else { 1 },
450 metadata: output.metadata,
451 images: output.images,
452 error_kind: output.error_kind,
453 })
454 }
455 None => Ok(ToolResult::error(name, format!("Unknown tool: {}", name))),
456 };
457
458 if let Ok(result) = &mut result {
459 result.metadata = Some(super::ensure_tool_result_evidence(
460 result.metadata.take(),
461 &result.output,
462 ));
463 }
464
465 if let Ok(ref r) = result {
466 crate::telemetry::record_tool_result(r.exit_code, start.elapsed());
467 self.record_trace_event(name, r, start.elapsed());
468 }
469
470 result
471 }
472
473 pub async fn execute_raw(
475 &self,
476 name: &str,
477 args: &serde_json::Value,
478 ) -> Result<Option<ToolOutput>> {
479 let ctx = self.context();
480 self.execute_raw_with_context(name, args, &ctx).await
481 }
482
483 pub async fn execute_raw_with_context(
485 &self,
486 name: &str,
487 args: &serde_json::Value,
488 ctx: &ToolContext,
489 ) -> Result<Option<ToolOutput>> {
490 let tool = self.get(name);
491
492 match tool {
493 Some(tool) => {
494 let mut output = tool.execute(args, ctx).await?;
495 self.compact_change_metadata(name, &mut output.metadata);
496 let original_content = output.content.clone();
497 let policy = self.transform_policy.read().unwrap().clone();
498 let truncated = transform_tool_output_with_artifact(name, &output.content, &policy);
499 output.content = truncated.content;
500 let loss_mode = truncated.loss_mode;
501 if let Some(artifact) = truncated.artifact {
502 self.store_tool_artifact(name, &original_content, &artifact);
503 output.metadata = Some(merge_tool_output_artifact_metadata(
504 output.metadata,
505 &artifact,
506 ));
507 }
508 output.metadata = Some(super::attach_tool_result_evidence(
509 output.metadata,
510 &original_content,
511 &output.content,
512 loss_mode,
513 ));
514 Ok(Some(output))
515 }
516 None => Ok(None),
517 }
518 }
519
520 fn store_tool_artifact(&self, tool_name: &str, content: &str, artifact: &ToolOutputArtifact) {
521 self.artifact_store.put(ToolArtifact {
522 artifact_id: artifact.artifact_id.clone(),
523 artifact_uri: artifact.artifact_uri.clone(),
524 tool_name: tool_name.to_string(),
525 content: content.to_string(),
526 original_bytes: artifact.original_bytes,
527 shown_bytes: artifact.shown_bytes,
528 });
529 }
530
531 fn compact_change_metadata(&self, tool_name: &str, metadata: &mut Option<serde_json::Value>) {
532 let Some(serde_json::Value::Object(object)) = metadata.as_mut() else {
533 return;
534 };
535 let before = object
536 .get("before")
537 .and_then(serde_json::Value::as_str)
538 .map(ToString::to_string);
539 let after = object
540 .get("after")
541 .and_then(serde_json::Value::as_str)
542 .map(ToString::to_string);
543 if before.is_none() && after.is_none() {
544 return;
545 }
546
547 let before_bytes = before.as_ref().map_or(0, String::len);
548 let after_bytes = after.as_ref().map_or(0, String::len);
549 let total_bytes = before_bytes.saturating_add(after_bytes);
550 let compacted = total_bytes > MAX_INLINE_CHANGE_BYTES;
551 let before_artifact = before.as_deref().and_then(|content| {
552 self.store_change_artifact(tool_name, "before", content, compacted)
553 });
554 let after_artifact = after
555 .as_deref()
556 .and_then(|content| self.store_change_artifact(tool_name, "after", content, compacted));
557
558 let unified_diff = if compacted && total_bytes <= MAX_DIFF_COMPUTE_BYTES {
559 let diff = similar::TextDiff::from_lines(
560 before.as_deref().unwrap_or_default(),
561 after.as_deref().unwrap_or_default(),
562 )
563 .unified_diff()
564 .context_radius(3)
565 .header("before", "after")
566 .to_string();
567 Some(bounded_head_tail(&diff, CHANGE_DIFF_PREVIEW_BYTES))
568 } else {
569 None
570 };
571
572 if compacted {
573 if let Some(content) = before.as_deref() {
574 object.insert(
575 "before".to_string(),
576 serde_json::Value::String(bounded_head_tail(
577 content,
578 CHANGE_SIDE_PREVIEW_BYTES,
579 )),
580 );
581 }
582 if let Some(content) = after.as_deref() {
583 object.insert(
584 "after".to_string(),
585 serde_json::Value::String(bounded_head_tail(
586 content,
587 CHANGE_SIDE_PREVIEW_BYTES,
588 )),
589 );
590 }
591 }
592
593 object.insert(
594 "change".to_string(),
595 serde_json::json!({
596 "compacted": compacted,
597 "before": before.as_deref().map(|content| serde_json::json!({
598 "bytes": content.len(),
599 "sha256": sha256::digest(content.as_bytes()),
600 "artifact": before_artifact,
601 })),
602 "after": after.as_deref().map(|content| serde_json::json!({
603 "bytes": content.len(),
604 "sha256": sha256::digest(content.as_bytes()),
605 "artifact": after_artifact,
606 })),
607 "unified_diff": unified_diff,
608 "diff_omitted": compacted && total_bytes > MAX_DIFF_COMPUTE_BYTES,
609 }),
610 );
611 }
612
613 fn store_change_artifact(
614 &self,
615 tool_name: &str,
616 side: &str,
617 content: &str,
618 store: bool,
619 ) -> Option<serde_json::Value> {
620 if !store || content.len() > self.artifact_store.limits().max_bytes {
621 return None;
622 }
623 let artifact = tool_output_artifact(&format!("{tool_name}-{side}"), content, 0);
624 self.store_tool_artifact(tool_name, content, &artifact);
625 Some(serde_json::json!({
626 "artifact_id": artifact.artifact_id,
627 "artifact_uri": artifact.artifact_uri,
628 }))
629 }
630
631 fn record_trace_event(&self, name: &str, result: &ToolResult, duration: std::time::Duration) {
632 let sink = self.trace_sink();
633 sink.record(TraceEvent::tool_execution(
634 name,
635 result.exit_code == 0,
636 result.exit_code,
637 duration,
638 result.output.len(),
639 result.metadata.as_ref(),
640 ));
641
642 if name == "program" {
643 sink.record(TraceEvent::program_execution(
644 name,
645 result.exit_code == 0,
646 result.exit_code,
647 duration,
648 result.output.len(),
649 result.metadata.as_ref(),
650 ));
651 }
652 }
653}
654
655fn bounded_head_tail(content: &str, max_bytes: usize) -> String {
656 if content.len() <= max_bytes {
657 return content.to_string();
658 }
659 let head_limit = max_bytes / 2;
660 let tail_limit = max_bytes.saturating_sub(head_limit);
661 let head = crate::text::truncate_utf8(content, head_limit);
662 let mut tail_start = content.len().saturating_sub(tail_limit);
663 while tail_start < content.len() && !content.is_char_boundary(tail_start) {
664 tail_start += 1;
665 }
666 format!(
667 "{}\n\n... [{} bytes omitted from middle] ...\n\n{}",
668 head,
669 content
670 .len()
671 .saturating_sub(head.len())
672 .saturating_sub(content.len().saturating_sub(tail_start)),
673 &content[tail_start..]
674 )
675}
676
677#[cfg(test)]
678#[path = "registry/tests.rs"]
679mod tests;