1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
//! 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,
});
}
}