Skip to main content

context_harness/agent_runtime/
tools.rs

1//! Read-only runtime adapters using the existing Tool/ToolRegistry and core
2//! retrieval implementation. No connector processes, embeddings or sidecars.
3use crate::{
4    config::Config,
5    sqlite_store::SqliteStore,
6    tool_binding::{self, ToolTrustClass},
7    traits::{GetTool, SearchTool, Tool, ToolContext, ToolRegistry},
8};
9use anyhow::{ensure, Context, Result};
10use async_trait::async_trait;
11use context_harness_core::{
12    search::{search, SearchParams, SearchRequest},
13    store::Store,
14};
15use serde::Deserialize;
16use serde_json::{json, Value};
17use sqlx::{
18    sqlite::{SqliteConnectOptions, SqlitePoolOptions},
19    SqlitePool,
20};
21
22#[derive(Deserialize)]
23#[serde(deny_unknown_fields)]
24struct Filters {
25    source: Option<String>,
26    since: Option<String>,
27}
28#[derive(Deserialize)]
29#[serde(deny_unknown_fields)]
30struct SearchArgs {
31    query: String,
32    #[serde(default = "keyword")]
33    mode: String,
34    #[serde(default = "limit")]
35    limit: i64,
36    filters: Option<Filters>,
37}
38fn keyword() -> String {
39    "keyword".into()
40}
41fn limit() -> i64 {
42    12
43}
44#[derive(Deserialize)]
45#[serde(deny_unknown_fields)]
46struct GetArgs {
47    id: String,
48}
49
50pub(super) fn validate(name: &str, arguments: &Value) -> Result<()> {
51    match name {
52        "search" => {
53            let args: SearchArgs = serde_json::from_value(arguments.clone())?;
54            ensure!(
55                !args.query.trim().is_empty() && args.query.len() <= 8192,
56                "invalid query"
57            );
58            ensure!(
59                args.mode == "keyword",
60                "runtime search supports keyword mode only"
61            );
62            ensure!(
63                (1..=100).contains(&args.limit),
64                "limit must be between 1 and 100"
65            );
66            if let Some(filters) = args.filters {
67                if let Some(source) = filters.source {
68                    ensure!(source.len() <= 1024, "source is too long");
69                }
70                if let Some(since) = filters.since {
71                    let date = chrono::NaiveDate::parse_from_str(&since, "%Y-%m-%d")?;
72                    ensure!(date.format("%Y-%m-%d").to_string() == since, "invalid date");
73                }
74            }
75        }
76        "get" => {
77            let args: GetArgs = serde_json::from_value(arguments.clone())?;
78            ensure!(
79                !args.id.trim().is_empty() && args.id.len() <= 128 && !args.id.contains(':'),
80                "invalid document id"
81            );
82        }
83        _ => anyhow::bail!("unsupported runtime tool"),
84    }
85    Ok(())
86}
87
88pub(super) async fn read_pool(config: &Config) -> Result<SqlitePool> {
89    Ok(SqlitePoolOptions::new()
90        .max_connections(2)
91        .connect_with(
92            SqliteConnectOptions::new()
93                .filename(&config.db.path)
94                .read_only(true)
95                .create_if_missing(false)
96                .pragma("query_only", "ON"),
97        )
98        .await?)
99}
100
101pub(super) async fn registry(config: &Config) -> Result<ToolRegistry> {
102    let pool = read_pool(config).await?;
103    let mut registry = ToolRegistry::new();
104    registry.register(Box::new(ReadSearch {
105        pool: pool.clone(),
106        candidate_limit: config.retrieval.candidate_k_keyword.clamp(1, 1000),
107    }));
108    registry.register(Box::new(ReadGet { pool }));
109    Ok(registry)
110}
111
112/// Build the built-in declaration registry without opening the database.
113/// The lazy pool is metadata-only and cannot issue I/O unless a tool executes.
114pub(super) fn metadata_registry(config: &Config) -> Result<ToolRegistry> {
115    let pool = SqlitePoolOptions::new()
116        .max_connections(2)
117        .connect_lazy_with(
118            SqliteConnectOptions::new()
119                .filename(&config.db.path)
120                .read_only(true)
121                .create_if_missing(false)
122                .pragma("query_only", "ON"),
123        );
124    let mut registry = ToolRegistry::new();
125    registry.register(Box::new(ReadSearch {
126        pool: pool.clone(),
127        candidate_limit: config.retrieval.candidate_k_keyword.clamp(1, 1000),
128    }));
129    registry.register(Box::new(ReadGet { pool }));
130    Ok(registry)
131}
132
133struct ReadSearch {
134    pool: SqlitePool,
135    candidate_limit: i64,
136}
137#[async_trait]
138impl Tool for ReadSearch {
139    fn capabilities(&self) -> Option<Vec<crate::agent_resource::Capability>> {
140        Some(vec![crate::agent_resource::Capability::ReadOnly])
141    }
142    fn binding_metadata(&self) -> Option<Value> {
143        Some(tool_binding::compatibility_binding_metadata(
144            self.name(),
145            "builtin.compat.search",
146            self.parameters_schema(),
147            self.capabilities().unwrap(),
148            ToolTrustClass::Builtin,
149        ))
150    }
151    fn name(&self) -> &str {
152        "search"
153    }
154    fn description(&self) -> &str {
155        "Search this workspace's indexed context using local keywords"
156    }
157    fn is_builtin(&self) -> bool {
158        true
159    }
160    fn parameters_schema(&self) -> Value {
161        let mut schema = SearchTool.parameters_schema();
162        schema["additionalProperties"] = json!(false);
163        schema["properties"]["mode"]["enum"] = json!(["keyword"]);
164        schema["properties"]["limit"]["minimum"] = json!(1);
165        schema["properties"]["limit"]["maximum"] = json!(100);
166        schema["properties"]["filters"]["additionalProperties"] = json!(false);
167        schema
168    }
169    fn validate_arguments(&self, arguments: &Value) -> Result<()> {
170        validate(self.name(), arguments)
171    }
172    async fn execute(&self, arguments: Value, _ctx: &ToolContext) -> Result<Value> {
173        validate("search", &arguments)?;
174        let args: SearchArgs = serde_json::from_value(arguments)?;
175        let filters = args.filters.as_ref();
176        let request = SearchRequest {
177            query: &args.query,
178            query_vec: None,
179            mode: "keyword",
180            source_filter: filters.and_then(|f| f.source.as_deref()),
181            since: filters.and_then(|f| f.since.as_deref()),
182            explain: false,
183            params: SearchParams {
184                hybrid_alpha: 0.0,
185                candidate_k_keyword: self.candidate_limit,
186                candidate_k_vector: 0,
187                final_limit: args.limit,
188            },
189        };
190        Ok(json!({"results": search(&SqliteStore::new(self.pool.clone()), &request).await?}))
191    }
192}
193struct ReadGet {
194    pool: SqlitePool,
195}
196#[async_trait]
197impl Tool for ReadGet {
198    fn capabilities(&self) -> Option<Vec<crate::agent_resource::Capability>> {
199        Some(vec![crate::agent_resource::Capability::ReadOnly])
200    }
201    fn binding_metadata(&self) -> Option<Value> {
202        Some(tool_binding::compatibility_binding_metadata(
203            self.name(),
204            "builtin.compat.get",
205            self.parameters_schema(),
206            self.capabilities().unwrap(),
207            ToolTrustClass::Builtin,
208        ))
209    }
210    fn name(&self) -> &str {
211        "get"
212    }
213    fn description(&self) -> &str {
214        "Retrieve an indexed document from this workspace"
215    }
216    fn is_builtin(&self) -> bool {
217        true
218    }
219    fn parameters_schema(&self) -> Value {
220        let mut schema = GetTool.parameters_schema();
221        schema["additionalProperties"] = json!(false);
222        schema
223    }
224    fn validate_arguments(&self, arguments: &Value) -> Result<()> {
225        validate(self.name(), arguments)
226    }
227    async fn execute(&self, arguments: Value, _ctx: &ToolContext) -> Result<Value> {
228        validate("get", &arguments)?;
229        let args: GetArgs = serde_json::from_value(arguments)?;
230        let document = SqliteStore::new(self.pool.clone())
231            .get_document(&args.id)
232            .await?
233            .context("document not found")?;
234        Ok(serde_json::to_value(document)?)
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    #[test]
242    fn rejects_unsafe_or_malformed_arguments() {
243        for args in [
244            json!({"query":"x", "mode":"semantic"}),
245            json!({"query":"x", "limit":-1}),
246            json!({"query":"x", "limit":"1"}),
247            json!({"query":"x", "workspace":"other"}),
248            json!({"query":"x", "filters":{"since":"2026-02-30"}}),
249        ] {
250            assert!(validate("search", &args).is_err());
251        }
252        assert!(validate("get", &json!({"id":"other:doc"})).is_err());
253        assert!(validate("get", &json!({"id":"doc", "workspace":"other"})).is_err());
254    }
255    #[tokio::test]
256    async fn retrieval_cannot_create_a_missing_database() {
257        let tmp = tempfile::TempDir::new().unwrap();
258        let mut config = Config::minimal();
259        config.db.path = tmp.path().join("absent.sqlite");
260        assert!(registry(&config).await.is_err());
261        assert!(!config.db.path.exists());
262    }
263}