context_harness/agent_runtime/
tools.rs1use 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
112pub(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}