salvor_server/client_tools.rs
1//! Client-performed tool declarations: what the operator says about a tool the
2//! CLIENT runs in its own process, and the registry a host loads them into.
3//!
4//! # Declared by the operator, implemented by the client
5//!
6//! A [`ClientToolDecl`] is a tool with no code behind it on this server. The
7//! operator declares its name, its [`Effect`], the shape of its input, the
8//! shape of its completion, and whether the client's word is good enough to
9//! close the call. The client is the one that actually performs the work, in
10//! its own process, with its own secrets. That is the whole point: a tool whose
11//! credential must never reach salvor can still be recorded in a salvor run.
12//!
13//! # Why declarations are never registered over HTTP
14//!
15//! They are loaded by `salvor serve --client-tool <FILE>` and by an embedding
16//! host through [`AppState::with_client_tools`](crate::AppState::with_client_tools),
17//! and there is deliberately no endpoint that accepts one.
18//!
19//! The reason is the effect class. The server-performed
20//! [`tool_step`](crate::client_runs::tool_step) already refuses to take the
21//! effect from the request body, so a caller cannot up- or down-grade a `Write`
22//! into a freely retried `Read`. A declaration carries an effect too. If a
23//! client could POST its own declaration it would be choosing its own effect
24//! class by the back door: declare the charge as a `Read`, and the write-ahead
25//! rule that makes an unsettled write surface for a human stops applying to it.
26//! Keeping declarations operator-side keeps the effect an operator's word in
27//! both surfaces, which is the invariant, not an implementation detail.
28//!
29//! # The format
30//!
31//! One TOML file per declaration, mirroring how `--agent` takes one agent file:
32//!
33//! ```toml
34//! name = "charge_card"
35//! effect = "write"
36//! trust_completion = false
37//!
38//! [input_schema]
39//! type = "object"
40//! required = ["amount_cents"]
41//!
42//! [input_schema.properties.amount_cents]
43//! type = "integer"
44//!
45//! [output_schema]
46//! type = "object"
47//! required = ["charge_id"]
48//!
49//! [output_schema.properties.charge_id]
50//! type = "string"
51//! ```
52//!
53//! The struct carries the `Deserialize` derive, so it defines the format; the
54//! CLI reads the bytes off disk and hands them to `toml`, exactly as it owns
55//! file reading for agent definitions. Nothing here touches the filesystem.
56
57use std::collections::HashMap;
58
59use axum::Json;
60use axum::extract::State;
61use axum::response::IntoResponse;
62use salvor_core::Effect;
63use serde::Deserialize;
64use serde_json::{Value, json};
65
66use crate::state::AppState;
67
68/// One operator-written declaration of a tool the CLIENT performs.
69///
70/// There is no handler behind it. It exists so this server can do the things it
71/// CAN honestly do about a call it never witnessed: fix the effect class, check
72/// the input before an intent is recorded, check the reported output against a
73/// shape the operator declared, pin named fields so a report cannot alter what
74/// was authorized, and decide whether the client's report is allowed to close
75/// the call at all.
76///
77/// Unknown keys are rejected rather than ignored. A misspelled key like
78/// `require_equal` would otherwise be dropped silently, leaving a guard the
79/// operator meant to set quietly absent, and the mistake would not surface until
80/// a client had already altered a field the operator meant to pin. Refusing
81/// early, precisely, is the rule.
82///
83/// The declaration deserializes through [`RawClientToolDecl`] so a
84/// cross-field rule the field-by-field format cannot express is enforced at
85/// load: every [`require_equal`](Self::require_equal) name must be required on
86/// both sides. A file that breaks it fails to parse, naming the field and the
87/// missing side.
88#[derive(Debug, Clone, Deserialize)]
89#[serde(try_from = "RawClientToolDecl")]
90pub struct ClientToolDecl {
91 /// The tool's name, the one a client names when it opens an intent.
92 pub name: String,
93 /// The operator-declared effect class, recorded on every intent for this
94 /// tool. Never taken from the client, for the reason in the module docs.
95 pub effect: Effect,
96 /// The schema an intent's input must satisfy, checked with
97 /// [`salvor_runtime::validate_against_schema`] before anything is written.
98 pub input_schema: Value,
99 /// The schema a client-reported completion must satisfy. Optional in the
100 /// format, because a declaration is still useful without one (the effect
101 /// and the input check both still apply), but a tool declared without it
102 /// cannot be self-completed by a client: an unfalsifiable completion is
103 /// precisely what the schema exists to prevent.
104 pub output_schema: Option<Value>,
105 /// Whether the client may record its own completion for this tool. `false`
106 /// by default: silence gets the safe direction, and self-completing a write
107 /// on the client's word alone is the convenient direction, so it is an
108 /// explicit opt-in. `false` means every call for this tool is settled by
109 /// hand through the resolve endpoint after someone has verified it
110 /// externally.
111 pub trust_completion: bool,
112 /// Top-level field names whose client-reported value must equal the intent's
113 /// recorded value. Empty by default. Every named field must appear in both
114 /// `input_schema.required` and `output_schema.required`, checked at load, so
115 /// the two values always exist to compare; at the completion boundary a
116 /// reported value that differs from the authorized one refuses the
117 /// completion. The output schema is a shape check and cannot know what was
118 /// authorized; this is the field-level equality the shape check cannot do.
119 pub require_equal: Vec<String>,
120}
121
122/// The on-disk shape of a [`ClientToolDecl`], before its cross-field rule is
123/// checked. Deserializing lands here first; [`TryFrom`] enforces the
124/// [`require_equal`](ClientToolDecl::require_equal) invariant and produces the
125/// public type, so a violating file fails to parse rather than loading a
126/// declaration whose completion boundary could not do the comparison it names.
127#[derive(Debug, Deserialize)]
128#[serde(deny_unknown_fields)]
129struct RawClientToolDecl {
130 name: String,
131 effect: Effect,
132 input_schema: Value,
133 #[serde(default)]
134 output_schema: Option<Value>,
135 /// Silence gets the safe direction: a declaration that says nothing about
136 /// trust may not self-complete.
137 #[serde(default)]
138 trust_completion: bool,
139 #[serde(default)]
140 require_equal: Vec<String>,
141}
142
143impl TryFrom<RawClientToolDecl> for ClientToolDecl {
144 type Error = String;
145
146 /// Enforces the load-time [`require_equal`](ClientToolDecl::require_equal)
147 /// rule: every named field must be present in both `input_schema.required`
148 /// and `output_schema.required`, so the value to compare always exists on
149 /// each side. A violation is refused here, naming the field and the side it
150 /// is missing from, exactly as an unknown key is refused: early and precise.
151 fn try_from(raw: RawClientToolDecl) -> Result<Self, Self::Error> {
152 for field in &raw.require_equal {
153 if !schema_requires(&raw.input_schema, field) {
154 return Err(missing_require_equal(&raw.name, field, "input_schema"));
155 }
156 let present_in_output = raw
157 .output_schema
158 .as_ref()
159 .is_some_and(|schema| schema_requires(schema, field));
160 if !present_in_output {
161 return Err(missing_require_equal(&raw.name, field, "output_schema"));
162 }
163 }
164 Ok(ClientToolDecl {
165 name: raw.name,
166 effect: raw.effect,
167 input_schema: raw.input_schema,
168 output_schema: raw.output_schema,
169 trust_completion: raw.trust_completion,
170 require_equal: raw.require_equal,
171 })
172 }
173}
174
175/// Whether `schema`'s `required` array lists `field`. A JSON Schema object with
176/// no `required`, or one whose `required` is not an array, requires nothing.
177fn schema_requires(schema: &Value, field: &str) -> bool {
178 schema
179 .get("required")
180 .and_then(Value::as_array)
181 .is_some_and(|required| required.iter().any(|name| name.as_str() == Some(field)))
182}
183
184/// The load-time refusal for a `require_equal` field absent from one side's
185/// `required` list, naming the tool, the field, and the side it is missing from.
186fn missing_require_equal(tool: &str, field: &str, side: &str) -> String {
187 format!(
188 "tool `{tool}` names `{field}` in require_equal, but `{field}` is not in {side}.required; a \
189 require_equal field must be required on both the input and the output side, so the two \
190 values always exist to compare"
191 )
192}
193
194/// The client-performed tool declarations a server was started with.
195///
196/// The counterpart of [`ToolRegistry`](crate::ToolRegistry), and deliberately a
197/// separate type: that one holds executable tools this server dispatches, this
198/// one holds declarations of tools it never runs. Merging them would put a
199/// `DynTool` with no implementation into the registry a graph `tool` node
200/// resolves through, and a graph node would then resolve a tool that cannot be
201/// called.
202///
203/// Empty is the default and is a complete, honest state: every client-tool
204/// intent is a clean `unknown_tool` until an operator declares one. There is no
205/// "no registry wired" case to distinguish, unlike the executable registry,
206/// because nothing is ever dispatched here.
207#[derive(Debug, Default, Clone)]
208pub struct ClientToolRegistry {
209 decls: HashMap<String, ClientToolDecl>,
210}
211
212impl ClientToolRegistry {
213 /// An empty set of declarations: the `salvor serve` default.
214 #[must_use]
215 pub fn new() -> Self {
216 Self {
217 decls: HashMap::new(),
218 }
219 }
220
221 /// Records `decl` under its own [`ClientToolDecl::name`], replacing any
222 /// declaration already held under that name, so a host composing a set
223 /// keeps the last word (the same rule [`ToolRegistry`](crate::ToolRegistry)
224 /// uses).
225 pub fn declare(&mut self, decl: ClientToolDecl) {
226 self.decls.insert(decl.name.clone(), decl);
227 }
228
229 /// Records `decl` and returns the registry, for the builder style a host
230 /// composes with.
231 #[must_use]
232 pub fn with_decl(mut self, decl: ClientToolDecl) -> Self {
233 self.declare(decl);
234 self
235 }
236
237 /// The declaration held under `name`, if any. `None` is the `unknown_tool`
238 /// case the client-tool intent endpoint reports without writing anything.
239 #[must_use]
240 pub fn get(&self, name: &str) -> Option<&ClientToolDecl> {
241 self.decls.get(name)
242 }
243
244 /// Whether no declarations are held (the `salvor serve` default).
245 #[must_use]
246 pub fn is_empty(&self) -> bool {
247 self.decls.is_empty()
248 }
249
250 /// How many declarations are held.
251 #[must_use]
252 pub fn len(&self) -> usize {
253 self.decls.len()
254 }
255
256 /// Every declared name, sorted, for a stable listing in a log line or an
257 /// operator-facing report.
258 #[must_use]
259 pub fn names(&self) -> Vec<String> {
260 let mut names: Vec<String> = self.decls.keys().cloned().collect();
261 names.sort();
262 names
263 }
264}
265
266/// `GET /v1/client-tools`: every client-performed tool declaration this server
267/// was started with.
268///
269/// This is how a client-driven loop gets the function definitions to hand the
270/// model: a declaration's `input_schema` IS the model tool's parameter schema,
271/// the same schema the server checks a client-tool intent's input against, so
272/// publishing it here is what keeps the client from keeping a second copy that
273/// can drift from the one the server validates against.
274///
275/// No drive token: this is server configuration, not run state, so it sits
276/// behind only the bearer-auth layer every other `/v1` route sits behind.
277/// Empty (never an error) on a server started with no `--client-tool` files,
278/// the same honest-empty posture [`ClientToolRegistry`] itself takes.
279pub async fn list(State(state): State<AppState>) -> impl IntoResponse {
280 let registry = state.client_tools();
281 let client_tools: Vec<Value> = registry
282 .names()
283 .into_iter()
284 .filter_map(|name| registry.get(&name).cloned())
285 .map(|decl| {
286 let mut entry = json!({
287 "name": decl.name,
288 "effect": decl.effect,
289 "input_schema": decl.input_schema,
290 "trust_completion": decl.trust_completion,
291 });
292 if let Some(output_schema) = decl.output_schema {
293 entry
294 .as_object_mut()
295 .expect("entry is a JSON object")
296 .insert("output_schema".to_owned(), output_schema);
297 }
298 if !decl.require_equal.is_empty() {
299 entry
300 .as_object_mut()
301 .expect("entry is a JSON object")
302 .insert("require_equal".to_owned(), json!(decl.require_equal));
303 }
304 entry
305 })
306 .collect();
307 Json(json!({ "client_tools": client_tools }))
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313
314 /// The TOML format the operator writes: the required fields, the optional
315 /// output schema, and the safe defaults. Silence about trust does not
316 /// self-complete, and no field is pinned unless one is named.
317 #[test]
318 fn a_declaration_parses_from_toml_with_its_defaults() {
319 let decl: ClientToolDecl = toml::from_str(
320 r#"
321 name = "charge_card"
322 effect = "write"
323
324 [input_schema]
325 type = "object"
326 "#,
327 )
328 .expect("the declaration parses");
329 assert_eq!(decl.name, "charge_card");
330 assert_eq!(decl.effect, Effect::Write);
331 assert!(decl.output_schema.is_none());
332 assert!(
333 !decl.trust_completion,
334 "a declaration silent about trust does not self-complete"
335 );
336 assert!(
337 decl.require_equal.is_empty(),
338 "no field is pinned unless one is named"
339 );
340 }
341
342 /// A misspelled key is an error, not a silent drop: a mistyped `require_equal`
343 /// would otherwise leave a guard the operator meant to set quietly absent.
344 #[test]
345 fn an_unknown_key_is_refused() {
346 let error = toml::from_str::<ClientToolDecl>(
347 r#"
348 name = "charge_card"
349 effect = "write"
350 trust_completions = false
351
352 [input_schema]
353 type = "object"
354 "#,
355 )
356 .expect_err("an unknown key is refused");
357 assert!(
358 error.to_string().contains("trust_completions"),
359 "the error names the offending key: {error}"
360 );
361 }
362
363 /// An explicit `trust_completion = true` opts into self-completion, the
364 /// direction silence no longer takes.
365 #[test]
366 fn trust_completion_is_an_explicit_opt_in() {
367 let decl: ClientToolDecl = toml::from_str(
368 r#"
369 name = "charge_card"
370 effect = "write"
371 trust_completion = true
372
373 [input_schema]
374 type = "object"
375 "#,
376 )
377 .expect("the declaration parses");
378 assert!(decl.trust_completion, "the explicit opt-in is honored");
379 }
380
381 /// A `require_equal` field present in both `required` lists loads and is
382 /// carried on the declaration.
383 #[test]
384 fn a_require_equal_field_required_on_both_sides_loads() {
385 let decl: ClientToolDecl = toml::from_str(
386 r#"
387 name = "charge_card"
388 effect = "write"
389 require_equal = ["amount_cents"]
390
391 [input_schema]
392 type = "object"
393 required = ["amount_cents"]
394
395 [output_schema]
396 type = "object"
397 required = ["amount_cents"]
398 "#,
399 )
400 .expect("the declaration parses");
401 assert_eq!(decl.require_equal, vec!["amount_cents".to_owned()]);
402 }
403
404 /// A `require_equal` field absent from `input_schema.required` is refused at
405 /// load, naming the field and the side it is missing from.
406 #[test]
407 fn a_require_equal_field_missing_from_the_input_required_is_refused() {
408 let error = toml::from_str::<ClientToolDecl>(
409 r#"
410 name = "charge_card"
411 effect = "write"
412 require_equal = ["amount_cents"]
413
414 [input_schema]
415 type = "object"
416
417 [output_schema]
418 type = "object"
419 required = ["amount_cents"]
420 "#,
421 )
422 .expect_err("the declaration is refused");
423 let message = error.to_string();
424 assert!(
425 message.contains("amount_cents") && message.contains("input_schema.required"),
426 "the error names the field and the missing side: {message}"
427 );
428 }
429
430 /// A `require_equal` field absent from `output_schema.required` (here because
431 /// there is no output schema at all) is refused at load, naming the output
432 /// side.
433 #[test]
434 fn a_require_equal_field_missing_from_the_output_required_is_refused() {
435 let error = toml::from_str::<ClientToolDecl>(
436 r#"
437 name = "charge_card"
438 effect = "write"
439 require_equal = ["amount_cents"]
440
441 [input_schema]
442 type = "object"
443 required = ["amount_cents"]
444 "#,
445 )
446 .expect_err("the declaration is refused");
447 let message = error.to_string();
448 assert!(
449 message.contains("amount_cents") && message.contains("output_schema.required"),
450 "the error names the field and the missing side: {message}"
451 );
452 }
453}