Skip to main content

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}