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}