context_harness/traits.rs
1//! Extension traits for custom connectors and tools.
2//!
3//! This module provides the trait-based extension system for Context Harness.
4//! Users can implement [`Connector`] and [`Tool`] in Rust to create compiled
5//! extensions that run alongside built-in and Lua-scripted ones.
6//!
7//! # Architecture
8//!
9//! ```text
10//! ┌──────────────────────────────────────────┐
11//! │ ConnectorRegistry │
12//! │ ┌─────────┐ ┌─────────┐ ┌────────────┐ │
13//! │ │Built-in │ │ Lua │ │ Custom │ │
14//! │ │FS/Git/S3│ │ Script │ │ (Rust) │ │
15//! │ └─────────┘ └─────────┘ └────────────┘ │
16//! └──────────────┬───────────────────────────┘
17//! ▼
18//! run_sync() → ingest pipeline
19//! ```
20//!
21//! ```text
22//! ┌──────────────────────────────────────────┐
23//! │ ToolRegistry │
24//! │ ┌─────────┐ ┌─────────┐ ┌────────────┐ │
25//! │ │Built-in │ │ Lua │ │ Custom │ │
26//! │ │search │ │ Script │ │ (Rust) │ │
27//! │ │get/src │ │ Tools │ │ Tools │ │
28//! │ └─────────┘ └─────────┘ └────────────┘ │
29//! └──────────────┬───────────────────────────┘
30//! ▼
31//! run_server() → MCP HTTP API
32//! ```
33//!
34//! # Usage
35//!
36//! ```rust
37//! use context_harness::traits::{ConnectorRegistry, ToolRegistry};
38//!
39//! let mut connectors = ConnectorRegistry::new();
40//! // connectors.register(Box::new(MyConnector::new()));
41//!
42//! let mut tools = ToolRegistry::new();
43//! // tools.register(Box::new(MyTool::new()));
44//! ```
45//!
46//! See `docs/RUST_TRAITS.md` for the full specification and examples.
47
48use 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// ═══════════════════════════════════════════════════════════════════════
63// Connector Trait
64// ═══════════════════════════════════════════════════════════════════════
65
66/// A data source connector that produces documents for ingestion.
67///
68/// Implement this trait to create a custom connector in Rust. The
69/// connector is responsible for scanning an external data source and
70/// returning a list of [`SourceItem`]s that flow through the standard
71/// ingestion pipeline (normalization → chunking → embedding).
72///
73/// # Lifecycle
74///
75/// 1. The connector is registered via [`ConnectorRegistry::register`].
76/// 2. [`scan`](Connector::scan) is called during `ctx sync custom:<name>`.
77/// 3. Returned items are normalized, chunked, and indexed.
78///
79/// # Example
80///
81/// ```rust
82/// use async_trait::async_trait;
83/// use anyhow::Result;
84/// use context_harness::models::SourceItem;
85/// use context_harness::traits::Connector;
86/// use chrono::Utc;
87///
88/// pub struct DatabaseConnector {
89/// connection_string: String,
90/// }
91///
92/// #[async_trait]
93/// impl Connector for DatabaseConnector {
94/// fn name(&self) -> &str { "database" }
95/// fn description(&self) -> &str { "Ingest rows from a database table" }
96/// fn connector_type(&self) -> &str { "custom" }
97///
98/// async fn scan(&self) -> Result<Vec<SourceItem>> {
99/// // ... query database and return SourceItems
100/// Ok(vec![])
101/// }
102/// }
103/// ```
104#[async_trait]
105pub trait Connector: Send + Sync {
106 /// Returns the connector instance name (e.g. `"docs"`, `"platform"`).
107 ///
108 /// Combined with [`connector_type`](Connector::connector_type) to form
109 /// the source label: `"{type}:{name}"`.
110 fn name(&self) -> &str;
111
112 /// Returns a one-line description of what this connector does.
113 ///
114 /// Used in `ctx sources` output and documentation.
115 fn description(&self) -> &str;
116
117 /// Returns the connector type identifier (e.g. `"filesystem"`, `"git"`, `"s3"`, `"custom"`).
118 ///
119 /// Built-in connectors return their type name; custom (user-defined)
120 /// connectors default to `"custom"`.
121 fn connector_type(&self) -> &str {
122 "custom"
123 }
124
125 /// Returns the source label used to tag documents from this connector.
126 ///
127 /// Defaults to `"{connector_type}:{name}"` (e.g. `"git:platform"`).
128 fn source_label(&self) -> String {
129 format!("{}:{}", self.connector_type(), self.name())
130 }
131
132 /// Scan the data source and return all items to ingest.
133 ///
134 /// Called on the tokio async runtime. May perform I/O operations
135 /// (HTTP requests, database queries, file reads).
136 ///
137 /// # Returns
138 ///
139 /// A vector of [`SourceItem`]s. Each item flows through the standard
140 /// ingestion pipeline. Items with empty `body` or `source_id` are
141 /// skipped with a warning.
142 async fn scan(&self) -> Result<Vec<SourceItem>>;
143}
144
145// ═══════════════════════════════════════════════════════════════════════
146// Tool Trait
147// ═══════════════════════════════════════════════════════════════════════
148
149/// A custom MCP tool that agents can discover and call.
150///
151/// Implement this trait to create a compiled Rust tool. Tools are
152/// registered at server startup and exposed via `GET /tools/list`
153/// for agent discovery and `POST /tools/{name}` for invocation.
154///
155/// # Lifecycle
156///
157/// 1. The tool is registered via [`ToolRegistry::register`].
158/// 2. [`name`](Tool::name), [`description`](Tool::description), and
159/// [`parameters_schema`](Tool::parameters_schema) are called at startup
160/// for the tool list.
161/// 3. [`execute`](Tool::execute) is called each time an agent invokes
162/// the tool.
163///
164/// # Example
165///
166/// ```rust
167/// use async_trait::async_trait;
168/// use anyhow::Result;
169/// use serde_json::{json, Value};
170/// use context_harness::traits::{Tool, ToolContext};
171///
172/// pub struct HealthCheckTool;
173///
174/// #[async_trait]
175/// impl Tool for HealthCheckTool {
176/// fn name(&self) -> &str { "health_check" }
177/// fn description(&self) -> &str { "Check connector health" }
178///
179/// fn parameters_schema(&self) -> Value {
180/// json!({
181/// "type": "object",
182/// "properties": {},
183/// "required": []
184/// })
185/// }
186///
187/// async fn execute(&self, _params: Value, ctx: &ToolContext) -> Result<Value> {
188/// let sources = ctx.sources()?;
189/// Ok(json!({ "sources": sources.len() }))
190/// }
191/// }
192/// ```
193#[async_trait]
194pub trait Tool: Send + Sync {
195 /// Returns the tool's name.
196 ///
197 /// Used as the route path (`POST /tools/{name}`) and in
198 /// `GET /tools/list` responses. Should be a lowercase
199 /// identifier with underscores (e.g., `"create_ticket"`).
200 fn name(&self) -> &str;
201
202 /// Returns a one-line description for agent discovery.
203 ///
204 /// Agents use this to decide whether to call the tool.
205 fn description(&self) -> &str;
206
207 /// Whether this tool is a built-in (true for search/get/sources).
208 ///
209 /// Built-in tools are marked with `"builtin": true` in the
210 /// `GET /tools/list` response. Defaults to `false`.
211 fn is_builtin(&self) -> bool {
212 false
213 }
214
215 /// Returns the OpenAI function-calling JSON Schema for parameters.
216 ///
217 /// Must be a valid JSON Schema object with `type: "object"`,
218 /// `properties`, and optionally `required`.
219 fn parameters_schema(&self) -> Value;
220
221 /// Execute the tool with validated parameters.
222 ///
223 /// Called each time an agent invokes the tool via `POST /tools/{name}`.
224 ///
225 /// # Arguments
226 ///
227 /// * `params` — JSON parameters (always a JSON object).
228 /// * `ctx` — Bridge to the Context Harness knowledge base.
229 ///
230 /// # Returns
231 ///
232 /// A JSON value that will be wrapped in `{ "result": ... }` in the
233 /// HTTP response.
234 async fn execute(&self, params: Value, ctx: &ToolContext) -> Result<Value>;
235}
236
237// ═══════════════════════════════════════════════════════════════════════
238// ToolContext
239// ═══════════════════════════════════════════════════════════════════════
240
241/// Options for [`ToolContext::search`].
242#[derive(Debug, Default)]
243pub struct SearchOptions {
244 /// Search mode: `"keyword"`, `"semantic"`, or `"hybrid"`.
245 pub mode: Option<String>,
246 /// Maximum number of results.
247 pub limit: Option<i64>,
248 /// Filter by source connector (e.g., `"git:platform"`).
249 pub source: Option<String>,
250}
251
252/// Context bridge for tool execution.
253///
254/// Provides tools with access to the Context Harness knowledge base
255/// during execution. Created by the server for each tool invocation.
256///
257/// All methods delegate to the same core functions used by the CLI
258/// and HTTP server, ensuring tools have identical capabilities.
259pub struct ToolContext {
260 /// The active workspace config used by the convenience methods below and by
261 /// compatibility-mode built-in tools. In multi mode this is the default
262 /// workspace's config; router-aware tools resolve per call instead.
263 config: Arc<Config>,
264 /// The workspace router. In compatibility mode this is a one-workspace
265 /// router; router-aware (multi-mode) tools dispatch through it.
266 router: Arc<WorkspaceRouter>,
267 /// Whether this context serves the pre-router flat shapes or labeled shapes.
268 mode: ServerMode,
269}
270
271impl ToolContext {
272 /// Create a single-workspace (compatibility) tool context from a config.
273 ///
274 /// Internally wraps the config in a one-workspace [`WorkspaceRouter`], so a
275 /// compatibility context is just "a router with one workspace".
276 pub fn new(config: Arc<Config>) -> Self {
277 let router = Arc::new(WorkspaceRouter::single(config.clone()));
278 Self {
279 config,
280 router,
281 mode: ServerMode::Compat,
282 }
283 }
284
285 /// Create a tool context backed by a (possibly multi-workspace) router.
286 ///
287 /// The convenience methods default to the router's default workspace; the
288 /// router-aware built-in tools resolve the request's `workspace` selector
289 /// against [`ToolContext::router`] per call.
290 pub fn routed(router: Arc<WorkspaceRouter>, mode: ServerMode) -> Self {
291 let config = router.default_config();
292 Self {
293 config,
294 router,
295 mode,
296 }
297 }
298
299 /// The server mode this context serves.
300 pub fn mode(&self) -> ServerMode {
301 self.mode
302 }
303
304 /// The workspace router, for router-aware tools.
305 pub fn router(&self) -> &Arc<WorkspaceRouter> {
306 &self.router
307 }
308
309 /// The active workspace config (the default workspace in multi mode).
310 pub fn config(&self) -> &Arc<Config> {
311 &self.config
312 }
313
314 /// Search the knowledge base.
315 ///
316 /// Equivalent to `POST /tools/search` or `ctx search`.
317 ///
318 /// # Example
319 ///
320 /// ```rust,no_run
321 /// # use context_harness::traits::{ToolContext, SearchOptions};
322 /// # async fn example(ctx: &ToolContext) -> anyhow::Result<()> {
323 /// let results = ctx.search("deployment runbook", SearchOptions {
324 /// mode: Some("hybrid".to_string()),
325 /// limit: Some(5),
326 /// ..Default::default()
327 /// }).await?;
328 /// # Ok(())
329 /// # }
330 /// ```
331 pub async fn search(&self, query: &str, opts: SearchOptions) -> Result<Vec<SearchResultItem>> {
332 search_documents(
333 &self.config,
334 query,
335 opts.mode.as_deref().unwrap_or("keyword"),
336 opts.source.as_deref(),
337 None,
338 opts.limit,
339 false,
340 )
341 .await
342 }
343
344 /// Retrieve a document by UUID.
345 ///
346 /// Equivalent to `POST /tools/get` or `ctx get`.
347 pub async fn get(&self, id: &str) -> Result<DocumentResponse> {
348 get_document(&self.config, id).await
349 }
350
351 /// List all configured connectors and their status.
352 ///
353 /// Equivalent to `GET /tools/sources` or `ctx sources`.
354 pub fn sources(&self) -> Result<Vec<SourceStatus>> {
355 Ok(get_sources(&self.config))
356 }
357}
358
359// ═══════════════════════════════════════════════════════════════════════
360// Built-in Tool Implementations
361// ═══════════════════════════════════════════════════════════════════════
362
363/// Built-in search tool. Delegates to [`ToolContext::search`].
364pub struct SearchTool;
365
366#[async_trait]
367impl Tool for SearchTool {
368 fn name(&self) -> &str {
369 "search"
370 }
371
372 fn description(&self) -> &str {
373 "Search the knowledge base"
374 }
375
376 fn is_builtin(&self) -> bool {
377 true
378 }
379
380 fn parameters_schema(&self) -> Value {
381 serde_json::json!({
382 "type": "object",
383 "properties": {
384 "query": { "type": "string", "description": "Search query" },
385 "mode": { "type": "string", "enum": ["keyword", "semantic", "hybrid"], "default": "keyword" },
386 "limit": { "type": "integer", "description": "Max results", "default": 12 },
387 "filters": {
388 "type": "object",
389 "properties": {
390 "source": { "type": "string", "description": "Filter by connector source" },
391 "since": { "type": "string", "description": "Only results updated after this date (YYYY-MM-DD)" }
392 }
393 }
394 },
395 "required": ["query"]
396 })
397 }
398
399 async fn execute(&self, params: Value, ctx: &ToolContext) -> Result<Value> {
400 let query = params["query"].as_str().unwrap_or("");
401 if query.trim().is_empty() {
402 anyhow::bail!("query must not be empty");
403 }
404
405 let mode = params["mode"].as_str().unwrap_or("keyword");
406 let limit = params["limit"].as_i64().unwrap_or(12);
407
408 let source = params
409 .get("filters")
410 .and_then(|f| f.get("source"))
411 .and_then(|s| s.as_str());
412 let since = params
413 .get("filters")
414 .and_then(|f| f.get("since"))
415 .and_then(|s| s.as_str());
416
417 let results =
418 search_documents(&ctx.config, query, mode, source, since, Some(limit), false).await?;
419
420 Ok(serde_json::json!({ "results": results }))
421 }
422}
423
424/// Built-in document retrieval tool. Delegates to [`get_document`].
425pub struct GetTool;
426
427#[async_trait]
428impl Tool for GetTool {
429 fn name(&self) -> &str {
430 "get"
431 }
432
433 fn description(&self) -> &str {
434 "Retrieve a document by UUID"
435 }
436
437 fn is_builtin(&self) -> bool {
438 true
439 }
440
441 fn parameters_schema(&self) -> Value {
442 serde_json::json!({
443 "type": "object",
444 "properties": {
445 "id": { "type": "string", "description": "Document UUID" }
446 },
447 "required": ["id"]
448 })
449 }
450
451 async fn execute(&self, params: Value, ctx: &ToolContext) -> Result<Value> {
452 let id = params["id"].as_str().unwrap_or("");
453 if id.trim().is_empty() {
454 anyhow::bail!("id must not be empty");
455 }
456
457 let doc = get_document(&ctx.config, id).await?;
458 Ok(serde_json::to_value(&doc)?)
459 }
460}
461
462/// Built-in sources listing tool. Delegates to [`get_sources`].
463pub struct SourcesTool;
464
465#[async_trait]
466impl Tool for SourcesTool {
467 fn name(&self) -> &str {
468 "sources"
469 }
470
471 fn description(&self) -> &str {
472 "List connector configuration and health status"
473 }
474
475 fn is_builtin(&self) -> bool {
476 true
477 }
478
479 fn parameters_schema(&self) -> Value {
480 serde_json::json!({
481 "type": "object",
482 "properties": {}
483 })
484 }
485
486 async fn execute(&self, _params: Value, ctx: &ToolContext) -> Result<Value> {
487 let sources = get_sources(&ctx.config);
488 Ok(serde_json::json!({ "sources": sources }))
489 }
490}
491
492// ═══════════════════════════════════════════════════════════════════════
493// Router-aware built-in tools (multi-workspace mode)
494// ═══════════════════════════════════════════════════════════════════════
495//
496// These mirror the compat built-ins but accept an optional `workspace`
497// selector, dispatch through the request's [`WorkspaceRouter`], and emit the
498// workspace-labeled (grouped) shapes (SPEC-0014 R18–R37). They are registered
499// only in multi-workspace mode; compatibility mode keeps the unit-struct tools
500// above unchanged (the additive invariant). `workspace = "all"` fan-out is
501// Phase 2 — the router currently rejects it with `unsupported_workspace_selector`.
502
503/// Add the optional `workspace` selector to a compat schema's `properties`.
504fn schema_with_workspace_selector(mut schema: Value) -> Value {
505 if let Some(props) = schema.get_mut("properties").and_then(|p| p.as_object_mut()) {
506 props.insert(
507 "workspace".to_string(),
508 serde_json::json!({
509 "type": "string",
510 "description": "Workspace id to target (omit for the default workspace)"
511 }),
512 );
513 }
514 schema
515}
516
517/// Build one `{ workspace, items }` group, tagging every item with `workspace`
518/// and a `qualified_id` (R29/R30/R36).
519fn search_group(workspace: &str, results: Vec<SearchResultItem>) -> Value {
520 let items: Vec<Value> = results
521 .into_iter()
522 .map(|item| {
523 let mut obj = serde_json::to_value(&item).unwrap_or(Value::Null);
524 if let Value::Object(map) = &mut obj {
525 let doc_id = map
526 .get("id")
527 .and_then(|v| v.as_str())
528 .unwrap_or_default()
529 .to_string();
530 map.insert(
531 "workspace".to_string(),
532 Value::String(workspace.to_string()),
533 );
534 map.insert(
535 "qualified_id".to_string(),
536 Value::String(format!("{workspace}:{doc_id}")),
537 );
538 }
539 obj
540 })
541 .collect();
542 serde_json::json!({ "workspace": workspace, "items": items })
543}
544
545/// A single-workspace grouped response: one group, no errors (R29/R30).
546fn shape_grouped_search(workspace: &str, results: Vec<SearchResultItem>) -> Value {
547 serde_json::json!({ "results": [ search_group(workspace, results) ], "errors": [] })
548}
549
550/// Build one `{ workspace, sources }` group for the `sources` tool.
551fn sources_group(workspace: &str, sources: Vec<SourceStatus>) -> Value {
552 serde_json::json!({ "workspace": workspace, "sources": sources })
553}
554
555/// Build one `errors[]` entry for a failed workspace (R37):
556/// `{ workspace, code, message }`.
557fn error_entry(workspace: &str, err: &RouterError) -> Value {
558 serde_json::json!({
559 "workspace": workspace,
560 "code": err.code(),
561 "message": err.to_string(),
562 })
563}
564
565/// Fan out a `search` across every enabled workspace (R32–R37): concurrent,
566/// per-workspace deadline, per-workspace `limit`, grouped results, and one
567/// `errors[]` entry per failed/timed-out workspace.
568async fn search_all(
569 ctx: &ToolContext,
570 query: String,
571 mode: String,
572 source: Option<String>,
573 since: Option<String>,
574 limit: i64,
575) -> Result<Value> {
576 let router = ctx.router();
577 let (healthy, seed_errors) = router.resolve_all();
578 let deadline = router.search_deadline();
579 let deadline_ms = deadline.as_millis() as u64;
580
581 let items: Vec<(String, Arc<Config>)> = healthy
582 .iter()
583 .map(|rt| (rt.id.clone(), rt.config.clone()))
584 .collect();
585
586 let outcomes = fan_out(
587 items,
588 deadline,
589 FAN_OUT_CONCURRENCY,
590 move |config: Arc<Config>| {
591 let query = query.clone();
592 let mode = mode.clone();
593 let source = source.clone();
594 let since = since.clone();
595 async move {
596 search_documents(
597 &config,
598 &query,
599 &mode,
600 source.as_deref(),
601 since.as_deref(),
602 Some(limit),
603 false,
604 )
605 .await
606 }
607 },
608 )
609 .await;
610
611 let mut groups: Vec<Value> = Vec::new();
612 let mut error_pairs: Vec<(String, Value)> = seed_errors
613 .iter()
614 .map(|(id, err)| (id.clone(), error_entry(id, err)))
615 .collect();
616
617 for (id, outcome) in outcomes {
618 match outcome {
619 FanOut::Ok(results) => groups.push(search_group(&id, results)),
620 FanOut::Failed(msg) => error_pairs.push((
621 id.clone(),
622 error_entry(
623 &id,
624 &RouterError::WorkspaceUnavailable {
625 id: id.clone(),
626 reason: msg,
627 },
628 ),
629 )),
630 FanOut::TimedOut => error_pairs.push((
631 id.clone(),
632 error_entry(
633 &id,
634 &RouterError::WorkspaceTimeout {
635 id: id.clone(),
636 deadline_ms,
637 },
638 ),
639 )),
640 }
641 }
642
643 // errors[] follows the stable registry order (DESIGN-0008).
644 let ws_order: Vec<String> = ctx.router().list().iter().map(|rt| rt.id.clone()).collect();
645 error_pairs.sort_by_key(|(id, _)| ws_order.iter().position(|w| w == id).unwrap_or(usize::MAX));
646 let errors: Vec<Value> = error_pairs.into_iter().map(|(_, v)| v).collect();
647
648 Ok(serde_json::json!({ "results": groups, "errors": errors }))
649}
650
651/// Multi-workspace `search`: resolves the `workspace` selector and groups results.
652pub struct RoutedSearchTool;
653
654#[async_trait]
655impl Tool for RoutedSearchTool {
656 fn name(&self) -> &str {
657 "search"
658 }
659
660 fn description(&self) -> &str {
661 "Search the knowledge base (optionally scoped to a workspace)"
662 }
663
664 fn is_builtin(&self) -> bool {
665 true
666 }
667
668 fn parameters_schema(&self) -> Value {
669 schema_with_workspace_selector(SearchTool.parameters_schema())
670 }
671
672 async fn execute(&self, params: Value, ctx: &ToolContext) -> Result<Value> {
673 let query = params["query"].as_str().unwrap_or("");
674 if query.trim().is_empty() {
675 anyhow::bail!("query must not be empty");
676 }
677 let mode = params["mode"].as_str().unwrap_or("keyword");
678 let limit = params["limit"].as_i64().unwrap_or(12);
679 let source = params
680 .get("filters")
681 .and_then(|f| f.get("source"))
682 .and_then(|s| s.as_str());
683 let since = params
684 .get("filters")
685 .and_then(|f| f.get("since"))
686 .and_then(|s| s.as_str());
687
688 let selector = params["workspace"].as_str();
689 if selector == Some(ALL_SELECTOR) {
690 return search_all(
691 ctx,
692 query.to_string(),
693 mode.to_string(),
694 source.map(str::to_string),
695 since.map(str::to_string),
696 limit,
697 )
698 .await;
699 }
700 let runtime = ctx.router().resolve(selector)?;
701
702 let results = search_documents(
703 &runtime.config,
704 query,
705 mode,
706 source,
707 since,
708 Some(limit),
709 false,
710 )
711 .await?;
712 Ok(shape_grouped_search(&runtime.id, results))
713 }
714}
715
716/// Multi-workspace `get`: supports a qualified id (`<ws>:<doc>`) or an explicit
717/// `workspace` selector, with conflict detection (R38–R43).
718pub struct RoutedGetTool;
719
720#[async_trait]
721impl Tool for RoutedGetTool {
722 fn name(&self) -> &str {
723 "get"
724 }
725
726 fn description(&self) -> &str {
727 "Retrieve a document by id or qualified id (<workspace>:<id>)"
728 }
729
730 fn is_builtin(&self) -> bool {
731 true
732 }
733
734 fn parameters_schema(&self) -> Value {
735 schema_with_workspace_selector(GetTool.parameters_schema())
736 }
737
738 async fn execute(&self, params: Value, ctx: &ToolContext) -> Result<Value> {
739 let id = params["id"].as_str().unwrap_or("");
740 if id.trim().is_empty() {
741 anyhow::bail!("id must not be empty");
742 }
743 let field = params["workspace"].as_str();
744 let (qualified_prefix, raw_id) = ctx.router().split_qualified_id(id);
745
746 // A qualified id and an explicit workspace field must agree (R41/R42).
747 if let (Some(q), Some(f)) = (qualified_prefix, field) {
748 if q != f {
749 return Err(RouterError::WorkspaceIdConflict {
750 field: f.to_string(),
751 qualified: q.to_string(),
752 }
753 .into());
754 }
755 }
756
757 let selector = qualified_prefix.or(field);
758 let runtime = ctx.router().resolve(selector)?;
759
760 let doc = get_document(&runtime.config, raw_id).await?;
761 let mut obj = serde_json::to_value(&doc)?;
762 if let Value::Object(map) = &mut obj {
763 map.insert("workspace".to_string(), Value::String(runtime.id.clone()));
764 map.insert(
765 "qualified_id".to_string(),
766 Value::String(format!("{}:{}", runtime.id, raw_id)),
767 );
768 }
769 Ok(obj)
770 }
771}
772
773/// Multi-workspace `sources`: connector status for one workspace, grouped and
774/// redacted (R44/R48/R49).
775pub struct RoutedSourcesTool;
776
777#[async_trait]
778impl Tool for RoutedSourcesTool {
779 fn name(&self) -> &str {
780 "sources"
781 }
782
783 fn description(&self) -> &str {
784 "List connector status for a workspace (optionally scoped to a workspace)"
785 }
786
787 fn is_builtin(&self) -> bool {
788 true
789 }
790
791 fn parameters_schema(&self) -> Value {
792 schema_with_workspace_selector(SourcesTool.parameters_schema())
793 }
794
795 async fn execute(&self, params: Value, ctx: &ToolContext) -> Result<Value> {
796 let selector = params["workspace"].as_str();
797 if selector == Some(ALL_SELECTOR) {
798 let (healthy, seed_errors) = ctx.router().resolve_all();
799 let results: Vec<Value> = healthy
800 .iter()
801 .map(|rt| sources_group(&rt.id, get_sources(&rt.config)))
802 .collect();
803 let errors: Vec<Value> = seed_errors
804 .iter()
805 .map(|(id, err)| error_entry(id, err))
806 .collect();
807 return Ok(serde_json::json!({ "results": results, "errors": errors }));
808 }
809 let runtime = ctx.router().resolve(selector)?;
810 let sources = get_sources(&runtime.config);
811 Ok(serde_json::json!({
812 "results": [ sources_group(&runtime.id, sources) ],
813 "errors": []
814 }))
815 }
816}
817
818/// Multi-workspace discovery tool: lists registered workspaces, their health,
819/// and which is the default — without exposing any secret config values (R46–R49).
820pub struct WorkspacesTool;
821
822#[async_trait]
823impl Tool for WorkspacesTool {
824 fn name(&self) -> &str {
825 "workspaces"
826 }
827
828 fn description(&self) -> &str {
829 "List registered workspaces, their health, and the default workspace"
830 }
831
832 fn is_builtin(&self) -> bool {
833 true
834 }
835
836 fn parameters_schema(&self) -> Value {
837 serde_json::json!({ "type": "object", "properties": {} })
838 }
839
840 async fn execute(&self, _params: Value, ctx: &ToolContext) -> Result<Value> {
841 let router = ctx.router();
842 let default = router.default_id();
843 let workspaces: Vec<Value> = router
844 .list()
845 .iter()
846 .map(|rt| {
847 let health = match &rt.health {
848 crate::workspace::WorkspaceHealth::Ok => serde_json::json!({ "status": "ok" }),
849 crate::workspace::WorkspaceHealth::Unavailable(reason) => {
850 serde_json::json!({ "status": "unavailable", "reason": reason })
851 }
852 };
853 serde_json::json!({
854 "id": rt.id,
855 "root": rt.root.as_ref().map(|p| p.display().to_string()),
856 "enabled": rt.enabled,
857 "default": Some(rt.id.as_str()) == default,
858 "resolution": rt.resolution.as_str(),
859 "health": health,
860 })
861 })
862 .collect();
863 Ok(serde_json::json!({ "workspaces": workspaces }))
864 }
865}
866
867// ═══════════════════════════════════════════════════════════════════════
868// Registries
869// ═══════════════════════════════════════════════════════════════════════
870
871/// Registry for connectors (built-in and custom).
872///
873/// Use [`ConnectorRegistry::from_config`] to create a registry pre-loaded
874/// with all built-in connectors from the config file, then optionally
875/// call [`register`](ConnectorRegistry::register) to add custom ones.
876///
877/// # Example
878///
879/// ```rust
880/// use context_harness::traits::ConnectorRegistry;
881///
882/// let mut connectors = ConnectorRegistry::new();
883/// // connectors.register(Box::new(MyConnector::new()));
884/// ```
885pub struct ConnectorRegistry {
886 connectors: Vec<Box<dyn Connector>>,
887}
888
889impl ConnectorRegistry {
890 /// Create an empty connector registry.
891 pub fn new() -> Self {
892 Self {
893 connectors: Vec::new(),
894 }
895 }
896
897 /// Create a registry pre-loaded with all built-in connectors from the config.
898 ///
899 /// This resolves all filesystem, git, S3, and script connector instances
900 /// from the TOML config and wraps them as trait objects.
901 pub fn from_config(config: &Config) -> Self {
902 use crate::connector_fs::FilesystemConnector;
903 use crate::connector_git::GitConnector;
904 use crate::connector_s3::S3Connector;
905 use crate::connector_script::ScriptConnector;
906
907 let mut registry = Self::new();
908
909 for (name, cfg) in &config.connectors.filesystem {
910 registry.register(Box::new(FilesystemConnector::new(
911 name.clone(),
912 cfg.clone(),
913 )));
914 }
915 for (name, cfg) in &config.connectors.git {
916 registry.register(Box::new(GitConnector::new(
917 name.clone(),
918 cfg.clone(),
919 config.db.path.clone(),
920 )));
921 }
922 for (name, cfg) in &config.connectors.s3 {
923 registry.register(Box::new(S3Connector::new(name.clone(), cfg.clone())));
924 }
925 for (name, cfg) in &config.connectors.script {
926 registry.register(Box::new(ScriptConnector::new(name.clone(), cfg.clone())));
927 }
928
929 registry
930 }
931
932 /// Register a connector.
933 pub fn register(&mut self, connector: Box<dyn Connector>) {
934 self.connectors.push(connector);
935 }
936
937 /// Get all registered connectors.
938 pub fn connectors(&self) -> &[Box<dyn Connector>] {
939 &self.connectors
940 }
941
942 /// Get connectors filtered by type (e.g. `"git"`, `"filesystem"`).
943 pub fn connectors_by_type(&self, connector_type: &str) -> Vec<&dyn Connector> {
944 self.connectors
945 .iter()
946 .filter(|c| c.connector_type() == connector_type)
947 .map(|c| c.as_ref())
948 .collect()
949 }
950
951 /// Find a specific connector by type and name.
952 pub fn find(&self, connector_type: &str, name: &str) -> Option<&dyn Connector> {
953 self.connectors
954 .iter()
955 .find(|c| c.connector_type() == connector_type && c.name() == name)
956 .map(|c| c.as_ref())
957 }
958
959 /// Check if the registry is empty.
960 pub fn is_empty(&self) -> bool {
961 self.connectors.is_empty()
962 }
963
964 /// Return the count of registered connectors.
965 pub fn len(&self) -> usize {
966 self.connectors.len()
967 }
968}
969
970impl Default for ConnectorRegistry {
971 fn default() -> Self {
972 Self::new()
973 }
974}
975
976/// Registry for tools (built-in, Lua, and custom Rust).
977///
978/// Use [`ToolRegistry::with_builtins`] to create a registry pre-loaded
979/// with the core `search`, `get`, and `sources` tools, then optionally
980/// call [`register`](ToolRegistry::register) to add custom ones.
981///
982/// # Example
983///
984/// ```rust
985/// use context_harness::traits::ToolRegistry;
986///
987/// let mut tools = ToolRegistry::with_builtins();
988/// // tools.register(Box::new(MyTool::new()));
989/// ```
990pub struct ToolRegistry {
991 tools: Vec<Box<dyn Tool>>,
992}
993
994impl ToolRegistry {
995 /// Create an empty tool registry.
996 pub fn new() -> Self {
997 Self { tools: Vec::new() }
998 }
999
1000 /// Create a tool registry pre-loaded with built-in tools (search, get, sources).
1001 pub fn with_builtins() -> Self {
1002 let mut registry = Self::new();
1003 registry.register(Box::new(SearchTool));
1004 registry.register(Box::new(GetTool));
1005 registry.register(Box::new(SourcesTool));
1006 registry
1007 }
1008
1009 /// Create a tool registry with the router-aware built-ins for
1010 /// multi-workspace mode: `search`/`get`/`sources` accept a `workspace`
1011 /// selector and the `workspaces` discovery tool is added. Workspace-local
1012 /// Lua/Rust tools are intentionally not registered in multi mode in Phase 1
1013 /// (SPEC-0014 R54).
1014 pub fn with_builtins_multi() -> Self {
1015 let mut registry = Self::new();
1016 registry.register(Box::new(RoutedSearchTool));
1017 registry.register(Box::new(RoutedGetTool));
1018 registry.register(Box::new(RoutedSourcesTool));
1019 registry.register(Box::new(WorkspacesTool));
1020 registry
1021 }
1022
1023 /// Register a tool.
1024 pub fn register(&mut self, tool: Box<dyn Tool>) {
1025 self.tools.push(tool);
1026 }
1027
1028 /// Get all registered tools.
1029 pub fn tools(&self) -> &[Box<dyn Tool>] {
1030 &self.tools
1031 }
1032
1033 /// Find a tool by name.
1034 pub fn find(&self, name: &str) -> Option<&dyn Tool> {
1035 self.tools
1036 .iter()
1037 .find(|t| t.name() == name)
1038 .map(|t| t.as_ref())
1039 }
1040
1041 /// Check if the registry is empty.
1042 pub fn is_empty(&self) -> bool {
1043 self.tools.is_empty()
1044 }
1045
1046 /// Return the count of registered tools.
1047 pub fn len(&self) -> usize {
1048 self.tools.len()
1049 }
1050}
1051
1052impl Default for ToolRegistry {
1053 fn default() -> Self {
1054 Self::new()
1055 }
1056}
1057
1058#[cfg(test)]
1059mod phase2_tests {
1060 use super::*;
1061 use crate::workspace::RouterError;
1062
1063 #[test]
1064 fn search_group_tags_items_with_workspace_and_qualified_id() {
1065 let item = SearchResultItem {
1066 id: "01ABC".to_string(),
1067 score: 0.5,
1068 title: Some("T".to_string()),
1069 source: "filesystem".to_string(),
1070 source_id: "f".to_string(),
1071 updated_at: "2026-01-01T00:00:00Z".to_string(),
1072 snippet: "snip".to_string(),
1073 source_url: None,
1074 explain: None,
1075 };
1076 let group = search_group("beta", vec![item]);
1077 assert_eq!(group["workspace"], "beta");
1078 let items = group["items"].as_array().unwrap();
1079 assert_eq!(items[0]["workspace"], "beta");
1080 assert_eq!(items[0]["qualified_id"], "beta:01ABC");
1081 }
1082
1083 #[test]
1084 fn error_entry_has_workspace_code_message() {
1085 let err = RouterError::WorkspaceTimeout {
1086 id: "beta".to_string(),
1087 deadline_ms: 5000,
1088 };
1089 let entry = error_entry("beta", &err);
1090 assert_eq!(entry["workspace"], "beta");
1091 assert_eq!(entry["code"], "workspace_timeout");
1092 assert!(entry["message"].as_str().unwrap().contains("5000"));
1093 }
1094}