1use anyhow::Result;
49use async_trait::async_trait;
50use serde_json::Value;
51use std::sync::Arc;
52
53use crate::config::Config;
54use crate::get::{get_document, DocumentResponse};
55use crate::models::SourceItem;
56use crate::search::{search_documents, SearchResultItem};
57use crate::sources::{get_sources, SourceStatus};
58use crate::workspace::{
59 fan_out, FanOut, RouterError, ServerMode, WorkspaceRouter, ALL_SELECTOR, FAN_OUT_CONCURRENCY,
60};
61
62#[async_trait]
105pub trait Connector: Send + Sync {
106 fn name(&self) -> &str;
111
112 fn description(&self) -> &str;
116
117 fn connector_type(&self) -> &str {
122 "custom"
123 }
124
125 fn source_label(&self) -> String {
129 format!("{}:{}", self.connector_type(), self.name())
130 }
131
132 async fn scan(&self) -> Result<Vec<SourceItem>>;
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum ToolExecutionErrorClass {
148 RecoverableError,
149 TerminalError,
150 UncertainSideEffect,
151}
152
153impl ToolExecutionErrorClass {
154 pub fn as_str(self) -> &'static str {
155 match self {
156 Self::RecoverableError => "recoverable_error",
157 Self::TerminalError => "terminal_error",
158 Self::UncertainSideEffect => "uncertain_side_effect",
159 }
160 }
161}
162
163pub struct ToolExecutionError {
168 class: ToolExecutionErrorClass,
169 code: String,
170 message: String,
171}
172
173impl ToolExecutionError {
174 pub fn recoverable(code: impl Into<String>, message: impl Into<String>) -> Result<Self> {
175 Self::new(ToolExecutionErrorClass::RecoverableError, code, message)
176 }
177
178 pub fn terminal(code: impl Into<String>, message: impl Into<String>) -> Result<Self> {
179 Self::new(ToolExecutionErrorClass::TerminalError, code, message)
180 }
181
182 pub fn uncertain_side_effect(
183 code: impl Into<String>,
184 message: impl Into<String>,
185 ) -> Result<Self> {
186 Self::new(ToolExecutionErrorClass::UncertainSideEffect, code, message)
187 }
188
189 fn new(
190 class: ToolExecutionErrorClass,
191 code: impl Into<String>,
192 message: impl Into<String>,
193 ) -> Result<Self> {
194 let code = code.into();
195 let message = message.into();
196 anyhow::ensure!(
197 !code.is_empty()
198 && code.len() <= 64
199 && code.bytes().enumerate().all(|(index, byte)| if index == 0 {
200 byte.is_ascii_lowercase()
201 } else {
202 byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_'
203 }),
204 "invalid tool execution error code"
205 );
206 anyhow::ensure!(
207 !message.trim().is_empty() && message.len() <= 4096,
208 "invalid tool execution error message"
209 );
210 Ok(Self {
211 class,
212 code,
213 message,
214 })
215 }
216
217 pub fn class(&self) -> ToolExecutionErrorClass {
218 self.class
219 }
220
221 pub fn code(&self) -> &str {
222 &self.code
223 }
224
225 pub fn message(&self) -> &str {
226 &self.message
227 }
228}
229
230impl std::fmt::Debug for ToolExecutionError {
231 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
232 formatter
233 .debug_struct("ToolExecutionError")
234 .field("class", &self.class.as_str())
235 .field("code", &self.code)
236 .finish_non_exhaustive()
237 }
238}
239
240impl std::fmt::Display for ToolExecutionError {
241 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
242 write!(formatter, "{}:{}", self.class.as_str(), self.code)
243 }
244}
245
246impl std::error::Error for ToolExecutionError {}
247
248#[async_trait]
297pub trait Tool: Send + Sync {
298 fn capabilities(&self) -> Option<Vec<crate::agent_resource::Capability>> {
301 None
302 }
303
304 fn binding_metadata(&self) -> Option<Value> {
307 None
308 }
309
310 fn runtime_dispatch(&self) -> ToolRuntimeDispatch {
313 ToolRuntimeDispatch::Direct
314 }
315
316 fn preparation(&self) -> Option<ToolPreparation> {
319 None
320 }
321
322 async fn prepare(&self, _ctx: &ToolContext) -> Result<()> {
323 Ok(())
324 }
325
326 fn name(&self) -> &str;
332
333 fn description(&self) -> &str;
337
338 fn is_builtin(&self) -> bool {
343 false
344 }
345
346 fn parameters_schema(&self) -> Value;
351
352 fn validate_arguments(&self, params: &Value) -> Result<()> {
355 anyhow::ensure!(params.is_object(), "tool arguments must be an object");
356 Ok(())
357 }
358
359 fn approval_arguments(&self, params: &Value) -> Result<Value> {
362 Ok(params.clone())
363 }
364
365 async fn execute(&self, params: Value, ctx: &ToolContext) -> Result<Value>;
379}
380
381#[derive(Debug, Clone, Copy, PartialEq, Eq)]
382pub enum ToolRuntimeDispatch {
383 Direct,
384 AgentDelegation,
385 RunControl(RunControlKind),
386}
387
388#[derive(Debug, Clone, Copy, PartialEq, Eq)]
389pub enum RunControlKind {
390 Blocked,
391 RequestUserInput,
392}
393
394#[derive(Debug, Clone)]
395pub struct ToolPreparation {
396 pub name: String,
397 pub capabilities: Vec<crate::agent_resource::Capability>,
398 pub arguments: Value,
399}
400
401#[derive(Debug, Default)]
407pub struct SearchOptions {
408 pub mode: Option<String>,
410 pub limit: Option<i64>,
412 pub source: Option<String>,
414}
415
416pub struct ToolContext {
424 config: Arc<Config>,
428 router: Arc<WorkspaceRouter>,
431 mode: ServerMode,
433}
434
435impl ToolContext {
436 pub fn new(config: Arc<Config>) -> Self {
441 let router = Arc::new(WorkspaceRouter::single(config.clone()));
442 Self {
443 config,
444 router,
445 mode: ServerMode::Compat,
446 }
447 }
448
449 pub fn routed(router: Arc<WorkspaceRouter>, mode: ServerMode) -> Self {
455 let config = router.default_config();
456 Self {
457 config,
458 router,
459 mode,
460 }
461 }
462
463 pub fn mode(&self) -> ServerMode {
465 self.mode
466 }
467
468 pub fn router(&self) -> &Arc<WorkspaceRouter> {
470 &self.router
471 }
472
473 pub fn config(&self) -> &Arc<Config> {
475 &self.config
476 }
477
478 pub async fn search(&self, query: &str, opts: SearchOptions) -> Result<Vec<SearchResultItem>> {
496 search_documents(
497 &self.config,
498 query,
499 opts.mode.as_deref().unwrap_or("keyword"),
500 opts.source.as_deref(),
501 None,
502 opts.limit,
503 false,
504 )
505 .await
506 }
507
508 pub async fn get(&self, id: &str) -> Result<DocumentResponse> {
512 get_document(&self.config, id).await
513 }
514
515 pub fn sources(&self) -> Result<Vec<SourceStatus>> {
519 Ok(get_sources(&self.config))
520 }
521}
522
523pub struct SearchTool;
529
530#[async_trait]
531impl Tool for SearchTool {
532 fn name(&self) -> &str {
533 "search"
534 }
535
536 fn description(&self) -> &str {
537 "Search the knowledge base"
538 }
539
540 fn is_builtin(&self) -> bool {
541 true
542 }
543
544 fn parameters_schema(&self) -> Value {
545 serde_json::json!({
546 "type": "object",
547 "properties": {
548 "query": { "type": "string", "description": "Search query" },
549 "mode": { "type": "string", "enum": ["keyword", "semantic", "hybrid"], "default": "keyword" },
550 "limit": { "type": "integer", "description": "Max results", "default": 12 },
551 "filters": {
552 "type": "object",
553 "properties": {
554 "source": { "type": "string", "description": "Filter by connector source" },
555 "since": { "type": "string", "description": "Only results updated after this date (YYYY-MM-DD)" }
556 }
557 }
558 },
559 "required": ["query"]
560 })
561 }
562
563 async fn execute(&self, params: Value, ctx: &ToolContext) -> Result<Value> {
564 let query = params["query"].as_str().unwrap_or("");
565 if query.trim().is_empty() {
566 anyhow::bail!("query must not be empty");
567 }
568
569 let mode = params["mode"].as_str().unwrap_or("keyword");
570 let limit = params["limit"].as_i64().unwrap_or(12);
571
572 let source = params
573 .get("filters")
574 .and_then(|f| f.get("source"))
575 .and_then(|s| s.as_str());
576 let since = params
577 .get("filters")
578 .and_then(|f| f.get("since"))
579 .and_then(|s| s.as_str());
580
581 let results =
582 search_documents(&ctx.config, query, mode, source, since, Some(limit), false).await?;
583
584 Ok(serde_json::json!({ "results": results }))
585 }
586}
587
588pub struct GetTool;
590
591#[async_trait]
592impl Tool for GetTool {
593 fn name(&self) -> &str {
594 "get"
595 }
596
597 fn description(&self) -> &str {
598 "Retrieve a document by UUID"
599 }
600
601 fn is_builtin(&self) -> bool {
602 true
603 }
604
605 fn parameters_schema(&self) -> Value {
606 serde_json::json!({
607 "type": "object",
608 "properties": {
609 "id": { "type": "string", "description": "Document UUID" }
610 },
611 "required": ["id"]
612 })
613 }
614
615 async fn execute(&self, params: Value, ctx: &ToolContext) -> Result<Value> {
616 let id = params["id"].as_str().unwrap_or("");
617 if id.trim().is_empty() {
618 anyhow::bail!("id must not be empty");
619 }
620
621 let doc = get_document(&ctx.config, id).await?;
622 Ok(serde_json::to_value(&doc)?)
623 }
624}
625
626pub struct SourcesTool;
628
629#[async_trait]
630impl Tool for SourcesTool {
631 fn name(&self) -> &str {
632 "sources"
633 }
634
635 fn description(&self) -> &str {
636 "List connector configuration and health status"
637 }
638
639 fn is_builtin(&self) -> bool {
640 true
641 }
642
643 fn parameters_schema(&self) -> Value {
644 serde_json::json!({
645 "type": "object",
646 "properties": {}
647 })
648 }
649
650 async fn execute(&self, _params: Value, ctx: &ToolContext) -> Result<Value> {
651 let sources = get_sources(&ctx.config);
652 Ok(serde_json::json!({ "sources": sources }))
653 }
654}
655
656fn schema_with_workspace_selector(mut schema: Value) -> Value {
669 if let Some(props) = schema.get_mut("properties").and_then(|p| p.as_object_mut()) {
670 props.insert(
671 "workspace".to_string(),
672 serde_json::json!({
673 "type": "string",
674 "description": "Workspace id to target (omit for the default workspace)"
675 }),
676 );
677 }
678 schema
679}
680
681fn search_group(workspace: &str, results: Vec<SearchResultItem>) -> Value {
684 let items: Vec<Value> = results
685 .into_iter()
686 .map(|item| {
687 let mut obj = serde_json::to_value(&item).unwrap_or(Value::Null);
688 if let Value::Object(map) = &mut obj {
689 let doc_id = map
690 .get("id")
691 .and_then(|v| v.as_str())
692 .unwrap_or_default()
693 .to_string();
694 map.insert(
695 "workspace".to_string(),
696 Value::String(workspace.to_string()),
697 );
698 map.insert(
699 "qualified_id".to_string(),
700 Value::String(format!("{workspace}:{doc_id}")),
701 );
702 }
703 obj
704 })
705 .collect();
706 serde_json::json!({ "workspace": workspace, "items": items })
707}
708
709fn shape_grouped_search(workspace: &str, results: Vec<SearchResultItem>) -> Value {
711 serde_json::json!({ "results": [ search_group(workspace, results) ], "errors": [] })
712}
713
714fn sources_group(workspace: &str, sources: Vec<SourceStatus>) -> Value {
716 serde_json::json!({ "workspace": workspace, "sources": sources })
717}
718
719fn error_entry(workspace: &str, err: &RouterError) -> Value {
722 serde_json::json!({
723 "workspace": workspace,
724 "code": err.code(),
725 "message": err.to_string(),
726 })
727}
728
729async fn search_all(
733 ctx: &ToolContext,
734 query: String,
735 mode: String,
736 source: Option<String>,
737 since: Option<String>,
738 limit: i64,
739) -> Result<Value> {
740 let router = ctx.router();
741 let (healthy, seed_errors) = router.resolve_all();
742 let deadline = router.search_deadline();
743 let deadline_ms = deadline.as_millis() as u64;
744
745 let items: Vec<(String, Arc<Config>)> = healthy
746 .iter()
747 .map(|rt| (rt.id.clone(), rt.config.clone()))
748 .collect();
749
750 let outcomes = fan_out(
751 items,
752 deadline,
753 FAN_OUT_CONCURRENCY,
754 move |config: Arc<Config>| {
755 let query = query.clone();
756 let mode = mode.clone();
757 let source = source.clone();
758 let since = since.clone();
759 async move {
760 search_documents(
761 &config,
762 &query,
763 &mode,
764 source.as_deref(),
765 since.as_deref(),
766 Some(limit),
767 false,
768 )
769 .await
770 }
771 },
772 )
773 .await;
774
775 let mut groups: Vec<Value> = Vec::new();
776 let mut error_pairs: Vec<(String, Value)> = seed_errors
777 .iter()
778 .map(|(id, err)| (id.clone(), error_entry(id, err)))
779 .collect();
780
781 for (id, outcome) in outcomes {
782 match outcome {
783 FanOut::Ok(results) => groups.push(search_group(&id, results)),
784 FanOut::Failed(msg) => error_pairs.push((
785 id.clone(),
786 error_entry(
787 &id,
788 &RouterError::WorkspaceUnavailable {
789 id: id.clone(),
790 reason: msg,
791 },
792 ),
793 )),
794 FanOut::TimedOut => error_pairs.push((
795 id.clone(),
796 error_entry(
797 &id,
798 &RouterError::WorkspaceTimeout {
799 id: id.clone(),
800 deadline_ms,
801 },
802 ),
803 )),
804 }
805 }
806
807 let ws_order: Vec<String> = ctx.router().list().iter().map(|rt| rt.id.clone()).collect();
809 error_pairs.sort_by_key(|(id, _)| ws_order.iter().position(|w| w == id).unwrap_or(usize::MAX));
810 let errors: Vec<Value> = error_pairs.into_iter().map(|(_, v)| v).collect();
811
812 Ok(serde_json::json!({ "results": groups, "errors": errors }))
813}
814
815pub struct RoutedSearchTool;
817
818#[async_trait]
819impl Tool for RoutedSearchTool {
820 fn name(&self) -> &str {
821 "search"
822 }
823
824 fn description(&self) -> &str {
825 "Search the knowledge base (optionally scoped to a workspace)"
826 }
827
828 fn is_builtin(&self) -> bool {
829 true
830 }
831
832 fn parameters_schema(&self) -> Value {
833 schema_with_workspace_selector(SearchTool.parameters_schema())
834 }
835
836 async fn execute(&self, params: Value, ctx: &ToolContext) -> Result<Value> {
837 let query = params["query"].as_str().unwrap_or("");
838 if query.trim().is_empty() {
839 anyhow::bail!("query must not be empty");
840 }
841 let mode = params["mode"].as_str().unwrap_or("keyword");
842 let limit = params["limit"].as_i64().unwrap_or(12);
843 let source = params
844 .get("filters")
845 .and_then(|f| f.get("source"))
846 .and_then(|s| s.as_str());
847 let since = params
848 .get("filters")
849 .and_then(|f| f.get("since"))
850 .and_then(|s| s.as_str());
851
852 let selector = params["workspace"].as_str();
853 if selector == Some(ALL_SELECTOR) {
854 return search_all(
855 ctx,
856 query.to_string(),
857 mode.to_string(),
858 source.map(str::to_string),
859 since.map(str::to_string),
860 limit,
861 )
862 .await;
863 }
864 let runtime = ctx.router().resolve(selector)?;
865
866 let results = search_documents(
867 &runtime.config,
868 query,
869 mode,
870 source,
871 since,
872 Some(limit),
873 false,
874 )
875 .await?;
876 Ok(shape_grouped_search(&runtime.id, results))
877 }
878}
879
880pub struct RoutedGetTool;
883
884#[async_trait]
885impl Tool for RoutedGetTool {
886 fn name(&self) -> &str {
887 "get"
888 }
889
890 fn description(&self) -> &str {
891 "Retrieve a document by id or qualified id (<workspace>:<id>)"
892 }
893
894 fn is_builtin(&self) -> bool {
895 true
896 }
897
898 fn parameters_schema(&self) -> Value {
899 schema_with_workspace_selector(GetTool.parameters_schema())
900 }
901
902 async fn execute(&self, params: Value, ctx: &ToolContext) -> Result<Value> {
903 let id = params["id"].as_str().unwrap_or("");
904 if id.trim().is_empty() {
905 anyhow::bail!("id must not be empty");
906 }
907 let field = params["workspace"].as_str();
908 let (qualified_prefix, raw_id) = ctx.router().split_qualified_id(id);
909
910 if let (Some(q), Some(f)) = (qualified_prefix, field) {
912 if q != f {
913 return Err(RouterError::WorkspaceIdConflict {
914 field: f.to_string(),
915 qualified: q.to_string(),
916 }
917 .into());
918 }
919 }
920
921 let selector = qualified_prefix.or(field);
922 let runtime = ctx.router().resolve(selector)?;
923
924 let doc = get_document(&runtime.config, raw_id).await?;
925 let mut obj = serde_json::to_value(&doc)?;
926 if let Value::Object(map) = &mut obj {
927 map.insert("workspace".to_string(), Value::String(runtime.id.clone()));
928 map.insert(
929 "qualified_id".to_string(),
930 Value::String(format!("{}:{}", runtime.id, raw_id)),
931 );
932 }
933 Ok(obj)
934 }
935}
936
937pub struct RoutedSourcesTool;
940
941#[async_trait]
942impl Tool for RoutedSourcesTool {
943 fn name(&self) -> &str {
944 "sources"
945 }
946
947 fn description(&self) -> &str {
948 "List connector status for a workspace (optionally scoped to a workspace)"
949 }
950
951 fn is_builtin(&self) -> bool {
952 true
953 }
954
955 fn parameters_schema(&self) -> Value {
956 schema_with_workspace_selector(SourcesTool.parameters_schema())
957 }
958
959 async fn execute(&self, params: Value, ctx: &ToolContext) -> Result<Value> {
960 let selector = params["workspace"].as_str();
961 if selector == Some(ALL_SELECTOR) {
962 let (healthy, seed_errors) = ctx.router().resolve_all();
963 let results: Vec<Value> = healthy
964 .iter()
965 .map(|rt| sources_group(&rt.id, get_sources(&rt.config)))
966 .collect();
967 let errors: Vec<Value> = seed_errors
968 .iter()
969 .map(|(id, err)| error_entry(id, err))
970 .collect();
971 return Ok(serde_json::json!({ "results": results, "errors": errors }));
972 }
973 let runtime = ctx.router().resolve(selector)?;
974 let sources = get_sources(&runtime.config);
975 Ok(serde_json::json!({
976 "results": [ sources_group(&runtime.id, sources) ],
977 "errors": []
978 }))
979 }
980}
981
982pub struct WorkspacesTool;
985
986#[async_trait]
987impl Tool for WorkspacesTool {
988 fn name(&self) -> &str {
989 "workspaces"
990 }
991
992 fn description(&self) -> &str {
993 "List registered workspaces, their health, and the default workspace"
994 }
995
996 fn is_builtin(&self) -> bool {
997 true
998 }
999
1000 fn parameters_schema(&self) -> Value {
1001 serde_json::json!({ "type": "object", "properties": {} })
1002 }
1003
1004 async fn execute(&self, _params: Value, ctx: &ToolContext) -> Result<Value> {
1005 let router = ctx.router();
1006 let default = router.default_id();
1007 let workspaces: Vec<Value> = router
1008 .list()
1009 .iter()
1010 .map(|rt| {
1011 let health = match &rt.health {
1012 crate::workspace::WorkspaceHealth::Ok => serde_json::json!({ "status": "ok" }),
1013 crate::workspace::WorkspaceHealth::Unavailable(reason) => {
1014 serde_json::json!({ "status": "unavailable", "reason": reason })
1015 }
1016 };
1017 serde_json::json!({
1018 "id": rt.id,
1019 "root": rt.root.as_ref().map(|p| p.display().to_string()),
1020 "enabled": rt.enabled,
1021 "default": Some(rt.id.as_str()) == default,
1022 "resolution": rt.resolution.as_str(),
1023 "health": health,
1024 })
1025 })
1026 .collect();
1027 Ok(serde_json::json!({ "workspaces": workspaces }))
1028 }
1029}
1030
1031pub struct ConnectorRegistry {
1050 connectors: Vec<Box<dyn Connector>>,
1051}
1052
1053impl ConnectorRegistry {
1054 pub fn new() -> Self {
1056 Self {
1057 connectors: Vec::new(),
1058 }
1059 }
1060
1061 pub fn from_config(config: &Config) -> Self {
1066 use crate::connector_fs::FilesystemConnector;
1067 use crate::connector_git::GitConnector;
1068 use crate::connector_s3::S3Connector;
1069 use crate::connector_script::ScriptConnector;
1070
1071 let mut registry = Self::new();
1072
1073 for (name, cfg) in &config.connectors.filesystem {
1074 registry.register(Box::new(FilesystemConnector::new(
1075 name.clone(),
1076 cfg.clone(),
1077 )));
1078 }
1079 for (name, cfg) in &config.connectors.git {
1080 registry.register(Box::new(GitConnector::new(
1081 name.clone(),
1082 cfg.clone(),
1083 config.db.path.clone(),
1084 )));
1085 }
1086 for (name, cfg) in &config.connectors.s3 {
1087 registry.register(Box::new(S3Connector::new(name.clone(), cfg.clone())));
1088 }
1089 for (name, cfg) in &config.connectors.script {
1090 registry.register(Box::new(ScriptConnector::new(name.clone(), cfg.clone())));
1091 }
1092
1093 registry
1094 }
1095
1096 pub fn register(&mut self, connector: Box<dyn Connector>) {
1098 self.connectors.push(connector);
1099 }
1100
1101 pub fn connectors(&self) -> &[Box<dyn Connector>] {
1103 &self.connectors
1104 }
1105
1106 pub fn connectors_by_type(&self, connector_type: &str) -> Vec<&dyn Connector> {
1108 self.connectors
1109 .iter()
1110 .filter(|c| c.connector_type() == connector_type)
1111 .map(|c| c.as_ref())
1112 .collect()
1113 }
1114
1115 pub fn find(&self, connector_type: &str, name: &str) -> Option<&dyn Connector> {
1117 self.connectors
1118 .iter()
1119 .find(|c| c.connector_type() == connector_type && c.name() == name)
1120 .map(|c| c.as_ref())
1121 }
1122
1123 pub fn is_empty(&self) -> bool {
1125 self.connectors.is_empty()
1126 }
1127
1128 pub fn len(&self) -> usize {
1130 self.connectors.len()
1131 }
1132}
1133
1134impl Default for ConnectorRegistry {
1135 fn default() -> Self {
1136 Self::new()
1137 }
1138}
1139
1140pub struct ToolRegistry {
1155 tools: Vec<Box<dyn Tool>>,
1156}
1157
1158impl ToolRegistry {
1159 pub fn new() -> Self {
1161 Self { tools: Vec::new() }
1162 }
1163
1164 pub fn with_builtins() -> Self {
1166 let mut registry = Self::new();
1167 registry.register(Box::new(SearchTool));
1168 registry.register(Box::new(GetTool));
1169 registry.register(Box::new(SourcesTool));
1170 registry
1171 }
1172
1173 pub fn with_builtins_multi() -> Self {
1179 let mut registry = Self::new();
1180 registry.register(Box::new(RoutedSearchTool));
1181 registry.register(Box::new(RoutedGetTool));
1182 registry.register(Box::new(RoutedSourcesTool));
1183 registry.register(Box::new(WorkspacesTool));
1184 registry
1185 }
1186
1187 pub fn register(&mut self, tool: Box<dyn Tool>) {
1189 self.tools.push(tool);
1190 }
1191
1192 pub fn tools(&self) -> &[Box<dyn Tool>] {
1194 &self.tools
1195 }
1196
1197 pub fn into_tools(self) -> Vec<Box<dyn Tool>> {
1199 self.tools
1200 }
1201
1202 pub fn find(&self, name: &str) -> Option<&dyn Tool> {
1204 self.tools
1205 .iter()
1206 .find(|t| t.name() == name)
1207 .map(|t| t.as_ref())
1208 }
1209
1210 pub fn is_empty(&self) -> bool {
1212 self.tools.is_empty()
1213 }
1214
1215 pub fn len(&self) -> usize {
1217 self.tools.len()
1218 }
1219}
1220
1221impl Default for ToolRegistry {
1222 fn default() -> Self {
1223 Self::new()
1224 }
1225}
1226
1227#[cfg(test)]
1228mod phase2_tests {
1229 use super::*;
1230 use crate::workspace::RouterError;
1231
1232 #[test]
1233 fn search_group_tags_items_with_workspace_and_qualified_id() {
1234 let item = SearchResultItem {
1235 id: "01ABC".to_string(),
1236 score: 0.5,
1237 title: Some("T".to_string()),
1238 source: "filesystem".to_string(),
1239 source_id: "f".to_string(),
1240 updated_at: "2026-01-01T00:00:00Z".to_string(),
1241 snippet: "snip".to_string(),
1242 source_url: None,
1243 explain: None,
1244 };
1245 let group = search_group("beta", vec![item]);
1246 assert_eq!(group["workspace"], "beta");
1247 let items = group["items"].as_array().unwrap();
1248 assert_eq!(items[0]["workspace"], "beta");
1249 assert_eq!(items[0]["qualified_id"], "beta:01ABC");
1250 }
1251
1252 #[test]
1253 fn error_entry_has_workspace_code_message() {
1254 let err = RouterError::WorkspaceTimeout {
1255 id: "beta".to_string(),
1256 deadline_ms: 5000,
1257 };
1258 let entry = error_entry("beta", &err);
1259 assert_eq!(entry["workspace"], "beta");
1260 assert_eq!(entry["code"], "workspace_timeout");
1261 assert!(entry["message"].as_str().unwrap().contains("5000"));
1262 }
1263
1264 #[test]
1265 fn tool_execution_errors_validate_and_redact_public_messages() {
1266 let error = ToolExecutionError::recoverable("not_found", "Safe public message").unwrap();
1267 assert_eq!(error.class(), ToolExecutionErrorClass::RecoverableError);
1268 assert_eq!(error.code(), "not_found");
1269 assert_eq!(error.message(), "Safe public message");
1270 assert_eq!(error.to_string(), "recoverable_error:not_found");
1271 assert!(!format!("{error:?}").contains("Safe public message"));
1272
1273 for code in ["", "NotFound", "1bad", "bad-code", &"a".repeat(65)] {
1274 assert!(ToolExecutionError::terminal(code, "safe").is_err());
1275 }
1276 for message in ["", " ", &"x".repeat(4097)] {
1277 assert!(ToolExecutionError::uncertain_side_effect("unknown", message).is_err());
1278 }
1279 }
1280}