1use crate::api::{RedDBError, RedDBResult};
8use crate::json::{Map, Value as JsonValue};
9use crate::runtime::ai::ask_rql_planner::{validate_candidate, CandidateDisposition};
10use crate::runtime::query_request::ParamValue;
11use crate::runtime::statement_frame::EffectiveScope;
12use crate::runtime::{RedDBRuntime, RuntimeQueryResult};
13use reddb_types::Value;
14
15pub const CATALOG_TOOL: &str = "reddb_catalog";
16pub const DESCRIBE_COLLECTION_TOOL: &str = "reddb_describe_collection";
17pub const FIND_FIELD_TOOL: &str = "reddb_find_field";
18pub const QUERY_TOOL: &str = "reddb_query";
19pub const FINAL_QUERY_TOOL: &str = "reddb_final_query";
20
21pub(crate) const METADATA_ARGUMENT_BYTES_MAX: usize = 256;
22const ASK_QUERY_BYTES_MAX: usize = 32 * 1024;
23const ASK_QUERY_PARAMETER_BYTES_MAX: usize = 16 * 1024;
24pub(crate) const ASK_QUERY_PARAMETER_COUNT_MAX: usize = 64;
25const ASK_QUERY_PURPOSE_BYTES_MAX: usize = 512;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum ToolSurface {
29 Ask,
30 Mcp,
31 Acp,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum ToolEffect {
36 Read,
37 Dynamic,
38}
39
40#[derive(Debug, Clone, PartialEq)]
41pub struct RedToolDefinition {
42 pub name: &'static str,
43 pub description: &'static str,
44 pub command_id: &'static str,
45 pub effect: ToolEffect,
46 pub input_schema: JsonValue,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum QueryPhase {
51 Explore,
52 Final,
53}
54
55impl QueryPhase {
56 pub fn as_str(self) -> &'static str {
57 match self {
58 Self::Explore => "explore",
59 Self::Final => "final",
60 }
61 }
62}
63
64#[derive(Debug, Clone, PartialEq)]
65pub enum RedToolCall {
66 Catalog,
67 DescribeCollection {
68 collection: String,
69 },
70 FindField {
71 field: String,
72 },
73 Query {
74 rql: String,
75 params: Vec<JsonValue>,
76 purpose: String,
77 phase: QueryPhase,
78 },
79}
80
81impl RedToolCall {
82 pub fn name(&self) -> &'static str {
83 match self {
84 Self::Catalog => CATALOG_TOOL,
85 Self::DescribeCollection { .. } => DESCRIBE_COLLECTION_TOOL,
86 Self::FindField { .. } => FIND_FIELD_TOOL,
87 Self::Query {
88 phase: QueryPhase::Explore,
89 ..
90 } => QUERY_TOOL,
91 Self::Query {
92 phase: QueryPhase::Final,
93 ..
94 } => FINAL_QUERY_TOOL,
95 }
96 }
97
98 pub fn arguments_json(&self) -> JsonValue {
99 let mut arguments = Map::new();
100 match self {
101 Self::Catalog => {}
102 Self::DescribeCollection { collection } => {
103 arguments.insert(
104 "collection".to_string(),
105 JsonValue::String(collection.clone()),
106 );
107 }
108 Self::FindField { field } => {
109 arguments.insert("field".to_string(), JsonValue::String(field.clone()));
110 }
111 Self::Query {
112 rql,
113 params,
114 purpose,
115 phase,
116 } => {
117 arguments.insert("sql".to_string(), JsonValue::String(rql.clone()));
118 arguments.insert("params".to_string(), JsonValue::Array(params.clone()));
119 arguments.insert("purpose".to_string(), JsonValue::String(purpose.clone()));
120 arguments.insert(
121 "phase".to_string(),
122 JsonValue::String(phase.as_str().to_string()),
123 );
124 }
125 }
126 JsonValue::Object(arguments)
127 }
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum ToolParseMode {
132 AskNative,
133 AskLegacy,
134 Direct,
135}
136
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum ToolExecutionProfile {
139 Ask,
140 Direct,
141}
142
143fn string_schema(description: &str) -> JsonValue {
144 let mut schema = Map::new();
145 schema.insert("type".to_string(), JsonValue::String("string".to_string()));
146 schema.insert(
147 "description".to_string(),
148 JsonValue::String(description.to_string()),
149 );
150 JsonValue::Object(schema)
151}
152
153fn params_schema() -> JsonValue {
154 let mut item = Map::new();
155 item.insert(
156 "anyOf".to_string(),
157 JsonValue::Array(
158 ["null", "boolean", "number", "string", "array", "object"]
159 .into_iter()
160 .map(|kind| {
161 let mut schema = Map::new();
162 schema.insert("type".to_string(), JsonValue::String(kind.to_string()));
163 JsonValue::Object(schema)
164 })
165 .collect(),
166 ),
167 );
168 item.insert(
169 "description".to_string(),
170 JsonValue::String(
171 "Bind value for the matching $N placeholder. Accepts null, boolean, number, string, array, or object."
172 .to_string(),
173 ),
174 );
175 let mut schema = Map::new();
176 schema.insert("type".to_string(), JsonValue::String("array".to_string()));
177 schema.insert("items".to_string(), JsonValue::Object(item));
178 schema.insert(
179 "description".to_string(),
180 JsonValue::String(
181 "Positional bind values for `$1`, `$2`, ... in `sql`. Index 0 binds `$1`.".to_string(),
182 ),
183 );
184 JsonValue::Object(schema)
185}
186
187fn object_schema(properties: Vec<(&str, JsonValue)>, required: &[&str]) -> JsonValue {
188 let mut property_map = Map::new();
189 for (name, schema) in properties {
190 property_map.insert(name.to_string(), schema);
191 }
192 let mut schema = Map::new();
193 schema.insert("type".to_string(), JsonValue::String("object".to_string()));
194 schema.insert("properties".to_string(), JsonValue::Object(property_map));
195 schema.insert(
196 "required".to_string(),
197 JsonValue::Array(
198 required
199 .iter()
200 .map(|name| JsonValue::String((*name).to_string()))
201 .collect(),
202 ),
203 );
204 schema.insert("additionalProperties".to_string(), JsonValue::Bool(false));
205 JsonValue::Object(schema)
206}
207
208fn query_schema(require_agent_fields: bool) -> JsonValue {
209 let sql_description = if require_agent_fields {
210 "Read-only SQL or RQL with $N placeholders for every data value"
211 } else {
212 "SQL or universal query to execute. Use `$1`, `$2`, ... placeholders for any value that came from the user."
213 };
214 let mut properties = vec![
215 ("sql", string_schema(sql_description)),
216 ("params", params_schema()),
217 ];
218 if require_agent_fields {
219 properties.push((
220 "purpose",
221 string_schema("One short auditable sentence explaining this query"),
222 ));
223 }
224 if require_agent_fields {
225 object_schema(properties, &["sql", "params", "purpose"])
226 } else {
227 object_schema(properties, &["sql"])
228 }
229}
230
231pub fn definitions(surface: ToolSurface) -> Vec<RedToolDefinition> {
233 let agent_surface = matches!(surface, ToolSurface::Ask | ToolSurface::Acp);
234 let (
235 catalog_description,
236 describe_description,
237 find_description,
238 query_description,
239 ) = match surface {
240 ToolSurface::Ask => (
241 "List a bounded summary of collections visible to the asking user",
242 "Describe columns of one collection visible to the asking user",
243 "Find visible collections containing an exact field name",
244 "Run one bounded exploratory read under the asking user's access",
245 ),
246 ToolSurface::Mcp => (
247 "List visible collections with model, schema, size, index, and attention metadata. Remote calls execute under the connected user's tenant and policies.",
248 "Describe the fields visible on one collection using the canonical red.columns catalog. The database applies the connected user's tenant and policies before returning metadata.",
249 "Find every visible collection with an exact field name using the canonical red.columns catalog. Useful before a global SELECT when the user names an attribute such as passport_number. Tenant and policy filtering is enforced by the database.",
250 "Execute a SQL or universal query against RedDB. Supports SELECT, INSERT, UPDATE, DELETE, and graph queries (Gremlin, Cypher, SPARQL).\n\nALWAYS pass user-provided values via the `params` array using `$1`, `$2`, ... placeholders rather than interpolating them into the SQL string. Example: `{\"sql\": \"SELECT * FROM users WHERE id = $1\", \"params\": [42]}`. Interpolating user input directly is unsafe and brittle; the parameterized form is type-checked and immune to injection.",
251 ),
252 ToolSurface::Acp => (
253 "List a bounded summary of collections visible to the connected user",
254 "Describe columns of one collection visible to the connected user",
255 "Find visible collections containing an exact field name",
256 "Run one bounded read-only query under the connected user's access",
257 ),
258 };
259 let query_effect = if agent_surface {
260 ToolEffect::Read
261 } else {
262 ToolEffect::Dynamic
263 };
264 let mut tools = vec![
265 RedToolDefinition {
266 name: CATALOG_TOOL,
267 description: catalog_description,
268 command_id: "query.execute",
269 effect: ToolEffect::Read,
270 input_schema: object_schema(Vec::new(), &[]),
271 },
272 RedToolDefinition {
273 name: DESCRIBE_COLLECTION_TOOL,
274 description: describe_description,
275 command_id: "query.execute",
276 effect: ToolEffect::Read,
277 input_schema: object_schema(
278 vec![(
279 "collection",
280 string_schema("Exact collection name from authorized metadata"),
281 )],
282 &["collection"],
283 ),
284 },
285 RedToolDefinition {
286 name: FIND_FIELD_TOOL,
287 description: find_description,
288 command_id: "query.execute",
289 effect: ToolEffect::Read,
290 input_schema: object_schema(
291 vec![("field", string_schema("Exact field name to locate"))],
292 &["field"],
293 ),
294 },
295 RedToolDefinition {
296 name: QUERY_TOOL,
297 description: query_description,
298 command_id: "query.execute",
299 effect: query_effect,
300 input_schema: query_schema(agent_surface),
301 },
302 ];
303 if surface == ToolSurface::Ask {
304 tools.push(RedToolDefinition {
305 name: FINAL_QUERY_TOOL,
306 description: "Provide the terminal read-only query whose rows answer the user",
307 command_id: "query.execute",
308 effect: ToolEffect::Read,
309 input_schema: query_schema(true),
310 });
311 }
312 tools
313}
314
315pub fn definition(surface: ToolSurface, name: &str) -> Option<RedToolDefinition> {
316 definitions(surface)
317 .into_iter()
318 .find(|definition| definition.name == name)
319}
320
321pub fn parse_call(
323 name: &str,
324 arguments: &JsonValue,
325 mode: ToolParseMode,
326) -> RedDBResult<RedToolCall> {
327 let arguments = arguments.as_object().ok_or_else(|| {
328 RedDBError::Query(format!("tool `{name}` arguments must be a JSON object"))
329 })?;
330 match name {
331 CATALOG_TOOL => Ok(RedToolCall::Catalog),
332 DESCRIBE_COLLECTION_TOOL => Ok(RedToolCall::DescribeCollection {
333 collection: bounded_string(arguments, "collection", METADATA_ARGUMENT_BYTES_MAX)?,
334 }),
335 FIND_FIELD_TOOL => Ok(RedToolCall::FindField {
336 field: bounded_string(arguments, "field", METADATA_ARGUMENT_BYTES_MAX)?,
337 }),
338 QUERY_TOOL | FINAL_QUERY_TOOL => {
339 let ask_mode = !matches!(mode, ToolParseMode::Direct);
340 let sql_limit = if ask_mode {
341 ASK_QUERY_BYTES_MAX
342 } else {
343 usize::MAX
344 };
345 let rql = bounded_string(arguments, "sql", sql_limit)?;
346 let params = parse_params(arguments, ask_mode)?;
347 let purpose =
348 optional_bounded_string(arguments, "purpose", ASK_QUERY_PURPOSE_BYTES_MAX)?
349 .unwrap_or_else(|| match mode {
350 ToolParseMode::Direct => {
351 "Execute the caller's parameterized query".to_string()
352 }
353 ToolParseMode::AskNative | ToolParseMode::AskLegacy => {
354 "Answer the user's question from authorized rows".to_string()
355 }
356 });
357 let phase = match mode {
358 ToolParseMode::AskNative if name == QUERY_TOOL => QueryPhase::Explore,
359 ToolParseMode::AskNative => QueryPhase::Final,
360 ToolParseMode::AskLegacy if name == FINAL_QUERY_TOOL => QueryPhase::Final,
361 ToolParseMode::AskLegacy => {
362 match arguments
363 .get("phase")
364 .and_then(JsonValue::as_str)
365 .unwrap_or("final")
366 {
367 "explore" => QueryPhase::Explore,
368 "final" => QueryPhase::Final,
369 other => {
370 return Err(RedDBError::Query(format!(
371 "tool query phase must be `explore` or `final`, got `{other}`"
372 )))
373 }
374 }
375 }
376 ToolParseMode::Direct => QueryPhase::Explore,
377 };
378 Ok(RedToolCall::Query {
379 rql,
380 params,
381 purpose,
382 phase,
383 })
384 }
385 other => Err(RedDBError::Query(format!("unsupported tool `{other}`"))),
386 }
387}
388
389fn optional_bounded_string(
390 arguments: &Map<String, JsonValue>,
391 name: &str,
392 bytes_max: usize,
393) -> RedDBResult<Option<String>> {
394 let Some(value) = arguments.get(name) else {
395 return Ok(None);
396 };
397 let value = value
398 .as_str()
399 .ok_or_else(|| RedDBError::Query(format!("tool argument `{name}` must be a string")))?;
400 let value = value.trim();
401 if value.is_empty() {
402 return Ok(None);
403 }
404 if value.len() > bytes_max {
405 return Err(RedDBError::Query(format!(
406 "tool argument `{name}` exceeds {bytes_max} bytes"
407 )));
408 }
409 Ok(Some(value.to_string()))
410}
411
412fn bounded_string(
413 arguments: &Map<String, JsonValue>,
414 name: &str,
415 bytes_max: usize,
416) -> RedDBResult<String> {
417 optional_bounded_string(arguments, name, bytes_max)?
418 .ok_or_else(|| RedDBError::Query(format!("tool is missing string argument `{name}`")))
419}
420
421fn parse_params(arguments: &Map<String, JsonValue>, bounded: bool) -> RedDBResult<Vec<JsonValue>> {
422 let Some(value) = arguments.get("params") else {
423 return Ok(Vec::new());
424 };
425 let params = value
426 .as_array()
427 .ok_or_else(|| RedDBError::Query("tool `params` must be an array".to_string()))?;
428 if bounded && params.len() > ASK_QUERY_PARAMETER_COUNT_MAX {
429 return Err(RedDBError::Query(format!(
430 "tool `params` exceeds {ASK_QUERY_PARAMETER_COUNT_MAX} values"
431 )));
432 }
433 if bounded {
434 let encoded = crate::json::to_vec(value).map_err(|error| {
435 RedDBError::Query(format!("tool `params` cannot be serialized: {error}"))
436 })?;
437 if encoded.len() > ASK_QUERY_PARAMETER_BYTES_MAX {
438 return Err(RedDBError::Query(format!(
439 "tool `params` exceeds {ASK_QUERY_PARAMETER_BYTES_MAX} bytes"
440 )));
441 }
442 }
443 for param in params {
444 ParamValue::decode_json(param)
445 .map_err(|error| RedDBError::Query(format!("invalid tool parameter: {error}")))?;
446 }
447 Ok(params.to_vec())
448}
449
450fn params_to_values(params: &[JsonValue]) -> RedDBResult<Vec<Value>> {
451 params
452 .iter()
453 .map(ParamValue::decode_json)
454 .collect::<Result<Vec<_>, _>>()
455 .map(|params| params.into_iter().map(Value::from).collect())
456 .map_err(RedDBError::Query)
457}
458
459pub struct RedToolExecutor<'a> {
462 runtime: &'a RedDBRuntime,
463 scope: &'a EffectiveScope,
464 profile: ToolExecutionProfile,
465}
466
467impl<'a> RedToolExecutor<'a> {
468 pub fn new(
469 runtime: &'a RedDBRuntime,
470 scope: &'a EffectiveScope,
471 profile: ToolExecutionProfile,
472 ) -> Self {
473 Self {
474 runtime,
475 scope,
476 profile,
477 }
478 }
479
480 pub fn execute(&self, call: &RedToolCall) -> RedDBResult<RuntimeQueryResult> {
481 let _scope_guard =
482 crate::runtime::ask_pipeline::AskScopeGuard::install(self.runtime, self.scope);
483 match call {
484 RedToolCall::Catalog => self.execute_query(
485 match self.profile {
486 ToolExecutionProfile::Ask => {
487 "SELECT name, model, schema_mode, entities, indices FROM red.collections \
488 WHERE internal = false ORDER BY name LIMIT 128"
489 }
490 ToolExecutionProfile::Direct => "SHOW COLLECTIONS",
491 },
492 &[],
493 ),
494 RedToolCall::DescribeCollection { collection } => self.execute_query(
495 match self.profile {
496 ToolExecutionProfile::Ask => {
497 "SELECT collection, name, type, nullable, default_value, is_primary_key, \
498 is_unique FROM red.columns WHERE collection = $1 ORDER BY name LIMIT 128"
499 }
500 ToolExecutionProfile::Direct => {
501 "SELECT collection, name, type, nullable, default_value, is_primary_key, \
502 is_unique FROM red.columns WHERE collection = $1 ORDER BY name"
503 }
504 },
505 &[Value::text(collection.clone())],
506 ),
507 RedToolCall::FindField { field } => self.execute_query(
508 match self.profile {
509 ToolExecutionProfile::Ask => {
510 "SELECT collection, name, type, nullable FROM red.columns WHERE name = $1 \
511 ORDER BY collection, name LIMIT 128"
512 }
513 ToolExecutionProfile::Direct => {
514 "SELECT collection, name, type, nullable FROM red.columns WHERE name = $1 \
515 ORDER BY collection, name"
516 }
517 },
518 &[Value::text(field.clone())],
519 ),
520 RedToolCall::Query { rql, params, .. } => {
521 let rql = if self.profile == ToolExecutionProfile::Ask {
522 let candidate = validate_candidate(rql)?;
523 if candidate.disposition != CandidateDisposition::ReadOnly {
524 return Err(RedDBError::Query(
525 "ASK common query tool accepts read-only RQL only".to_string(),
526 ));
527 }
528 candidate.rql
529 } else {
530 rql.clone()
531 };
532 self.execute_query(&rql, ¶ms_to_values(params)?)
533 }
534 }
535 }
536
537 fn execute_query(&self, query: &str, params: &[Value]) -> RedDBResult<RuntimeQueryResult> {
538 if params.is_empty() {
539 self.runtime.execute_query(query)
540 } else {
541 self.runtime.execute_query_with_params(query, params)
542 }
543 }
544}
545
546#[cfg(test)]
547mod tests {
548 use super::*;
549
550 fn object(entries: Vec<(&str, JsonValue)>) -> JsonValue {
551 let mut map = Map::new();
552 for (name, value) in entries {
553 map.insert(name.to_string(), value);
554 }
555 JsonValue::Object(map)
556 }
557
558 #[test]
559 fn all_surfaces_share_the_common_read_tool_names() {
560 for name in [
561 CATALOG_TOOL,
562 DESCRIBE_COLLECTION_TOOL,
563 FIND_FIELD_TOOL,
564 QUERY_TOOL,
565 ] {
566 assert!(definition(ToolSurface::Ask, name).is_some(), "ASK: {name}");
567 assert!(definition(ToolSurface::Mcp, name).is_some(), "MCP: {name}");
568 assert!(definition(ToolSurface::Acp, name).is_some(), "ACP: {name}");
569 }
570 assert!(definition(ToolSurface::Ask, FINAL_QUERY_TOOL).is_some());
571 assert!(definition(ToolSurface::Mcp, FINAL_QUERY_TOOL).is_none());
572 assert!(definition(ToolSurface::Acp, FINAL_QUERY_TOOL).is_none());
573 }
574
575 #[test]
576 fn native_query_cannot_smuggle_the_final_phase() {
577 let call = parse_call(
578 QUERY_TOOL,
579 &object(vec![
580 ("sql", JsonValue::String("SELECT 1".to_string())),
581 ("params", JsonValue::Array(Vec::new())),
582 ("purpose", JsonValue::String("inspect".to_string())),
583 ("phase", JsonValue::String("final".to_string())),
584 ]),
585 ToolParseMode::AskNative,
586 )
587 .expect("native tool call");
588 assert!(matches!(
589 call,
590 RedToolCall::Query {
591 phase: QueryPhase::Explore,
592 ..
593 }
594 ));
595 }
596
597 #[test]
598 fn legacy_final_tool_cannot_smuggle_an_exploratory_phase() {
599 let call = parse_call(
600 FINAL_QUERY_TOOL,
601 &object(vec![
602 ("sql", JsonValue::String("SELECT 1".to_string())),
603 ("params", JsonValue::Array(Vec::new())),
604 ("purpose", JsonValue::String("answer".to_string())),
605 ("phase", JsonValue::String("explore".to_string())),
606 ]),
607 ToolParseMode::AskLegacy,
608 )
609 .expect("legacy final tool call");
610 assert!(matches!(
611 call,
612 RedToolCall::Query {
613 phase: QueryPhase::Final,
614 ..
615 }
616 ));
617 }
618}