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//! idempotency_key = ["order_id", "amount_cents"]
38//!
39//! [input_schema]
40//! type = "object"
41//! required = ["amount_cents"]
42//!
43//! [input_schema.properties.amount_cents]
44//! type = "integer"
45//!
46//! [output_schema]
47//! type = "object"
48//! required = ["charge_id"]
49//!
50//! [output_schema.properties.charge_id]
51//! type = "string"
52//! ```
53//!
54//! The struct carries the `Deserialize` derive, so it defines the format; the
55//! CLI reads the bytes off disk and hands them to `toml`, exactly as it owns
56//! file reading for agent definitions. Nothing here touches the filesystem.
57
58use std::collections::HashMap;
59
60use axum::Json;
61use axum::extract::State;
62use axum::response::IntoResponse;
63use salvor_core::{Effect, Event, EventEnvelope, Performer};
64use salvor_runtime::validate_against_schema;
65use serde::Deserialize;
66use serde_json::{Value, json};
67
68use crate::error::ApiError;
69use crate::state::AppState;
70
71/// One operator-written declaration of a tool the CLIENT performs.
72///
73/// There is no handler behind it. It exists so this server can do the things it
74/// CAN honestly do about a call it never witnessed: fix the effect class, check
75/// the input before an intent is recorded, check the reported output against a
76/// shape the operator declared, pin named fields so a report cannot alter what
77/// was authorized, and decide whether the client's report is allowed to close
78/// the call at all.
79///
80/// Unknown keys are rejected rather than ignored. A misspelled key like
81/// `require_equal` would otherwise be dropped silently, leaving a guard the
82/// operator meant to set quietly absent, and the mistake would not surface until
83/// a client had already altered a field the operator meant to pin. Refusing
84/// early, precisely, is the rule.
85///
86/// The declaration deserializes through [`RawClientToolDecl`] so a
87/// cross-field rule the field-by-field format cannot express is enforced at
88/// load: every [`require_equal`](Self::require_equal) name must be required on
89/// both sides. A file that breaks it fails to parse, naming the field and the
90/// missing side.
91#[derive(Debug, Clone, Deserialize)]
92#[serde(try_from = "RawClientToolDecl")]
93pub struct ClientToolDecl {
94    /// The tool's name, the one a client names when it opens an intent.
95    pub name: String,
96    /// The operator-declared effect class, recorded on every intent for this
97    /// tool. Never taken from the client, for the reason in the module docs.
98    pub effect: Effect,
99    /// The schema an intent's input must satisfy, checked with
100    /// [`salvor_runtime::validate_against_schema`] before anything is written.
101    pub input_schema: Value,
102    /// The schema a client-reported completion must satisfy. Optional in the
103    /// format, because a declaration is still useful without one (the effect
104    /// and the input check both still apply), but a tool declared without it
105    /// cannot be self-completed by a client: an unfalsifiable completion is
106    /// precisely what the schema exists to prevent.
107    pub output_schema: Option<Value>,
108    /// Whether the client may record its own completion for this tool. `false`
109    /// by default: silence gets the safe direction, and self-completing a write
110    /// on the client's word alone is the convenient direction, so it is an
111    /// explicit opt-in. `false` means every call for this tool is settled by
112    /// hand through the resolve endpoint after someone has verified it
113    /// externally.
114    pub trust_completion: bool,
115    /// Top-level field names whose client-reported value must equal the intent's
116    /// recorded value. Empty by default. Every named field must appear in both
117    /// `input_schema.required` and `output_schema.required`, checked at load, so
118    /// the two values always exist to compare; at the completion boundary a
119    /// reported value that differs from the authorized one refuses the
120    /// completion. The output schema is a shape check and cannot know what was
121    /// authorized; this is the field-level equality the shape check cannot do.
122    pub require_equal: Vec<String>,
123    /// Top-level input field names that, together, say what one call for this
124    /// tool IS. Empty by default, and empty means the key stays positional.
125    ///
126    /// A client-performed call always gets a server-derived idempotency key
127    /// (see `client_tool_idempotency_key`), because the client must not be the
128    /// one choosing it. What the operator chooses here is what the derivation
129    /// is over. With no fields named, the key is derived from the call's
130    /// POSITION in the run, which is an attempt identifier: the same position
131    /// retried presents the same key, and that is all it promises. Naming
132    /// fields makes it a content identity instead: `["order_id",
133    /// "amount_cents"]` says that a refund of that amount against that order is
134    /// one refund, wherever in the run it is asked for, so a loop that asks for
135    /// it twice gets the first call's answer back rather than a second refund.
136    ///
137    /// Each name is a top-level field of the intent's input. A field the input
138    /// does not carry is refused at the intent boundary, naming it, rather than
139    /// silently deriving a key over a missing value: two different calls would
140    /// otherwise collapse onto one identity.
141    pub idempotency_key: Vec<String>,
142}
143
144/// The on-disk shape of a [`ClientToolDecl`], before its cross-field rule is
145/// checked. Deserializing lands here first; [`TryFrom`] enforces the
146/// [`require_equal`](ClientToolDecl::require_equal) invariant and produces the
147/// public type, so a violating file fails to parse rather than loading a
148/// declaration whose completion boundary could not do the comparison it names.
149#[derive(Debug, Deserialize)]
150#[serde(deny_unknown_fields)]
151struct RawClientToolDecl {
152    name: String,
153    effect: Effect,
154    input_schema: Value,
155    #[serde(default)]
156    output_schema: Option<Value>,
157    /// Silence gets the safe direction: a declaration that says nothing about
158    /// trust may not self-complete.
159    #[serde(default)]
160    trust_completion: bool,
161    #[serde(default)]
162    require_equal: Vec<String>,
163    /// Silence keeps the positional derivation, which is what every declaration
164    /// written before this field meant.
165    #[serde(default)]
166    idempotency_key: Vec<String>,
167}
168
169impl TryFrom<RawClientToolDecl> for ClientToolDecl {
170    type Error = String;
171
172    /// Enforces the load-time [`require_equal`](ClientToolDecl::require_equal)
173    /// rule: every named field must be present in both `input_schema.required`
174    /// and `output_schema.required`, so the value to compare always exists on
175    /// each side. A violation is refused here, naming the field and the side it
176    /// is missing from, exactly as an unknown key is refused: early and precise.
177    fn try_from(raw: RawClientToolDecl) -> Result<Self, Self::Error> {
178        // A key field the input is not obliged to carry would be a key that
179        // sometimes cannot be derived, and the failure would land on a client
180        // mid-run rather than on the operator at load. Same rule, same moment,
181        // same precision as the require_equal check below.
182        for field in &raw.idempotency_key {
183            if !schema_requires(&raw.input_schema, field) {
184                return Err(missing_idempotency_key_field(&raw.name, field));
185            }
186        }
187        for field in &raw.require_equal {
188            if !schema_requires(&raw.input_schema, field) {
189                return Err(missing_require_equal(&raw.name, field, "input_schema"));
190            }
191            let present_in_output = raw
192                .output_schema
193                .as_ref()
194                .is_some_and(|schema| schema_requires(schema, field));
195            if !present_in_output {
196                return Err(missing_require_equal(&raw.name, field, "output_schema"));
197            }
198        }
199        Ok(ClientToolDecl {
200            name: raw.name,
201            effect: raw.effect,
202            input_schema: raw.input_schema,
203            output_schema: raw.output_schema,
204            trust_completion: raw.trust_completion,
205            require_equal: raw.require_equal,
206            idempotency_key: raw.idempotency_key,
207        })
208    }
209}
210
211/// Whether `schema`'s `required` array lists `field`. A JSON Schema object with
212/// no `required`, or one whose `required` is not an array, requires nothing.
213fn schema_requires(schema: &Value, field: &str) -> bool {
214    schema
215        .get("required")
216        .and_then(Value::as_array)
217        .is_some_and(|required| required.iter().any(|name| name.as_str() == Some(field)))
218}
219
220/// The load-time refusal for a `require_equal` field absent from one side's
221/// `required` list, naming the tool, the field, and the side it is missing from.
222fn missing_require_equal(tool: &str, field: &str, side: &str) -> String {
223    format!(
224        "tool `{tool}` names `{field}` in require_equal, but `{field}` is not in {side}.required; a \
225         require_equal field must be required on both the input and the output side, so the two \
226         values always exist to compare"
227    )
228}
229
230/// The load-time refusal for an `idempotency_key` field that is not required by
231/// the input schema, naming the tool and the field.
232fn missing_idempotency_key_field(tool: &str, field: &str) -> String {
233    format!(
234        "tool `{tool}` names `{field}` in idempotency_key, but `{field}` is not in \
235         input_schema.required; a field the key is derived from must be required, so every call \
236         for this tool has one to derive from"
237    )
238}
239
240/// The client-performed tool declarations a server was started with.
241///
242/// The counterpart of [`ToolRegistry`](crate::ToolRegistry), and deliberately a
243/// separate type: that one holds executable tools this server dispatches, this
244/// one holds declarations of tools it never runs. Merging them would put a
245/// `DynTool` with no implementation into the registry a graph `tool` node
246/// resolves through, and a graph node would then resolve a tool that cannot be
247/// called.
248///
249/// Empty is the default and is a complete, honest state: every client-tool
250/// intent is a clean `unknown_tool` until an operator declares one. There is no
251/// "no registry wired" case to distinguish, unlike the executable registry,
252/// because nothing is ever dispatched here.
253#[derive(Debug, Default, Clone)]
254pub struct ClientToolRegistry {
255    decls: HashMap<String, ClientToolDecl>,
256}
257
258impl ClientToolRegistry {
259    /// An empty set of declarations: the `salvor serve` default.
260    #[must_use]
261    pub fn new() -> Self {
262        Self {
263            decls: HashMap::new(),
264        }
265    }
266
267    /// Records `decl` under its own [`ClientToolDecl::name`], replacing any
268    /// declaration already held under that name, so a host composing a set
269    /// keeps the last word (the same rule [`ToolRegistry`](crate::ToolRegistry)
270    /// uses).
271    pub fn declare(&mut self, decl: ClientToolDecl) {
272        self.decls.insert(decl.name.clone(), decl);
273    }
274
275    /// Records `decl` and returns the registry, for the builder style a host
276    /// composes with.
277    #[must_use]
278    pub fn with_decl(mut self, decl: ClientToolDecl) -> Self {
279        self.declare(decl);
280        self
281    }
282
283    /// The declaration held under `name`, if any. `None` is the `unknown_tool`
284    /// case the client-tool intent endpoint reports without writing anything.
285    #[must_use]
286    pub fn get(&self, name: &str) -> Option<&ClientToolDecl> {
287        self.decls.get(name)
288    }
289
290    /// Whether no declarations are held (the `salvor serve` default).
291    #[must_use]
292    pub fn is_empty(&self) -> bool {
293        self.decls.is_empty()
294    }
295
296    /// How many declarations are held.
297    #[must_use]
298    pub fn len(&self) -> usize {
299        self.decls.len()
300    }
301
302    /// Every declared name, sorted, for a stable listing in a log line or an
303    /// operator-facing report.
304    #[must_use]
305    pub fn names(&self) -> Vec<String> {
306        let mut names: Vec<String> = self.decls.keys().cloned().collect();
307        names.sort();
308        names
309    }
310}
311
312/// `GET /v1/client-tools`: every client-performed tool declaration this server
313/// was started with.
314///
315/// This is how a client-driven loop gets the function definitions to hand the
316/// model: a declaration's `input_schema` IS the model tool's parameter schema,
317/// the same schema the server checks a client-tool intent's input against, so
318/// publishing it here is what keeps the client from keeping a second copy that
319/// can drift from the one the server validates against.
320///
321/// No drive token: this is server configuration, not run state, so it sits
322/// behind only the bearer-auth layer every other `/v1` route sits behind.
323/// Empty (never an error) on a server started with no `--client-tool` files,
324/// the same honest-empty posture [`ClientToolRegistry`] itself takes.
325pub async fn list(State(state): State<AppState>) -> impl IntoResponse {
326    let registry = state.client_tools();
327    let client_tools: Vec<Value> = registry
328        .names()
329        .into_iter()
330        .filter_map(|name| registry.get(&name).cloned())
331        .map(|decl| {
332            let mut entry = json!({
333                "name": decl.name,
334                "effect": decl.effect,
335                "input_schema": decl.input_schema,
336                "trust_completion": decl.trust_completion,
337            });
338            if let Some(output_schema) = decl.output_schema {
339                entry
340                    .as_object_mut()
341                    .expect("entry is a JSON object")
342                    .insert("output_schema".to_owned(), output_schema);
343            }
344            if !decl.require_equal.is_empty() {
345                entry
346                    .as_object_mut()
347                    .expect("entry is a JSON object")
348                    .insert("require_equal".to_owned(), json!(decl.require_equal));
349            }
350            // Published for the same reason `input_schema` is: a client that
351            // wants to derive the key itself, to check this server's work, has
352            // to know what the derivation is over.
353            if !decl.idempotency_key.is_empty() {
354                entry
355                    .as_object_mut()
356                    .expect("entry is a JSON object")
357                    .insert("idempotency_key".to_owned(), json!(decl.idempotency_key));
358            }
359            entry
360        })
361        .collect();
362    Json(json!({ "client_tools": client_tools }))
363}
364
365/// Checks a hand-recorded resolution against the operator's declaration, when
366/// the call being resolved is one the CLIENT performed.
367///
368/// Both resolve endpoints (`POST /v1/runs/{id}/resolve` and its drive-token
369/// twin `POST /v1/client-runs/{id}/resolve`) go through here before a
370/// completion is written, and they share this one function so an operator meets
371/// the same rules on either path.
372///
373/// # Why resolve is checked at all
374///
375/// Resolve records an output nothing in this process witnessed, which is
376/// exactly the situation the declaration exists for. Every guard the completion
377/// boundary applies to a client's own report applies here for the same reason:
378/// the `output_schema` says what evidence a finished call has to carry, and a
379/// `require_equal` field says a report may not change what was authorized. A
380/// resolution that skipped both could settle a refund intent for 5000 with a
381/// completion saying 50000, and the log would carry it as fact.
382///
383/// What is NOT checked here is `trust_completion`. That flag answers "may the
384/// CLIENT close this call", and the whole point of a `false` is that a person
385/// closes it instead, which is what this path is. Refusing here would leave a
386/// tool nobody could ever settle.
387///
388/// A dangling intent that salvor performed itself is left alone: this server
389/// witnessed that call, holds no declaration for it, and its output is checked
390/// by nothing today. Returns `Ok(())` for that case, and for a log that does
391/// not end at a tool intent at all (the resolve itself then refuses on state).
392///
393/// # Errors
394///
395/// [`ApiError::BadRequest`] naming the tool when no declaration for it is
396/// loaded here, when the output fails the declared `output_schema`, or when a
397/// `require_equal` field's value differs from the one the intent recorded.
398pub(crate) fn check_client_resolution(
399    registry: &ClientToolRegistry,
400    log: &[EventEnvelope],
401    output: &Value,
402) -> Result<(), ApiError> {
403    let Some(EventEnvelope {
404        event:
405            Event::ToolCallRequested {
406                tool,
407                input,
408                performed_by: Some(Performer::Client),
409                ..
410            },
411        ..
412    }) = log.last()
413    else {
414        return Ok(());
415    };
416
417    // A declaration this server no longer holds is a stale registry, not a bad
418    // request from the caller, so the message says what the operator has to fix
419    // rather than what the caller should have sent. Recording the resolution
420    // unchecked is the one thing this must not do: the shape and the pinned
421    // fields would go unexamined precisely where nobody witnessed the call.
422    let decl = registry.get(tool).ok_or_else(|| {
423        ApiError::BadRequest(format!(
424            "no client-performed tool named `{tool}` is declared on this server, so the output \
425             offered for it cannot be checked; start the server with `--client-tool <FILE>` for \
426             `{tool}` and resolve again"
427        ))
428    })?;
429
430    // A declaration with no output_schema has nothing to check the shape
431    // against, and that is a legitimate declaration: it is exactly the tool the
432    // client may not self-complete, whose calls are meant to arrive here. The
433    // load-time rule guarantees no require_equal field can be named without an
434    // output schema, so nothing below is skipped along with it.
435    if let Some(output_schema) = &decl.output_schema {
436        validate_against_schema(output, output_schema).map_err(|error| {
437            ApiError::BadRequest(format!(
438                "the output offered for `{tool}` does not match its declared output_schema: {error}"
439            ))
440        })?;
441    }
442
443    for field in &decl.require_equal {
444        let authorized = input.get(field).unwrap_or(&Value::Null);
445        let offered = output.get(field).unwrap_or(&Value::Null);
446        if authorized != offered {
447            return Err(ApiError::BadRequest(format!(
448                "the output offered for `{tool}` reports `{field}` as {offered}, but the intent \
449                 recorded {authorized}; a resolution may not alter a require_equal field. Record \
450                 what was authorized, or abandon the run if the provider did something else"
451            )));
452        }
453    }
454    Ok(())
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460
461    /// The TOML format the operator writes: the required fields, the optional
462    /// output schema, and the safe defaults. Silence about trust does not
463    /// self-complete, and no field is pinned unless one is named.
464    #[test]
465    fn a_declaration_parses_from_toml_with_its_defaults() {
466        let decl: ClientToolDecl = toml::from_str(
467            r#"
468            name = "charge_card"
469            effect = "write"
470
471            [input_schema]
472            type = "object"
473            "#,
474        )
475        .expect("the declaration parses");
476        assert_eq!(decl.name, "charge_card");
477        assert_eq!(decl.effect, Effect::Write);
478        assert!(decl.output_schema.is_none());
479        assert!(
480            !decl.trust_completion,
481            "a declaration silent about trust does not self-complete"
482        );
483        assert!(
484            decl.require_equal.is_empty(),
485            "no field is pinned unless one is named"
486        );
487    }
488
489    /// A declaration silent about `idempotency_key` keeps the positional
490    /// derivation, which is what every declaration written before the field
491    /// meant.
492    #[test]
493    fn an_unset_idempotency_key_is_empty() {
494        let decl: ClientToolDecl = toml::from_str(
495            r#"
496            name = "charge_card"
497            effect = "write"
498
499            [input_schema]
500            type = "object"
501            "#,
502        )
503        .expect("the declaration parses");
504        assert!(
505            decl.idempotency_key.is_empty(),
506            "silence keeps the key positional"
507        );
508    }
509
510    /// Named key fields load in the order the operator wrote them, which is the
511    /// order the derivation reads them in.
512    #[test]
513    fn declared_key_fields_load_in_order() {
514        let decl: ClientToolDecl = toml::from_str(
515            r#"
516            name = "refund_card"
517            effect = "write"
518            idempotency_key = ["order_id", "amount_cents"]
519
520            [input_schema]
521            type = "object"
522            required = ["order_id", "amount_cents"]
523            "#,
524        )
525        .expect("the declaration parses");
526        assert_eq!(
527            decl.idempotency_key,
528            vec!["order_id".to_owned(), "amount_cents".to_owned()]
529        );
530    }
531
532    /// A key field the input schema does not require is refused at load, naming
533    /// the field: a key that sometimes cannot be derived is the operator's
534    /// mistake, and it should surface here rather than on a client mid-run.
535    #[test]
536    fn an_idempotency_key_field_not_required_by_the_input_is_refused() {
537        let error = toml::from_str::<ClientToolDecl>(
538            r#"
539            name = "refund_card"
540            effect = "write"
541            idempotency_key = ["order_id"]
542
543            [input_schema]
544            type = "object"
545            required = ["amount_cents"]
546            "#,
547        )
548        .expect_err("the declaration is refused");
549        let message = error.to_string();
550        assert!(
551            message.contains("order_id") && message.contains("input_schema.required"),
552            "the error names the field and what it is missing from: {message}"
553        );
554    }
555
556    /// A misspelled key is an error, not a silent drop: a mistyped `require_equal`
557    /// would otherwise leave a guard the operator meant to set quietly absent.
558    #[test]
559    fn an_unknown_key_is_refused() {
560        let error = toml::from_str::<ClientToolDecl>(
561            r#"
562            name = "charge_card"
563            effect = "write"
564            trust_completions = false
565
566            [input_schema]
567            type = "object"
568            "#,
569        )
570        .expect_err("an unknown key is refused");
571        assert!(
572            error.to_string().contains("trust_completions"),
573            "the error names the offending key: {error}"
574        );
575    }
576
577    /// An explicit `trust_completion = true` opts into self-completion, the
578    /// direction silence no longer takes.
579    #[test]
580    fn trust_completion_is_an_explicit_opt_in() {
581        let decl: ClientToolDecl = toml::from_str(
582            r#"
583            name = "charge_card"
584            effect = "write"
585            trust_completion = true
586
587            [input_schema]
588            type = "object"
589            "#,
590        )
591        .expect("the declaration parses");
592        assert!(decl.trust_completion, "the explicit opt-in is honored");
593    }
594
595    /// A `require_equal` field present in both `required` lists loads and is
596    /// carried on the declaration.
597    #[test]
598    fn a_require_equal_field_required_on_both_sides_loads() {
599        let decl: ClientToolDecl = toml::from_str(
600            r#"
601            name = "charge_card"
602            effect = "write"
603            require_equal = ["amount_cents"]
604
605            [input_schema]
606            type = "object"
607            required = ["amount_cents"]
608
609            [output_schema]
610            type = "object"
611            required = ["amount_cents"]
612            "#,
613        )
614        .expect("the declaration parses");
615        assert_eq!(decl.require_equal, vec!["amount_cents".to_owned()]);
616    }
617
618    /// A `require_equal` field absent from `input_schema.required` is refused at
619    /// load, naming the field and the side it is missing from.
620    #[test]
621    fn a_require_equal_field_missing_from_the_input_required_is_refused() {
622        let error = toml::from_str::<ClientToolDecl>(
623            r#"
624            name = "charge_card"
625            effect = "write"
626            require_equal = ["amount_cents"]
627
628            [input_schema]
629            type = "object"
630
631            [output_schema]
632            type = "object"
633            required = ["amount_cents"]
634            "#,
635        )
636        .expect_err("the declaration is refused");
637        let message = error.to_string();
638        assert!(
639            message.contains("amount_cents") && message.contains("input_schema.required"),
640            "the error names the field and the missing side: {message}"
641        );
642    }
643
644    /// A `require_equal` field absent from `output_schema.required` (here because
645    /// there is no output schema at all) is refused at load, naming the output
646    /// side.
647    #[test]
648    fn a_require_equal_field_missing_from_the_output_required_is_refused() {
649        let error = toml::from_str::<ClientToolDecl>(
650            r#"
651            name = "charge_card"
652            effect = "write"
653            require_equal = ["amount_cents"]
654
655            [input_schema]
656            type = "object"
657            required = ["amount_cents"]
658            "#,
659        )
660        .expect_err("the declaration is refused");
661        let message = error.to_string();
662        assert!(
663            message.contains("amount_cents") && message.contains("output_schema.required"),
664            "the error names the field and the missing side: {message}"
665        );
666    }
667}