Skip to main content

reddb_server/runtime/
agent_tools.rs

1//! Protocol-neutral RedDB tools shared by ASK, MCP, and ACP adapters.
2//!
3//! This module owns the canonical definitions, argument validation, and query
4//! execution for the common read/discovery surface. Protocol adapters decide
5//! which profile to expose and how to serialize [`RuntimeQueryResult`].
6
7use 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
231/// Canonical tool registry projected for one protocol/runtime surface.
232pub 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
321/// Parse and bound one call from any adapter into the common representation.
322pub 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
459/// Common execution authority. The captured scope is always reinstalled, so
460/// adapters cannot accidentally execute under a later ambient identity.
461pub 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, &params_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}