saya-cli 0.4.1

Database-aware AI agent for the terminal: full-screen TUI, schema discovery, and bounded read-only SQL over PostgreSQL, MySQL, SQLite, DuckDB, and Snowflake.
//! Dispatches a read-only agent tool call to its connection and records an
//! observation of what it touched. The recording lives here, beside the
//! dispatch, because this is the only place that knows the tool name, the SQL,
//! the target connection, and the result together (spec §3).

use saya_agent::ToolError;

use super::DatabaseTools;
use super::definitions::validate_arguments;
use super::observations::{ObservationOutcome, ToolObservation};

impl DatabaseTools {
    /// Dispatches a read-only agent tool call to its selected connection.
    //
    // `pub(in crate::agent::tools)` so the sibling `executor` module (which
    // implements `ToolExecutor for DatabaseTools`) can call it; this matches the
    // visibility the method had when it lived directly in `mod.rs`.
    pub(in crate::agent::tools) async fn execute_read_only(
        &self,
        name: &str,
        arguments: serde_json::Value,
    ) -> Result<serde_json::Value, ToolError> {
        // Contract tools have their own argument validation and execution
        // (sibling concern) and never reach a connector; route them before the
        // database-tool validation, which would reject their names.
        //
        // Contract tool calls record NOTHING. An observation about reading
        // memory is not evidence about a database object, and recording it would
        // let memory reinforce itself (spec §3). This early return is the whole
        // of that rule: no recording, ever, for `contract_search`/`contract_read`.
        if matches!(name, "contract_search" | "contract_read") {
            return self.execute_contract_tool(name, arguments).await;
        }
        validate_arguments(name, &arguments)?;
        // The workspace tools are dispatched before connection resolution:
        // they read or write the run's workspace directory, not a database,
        // and a workspace-only run has no selected profile for
        // `registry.resolve` to fail on. Like the contract tools they record
        // NOTHING — an observation about a workspace file or path is not
        // evidence about a database object. (`workspace_write` reaches this
        // routing only when the loop's permit gate already allowed it; the
        // routing decides where it runs, never whether.)
        if matches!(
            name,
            "workspace_read"
                | "workspace_list"
                | "workspace_write"
                | "workspace_edit"
                | "glob"
                | "grep"
        ) {
            return match name {
                "workspace_read" => self.workspace_read(&arguments).await,
                "workspace_list" => self.workspace_list(&arguments).await,
                "workspace_write" => self.workspace_write(&arguments).await,
                "workspace_edit" => self.workspace_edit(&arguments).await,
                "glob" => self.workspace_glob(&arguments).await,
                "grep" => self.workspace_grep(&arguments).await,
                _ => Err(ToolError::UnsupportedTool),
            };
        }
        if matches!(
            name,
            "bounded_sql_query"
                | "bounded_sql_query_all"
                | "render_chart"
                | "result_shape"
                | "column_health"
                | "join_check"
        ) && !self.allow_query_data
        {
            // The data-sharing gate refuses a query tool before it touches a
            // database. This is the denial reachable from saya-cli: the agent
            // loop's user-approval denial lives in saya-agent and never reaches
            // this executor, so this is the boundary that records `Denied`.
            // A denied call leaves no positive evidence: no objects, no rows.
            self.record_denied(name);
            return Err(ToolError::DataSharingDisabled);
        }
        if name == "bounded_sql_query_all" {
            let sql = arguments
                .get("sql")
                .and_then(serde_json::Value::as_str)
                .ok_or(ToolError::InvalidQueryArguments)?;
            return self.query_all(sql).await;
        }
        let connection = arguments
            .get("connection")
            .and_then(serde_json::Value::as_str);
        let entry = self.registry.resolve(connection)?;
        match name {
            "schema_discovery" => {
                let profile_id = entry.profile_id.as_deref();
                let result = crate::agent::state_tools::schema(
                    entry.connector.as_ref(),
                    self.state_db.as_ref(),
                    profile_id,
                )
                .await;
                if let Some(log) = &self.observations {
                    log.record_schema(name, profile_id, result.is_ok());
                }
                result
            }
            "bounded_sql_query" => {
                let sql = arguments
                    .get("sql")
                    .and_then(serde_json::Value::as_str)
                    .ok_or(ToolError::InvalidQueryArguments)?;
                // A1: detect a confirmed claim this statement contradicts, from
                // the statement itself — independent of whether the query then
                // succeeds (the override is about the statement the model wrote).
                // Best-effort: a missing receipt/log or a fail-closed detector
                // records nothing; the turn is never failed by detection.
                self.detect_and_record_overrides(sql, entry.dialect);
                let result = crate::agent::state_tools::query(
                    entry.connector.as_ref(),
                    sql,
                    self.max_rows,
                    self.state_db.as_ref(),
                    entry.profile_id.as_deref(),
                )
                .await;
                if let Some(log) = &self.observations {
                    log.record_query(
                        name,
                        sql,
                        entry.dialect,
                        entry.profile_id.as_deref(),
                        result.as_ref().ok(),
                    );
                }
                result
            }
            "result_shape" => {
                let sql = arguments
                    .get("sql")
                    .and_then(serde_json::Value::as_str)
                    .ok_or(ToolError::InvalidQueryArguments)?;
                let result = self.result_shape(entry, sql).await;
                if let Some(log) = &self.observations {
                    // result_shape runs SQL but returns no rows; record it like
                    // a query so the objects it touched are still credited as
                    // evidence, reading row_count/truncated from the shape when
                    // it succeeded.
                    log.record_query(
                        name,
                        sql,
                        entry.dialect,
                        entry.profile_id.as_deref(),
                        result.as_ref().ok(),
                    );
                }
                result
            }
            "column_health" => {
                let sql = arguments
                    .get("sql")
                    .and_then(serde_json::Value::as_str)
                    .ok_or(ToolError::InvalidQueryArguments)?;
                let result = self.column_health(entry, sql).await;
                if let Some(log) = &self.observations {
                    log.record_query(
                        name,
                        sql,
                        entry.dialect,
                        entry.profile_id.as_deref(),
                        result.as_ref().ok(),
                    );
                }
                result
            }
            "join_check" => {
                let sql = arguments
                    .get("sql")
                    .and_then(serde_json::Value::as_str)
                    .ok_or(ToolError::InvalidQueryArguments)?;
                let result = self.join_check(entry, sql).await;
                if let Some(log) = &self.observations {
                    log.record_query(
                        name,
                        sql,
                        entry.dialect,
                        entry.profile_id.as_deref(),
                        result.as_ref().ok(),
                    );
                }
                result
            }
            "render_chart" => {
                let sql = arguments
                    .get("sql")
                    .and_then(serde_json::Value::as_str)
                    .ok_or(ToolError::InvalidQueryArguments)?;
                let result = self.render_chart(&arguments).await;
                if let Some(log) = &self.observations {
                    // render_chart runs SQL but returns a path, not rows; it has
                    // no row_count/truncated to record, so it records like a
                    // query with only the objects/columns it named.
                    log.record_query(
                        name,
                        sql,
                        entry.dialect,
                        entry.profile_id.as_deref(),
                        result.as_ref().ok(),
                    );
                }
                result
            }
            _ => Err(ToolError::UnsupportedTool),
        }
    }

    fn record_denied(&self, name: &str) {
        let Some(log) = &self.observations else {
            return;
        };
        log.record(ToolObservation {
            tool: name.into(),
            outcome: ObservationOutcome::Denied,
            profile: None,
            objects: Vec::new(),
            columns: Vec::new(),
            row_count: None,
            truncated: None,
            references_partial: false,
        });
    }
}