Skip to main content

salvor_runtime/
agent.rs

1//! [`Agent`] and [`Agent::builder`]: the batteries-included agent
2//! definition.
3//!
4//! An agent is model + system prompt + tools + budgets (+ pricing when a
5//! cost budget is declared, + an output schema when the agent declares the
6//! shape of what it produces). Under the single built-in loop, that makes an
7//! agent definition pure data, and pure data can be content-hashed:
8//!
9//! # The definition hash
10//!
11//! `agent_def_hash` (recorded in `RunStarted` and re-checked on every
12//! resume) is `sha256:` over the canonical serialization (see
13//! [`crate::hash`]) of this JSON value:
14//!
15//! ```json
16//! {
17//!     "budgets": {"max_cost_usd": null, "max_steps": 40, "max_tokens": null,
18//!                  "max_wall_time_seconds": null},
19//!     "model": "<model id>",
20//!     "pricing": {"input_per_mtok": 3.0, "output_per_mtok": 15.0},
21//!     "system_prompt": "...",
22//!     "tools": [{"description": "...", "effect": "read",
23//!                "input_schema": { ... }, "name": "..."} , ...]
24//! }
25//! ```
26//!
27//! A tool that declares an `output_schema` (see
28//! [`ToolHandler::output_schema`](salvor_tools::ToolHandler::output_schema))
29//! carries that key too, alongside `description`/`effect`/`input_schema`/
30//! `name` in its entry above. A tool that declares none omits the key
31//! entirely rather than carrying it as `null`, so every tool without one
32//! hashes exactly as it did before `output_schema` existed on the contract.
33//!
34//! An agent that declares an [`output_schema`](Agent::output_schema) of its
35//! own carries it as a top-level `"output_schema"` key beside `"model"` and
36//! `"tools"`, under the same absent-not-null discipline: an agent that
37//! declares none omits the key entirely, so its hash is byte-identical to
38//! the one it had before agents could declare a shape at all. That matters
39//! more here than anywhere: an agent hash is pinned in checked-in graph
40//! documents, and adding a field must not orphan a single one of them.
41//!
42//! Tools appear sorted by name (the `ToolSet` enumerates them that way), so
43//! registration order never changes the hash; any change to the model id,
44//! prompt, a tool contract, a budget, pricing, or the declared output shape
45//! does. The client configuration (base URL, API key, retries) is
46//! deliberately *not* hashed: it is transport, not definition, and pointing
47//! the same agent at a local endpoint must not orphan its recorded runs. MCP-backed tools participate
48//! exactly like native ones, through their `DynTool` descriptors.
49//!
50//! # Build-time checks
51//!
52//! [`AgentBuilder::build`] fails (rather than letting a run fail later)
53//! when no model is configured, when a duplicate tool name is registered,
54//! or when a cost budget is declared without [`Pricing`], since a cost
55//! check without rates cannot be computed at all.
56
57use std::collections::BTreeMap;
58
59use salvor_llm::{Client, Config};
60use salvor_tools::{DynTool, ToolHandler, ToolSet};
61use serde_json::{Value, json};
62use thiserror::Error;
63
64use crate::hash::hash_value;
65use salvor_core::{Budgets, Pricing};
66
67/// The default `max_tokens` sent with each model request when the builder
68/// is not told otherwise.
69pub const DEFAULT_MAX_RESPONSE_TOKENS: u32 = 4096;
70
71/// A built agent definition plus the client that executes its model calls.
72/// Construct with [`Agent::builder`].
73pub struct Agent {
74    client: Client,
75    model: String,
76    system_prompt: Option<String>,
77    tools: ToolSet,
78    budgets: Budgets,
79    pricing: Option<Pricing>,
80    max_response_tokens: u32,
81    output_schema: Option<Value>,
82    def_hash: String,
83    record_prompts: bool,
84    labels: Option<BTreeMap<String, String>>,
85    name: Option<String>,
86}
87
88impl Agent {
89    /// Starts building an agent.
90    #[must_use]
91    pub fn builder() -> AgentBuilder {
92        AgentBuilder::new()
93    }
94
95    /// The content hash of this definition, as recorded in `RunStarted`.
96    /// Computed once at build time; see the module docs for what it covers.
97    #[must_use]
98    pub fn def_hash(&self) -> &str {
99        &self.def_hash
100    }
101
102    /// The client model calls go through.
103    #[must_use]
104    pub fn client(&self) -> &Client {
105        &self.client
106    }
107
108    /// The model id sent with every request.
109    #[must_use]
110    pub fn model(&self) -> &str {
111        &self.model
112    }
113
114    /// The system prompt, when one is set.
115    #[must_use]
116    pub fn system_prompt(&self) -> Option<&str> {
117        self.system_prompt.as_deref()
118    }
119
120    /// The tools the model may call.
121    #[must_use]
122    pub fn tools(&self) -> &ToolSet {
123        &self.tools
124    }
125
126    /// The declared budgets.
127    #[must_use]
128    pub fn budgets(&self) -> &Budgets {
129        &self.budgets
130    }
131
132    /// The pricing table, when one is set.
133    #[must_use]
134    pub fn pricing(&self) -> Option<&Pricing> {
135        self.pricing.as_ref()
136    }
137
138    /// The `max_tokens` cap sent with each model request.
139    #[must_use]
140    pub fn max_response_tokens(&self) -> u32 {
141        self.max_response_tokens
142    }
143
144    /// The shape this agent's final answer must take, when it declares one
145    /// with [`AgentBuilder::output_schema`]. `Some` puts every run of this
146    /// agent on the structured path: the built-in loop offers the model the
147    /// `salvor_answer` tool carrying this schema, forces a tool call, and
148    /// ends only on an answer the schema accepts, so the run's output is that
149    /// object rather than a paragraph of prose.
150    ///
151    /// Unlike [`record_prompts`](Self::record_prompts), [`labels`](Self::labels),
152    /// and [`name`](Self::name), this IS part of
153    /// [`def_hash`](Self::def_hash). Those three are posture and paperwork
154    /// around an agent; this changes what the agent produces, so two agents
155    /// that differ only here are two different agents and must not share one
156    /// recorded identity. Declaring a schema on an existing agent file
157    /// therefore mints a new hash, and any graph document pinning the old one
158    /// needs repinning.
159    #[must_use]
160    pub fn output_schema(&self) -> Option<&Value> {
161        self.output_schema.as_ref()
162    }
163
164    /// Whether runs of this agent record the full model request body into the
165    /// durable log. Off by default; see [`AgentBuilder::record_prompts`]. This
166    /// is operator/transport policy, not part of the definition, so it is
167    /// deliberately excluded from [`def_hash`](Self::def_hash): flipping it
168    /// must not orphan an agent's recorded runs.
169    #[must_use]
170    pub fn record_prompts(&self) -> bool {
171        self.record_prompts
172    }
173
174    /// Correlation tags to stamp on every fresh run of this agent, when set
175    /// with [`AgentBuilder::labels`]. Like [`record_prompts`](Self::record_prompts),
176    /// this is operator/deployment metadata, not part of the definition, so
177    /// it is deliberately excluded from [`def_hash`](Self::def_hash):
178    /// relabeling an agent must not orphan its recorded runs.
179    #[must_use]
180    pub fn labels(&self) -> Option<&BTreeMap<String, String>> {
181        self.labels.as_ref()
182    }
183
184    /// A short human label for this agent, when set with
185    /// [`AgentBuilder::name`]: a display name the control plane's agent
186    /// registry (`GET /v1/agents/{hash}`) can hand back to a caller that only
187    /// has the hash. Like [`labels`](Self::labels) and
188    /// [`record_prompts`](Self::record_prompts), this is descriptive
189    /// metadata, not part of the definition, so it is deliberately excluded
190    /// from [`def_hash`](Self::def_hash): renaming an agent must not mint a
191    /// new identity or orphan its recorded runs.
192    #[must_use]
193    pub fn name(&self) -> Option<&str> {
194        self.name.as_deref()
195    }
196}
197
198/// Builds an [`Agent`]:
199///
200/// ```no_run
201/// use salvor_llm::Config;
202/// use salvor_runtime::{Agent, Budgets};
203///
204/// # fn demo() -> Result<(), salvor_runtime::AgentBuildError> {
205/// let agent = Agent::builder()
206///     .model(Config::from_env(), "claude-opus-4-8")
207///     .system_prompt("You are a research agent.")
208///     .budgets(Budgets {
209///         max_steps: Some(40),
210///         ..Budgets::default()
211///     })
212///     .build()?;
213/// # Ok(())
214/// # }
215/// ```
216#[derive(Default)]
217pub struct AgentBuilder {
218    client: Option<Client>,
219    config: Option<Config>,
220    model: Option<String>,
221    system_prompt: Option<String>,
222    tools: Vec<Box<dyn DynTool>>,
223    budgets: Budgets,
224    pricing: Option<Pricing>,
225    max_response_tokens: Option<u32>,
226    output_schema: Option<Value>,
227    record_prompts: bool,
228    labels: Option<BTreeMap<String, String>>,
229    name: Option<String>,
230}
231
232impl AgentBuilder {
233    /// An empty builder; [`Agent::builder`] is the usual entry point.
234    #[must_use]
235    pub fn new() -> Self {
236        Self::default()
237    }
238
239    /// Sets the model by client configuration plus model id. The client is
240    /// constructed at [`build`](Self::build) time.
241    #[must_use]
242    pub fn model(mut self, config: Config, model: impl Into<String>) -> Self {
243        self.config = Some(config);
244        self.client = None;
245        self.model = Some(model.into());
246        self
247    }
248
249    /// Sets the model by an already-built client plus model id.
250    #[must_use]
251    pub fn client(mut self, client: Client, model: impl Into<String>) -> Self {
252        self.client = Some(client);
253        self.config = None;
254        self.model = Some(model.into());
255        self
256    }
257
258    /// Sets the system prompt.
259    #[must_use]
260    pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
261        self.system_prompt = Some(prompt.into());
262        self
263    }
264
265    /// Adds a typed tool handler. Duplicate names are reported at
266    /// [`build`](Self::build) time.
267    #[must_use]
268    pub fn tool<H: ToolHandler + 'static>(mut self, handler: H) -> Self {
269        self.tools
270            .push(Box::new(salvor_tools::TypedTool::new(handler)));
271        self
272    }
273
274    /// Adds an already type-erased tool. This is how MCP-backed tools (and
275    /// any other runtime-defined `DynTool`) join the agent, on equal footing
276    /// with native handlers.
277    #[must_use]
278    pub fn tool_dyn(mut self, tool: Box<dyn DynTool>) -> Self {
279        self.tools.push(tool);
280        self
281    }
282
283    /// Sets the declared budgets.
284    #[must_use]
285    pub fn budgets(mut self, budgets: Budgets) -> Self {
286        self.budgets = budgets;
287        self
288    }
289
290    /// Sets the pricing table cost budgets are computed against.
291    #[must_use]
292    pub fn pricing(mut self, pricing: Pricing) -> Self {
293        self.pricing = Some(pricing);
294        self
295    }
296
297    /// Sets the `max_tokens` cap sent with each model request (default
298    /// [`DEFAULT_MAX_RESPONSE_TOKENS`]).
299    #[must_use]
300    pub fn max_response_tokens(mut self, max_tokens: u32) -> Self {
301        self.max_response_tokens = Some(max_tokens);
302        self
303    }
304
305    /// Declares the shape of this agent's final answer (unset by default),
306    /// putting every run of it on the structured path: the built-in loop
307    /// offers the model the `salvor_answer` tool carrying this schema and
308    /// ends only on an answer the schema accepts. See
309    /// [`Agent::output_schema`], and
310    /// [`drive_loop_structured`](crate::drive_loop_structured) for the loop
311    /// itself.
312    ///
313    /// This one IS hashed into [`Agent::def_hash`], unlike
314    /// [`record_prompts`](Self::record_prompts), [`labels`](Self::labels),
315    /// and [`name`](Self::name): it changes what the agent produces, not how
316    /// it is deployed or described. An agent that declares no schema hashes
317    /// exactly as it did before this setter existed.
318    ///
319    /// A graph's `agent` node may declare a schema too, and a node's
320    /// declaration wins over this one for that node (see the graph engine's
321    /// `drive_agent_node`). This is the agent's own default, used wherever it
322    /// runs without a node speaking for it.
323    #[must_use]
324    pub fn output_schema(mut self, schema: Value) -> Self {
325        self.output_schema = Some(schema);
326        self
327    }
328
329    /// Turns on recording of the full model request body for runs of this
330    /// agent (default off). This is the resolved effective setting; the CLI
331    /// and server compute it from the per-agent `record_prompts` config and the
332    /// `SALVOR_RECORD_PROMPTS` default before calling this. It is PII-sensitive
333    /// (the body may hold user data or secrets) and is deliberately kept out of
334    /// the definition hash. See [`Agent::record_prompts`].
335    #[must_use]
336    pub fn record_prompts(mut self, record_prompts: bool) -> Self {
337        self.record_prompts = record_prompts;
338        self
339    }
340
341    /// Sets correlation tags to stamp on every fresh run of this agent (a
342    /// build id, an environment name). Unset by default. Like
343    /// [`record_prompts`](Self::record_prompts), this is operator/deployment
344    /// metadata rather than part of what the agent runs, so it is excluded
345    /// from [`Agent::def_hash`] (see that method's docs). Sanity bounds on
346    /// the labels themselves are not checked here; they are enforced where a
347    /// run is actually created (see [`crate::validate_labels`]), so this
348    /// setter is infallible.
349    #[must_use]
350    pub fn labels(mut self, labels: BTreeMap<String, String>) -> Self {
351        self.labels = Some(labels);
352        self
353    }
354
355    /// Sets a short human label for this agent (unset by default). Like
356    /// [`record_prompts`](Self::record_prompts) and [`labels`](Self::labels),
357    /// this is descriptive metadata rather than part of what the agent runs,
358    /// so it is excluded from [`Agent::def_hash`] (see that method's docs).
359    /// Sanity bounds on the name itself are not checked here; a config-file
360    /// caller enforces them where the config is parsed (see
361    /// `salvor_cli::agent_config::MAX_NAME_LEN`), so this setter is
362    /// infallible, mirroring [`labels`](Self::labels).
363    #[must_use]
364    pub fn name(mut self, name: impl Into<String>) -> Self {
365        self.name = Some(name.into());
366        self
367    }
368
369    /// Builds the agent, computing its definition hash.
370    ///
371    /// # Errors
372    ///
373    /// [`AgentBuildError::MissingModel`] when neither
374    /// [`model`](Self::model) nor [`client`](Self::client) was called;
375    /// [`AgentBuildError::DuplicateTool`] when two tools share a name;
376    /// [`AgentBuildError::CostBudgetWithoutPricing`] when `max_cost_usd` is
377    /// declared with no [`Pricing`]; [`AgentBuildError::Client`] when the
378    /// client cannot be constructed from the given configuration.
379    pub fn build(self) -> Result<Agent, AgentBuildError> {
380        let model = self.model.ok_or(AgentBuildError::MissingModel)?;
381        let client = match (self.client, self.config) {
382            (Some(client), _) => client,
383            (None, Some(config)) => Client::new(config).map_err(AgentBuildError::Client)?,
384            (None, None) => return Err(AgentBuildError::MissingModel),
385        };
386        if self.budgets.max_cost_usd.is_some() && self.pricing.is_none() {
387            return Err(AgentBuildError::CostBudgetWithoutPricing);
388        }
389
390        let mut tools = ToolSet::new();
391        for tool in self.tools {
392            let name = tool.name().to_owned();
393            tools
394                .register_dyn(tool)
395                .map_err(|_| AgentBuildError::DuplicateTool { name })?;
396        }
397
398        let def_hash = compute_def_hash(
399            &model,
400            self.system_prompt.as_deref(),
401            &tools,
402            &self.budgets,
403            self.pricing.as_ref(),
404            self.output_schema.as_ref(),
405        );
406
407        Ok(Agent {
408            client,
409            model,
410            system_prompt: self.system_prompt,
411            tools,
412            budgets: self.budgets,
413            pricing: self.pricing,
414            max_response_tokens: self
415                .max_response_tokens
416                .unwrap_or(DEFAULT_MAX_RESPONSE_TOKENS),
417            output_schema: self.output_schema,
418            def_hash,
419            record_prompts: self.record_prompts,
420            labels: self.labels,
421            name: self.name,
422        })
423    }
424}
425
426/// Why an agent could not be built.
427#[derive(Debug, Error)]
428pub enum AgentBuildError {
429    /// No model was configured.
430    #[error("an agent needs a model: call .model(config, id) or .client(client, id)")]
431    MissingModel,
432
433    /// A cost budget was declared with no pricing to compute cost from.
434    #[error("max_cost_usd is declared but no pricing is set; call .pricing(Pricing {{ .. }})")]
435    CostBudgetWithoutPricing,
436
437    /// Two registered tools share a name.
438    #[error("a tool named `{name}` is registered twice")]
439    DuplicateTool {
440        /// The name that collided.
441        name: String,
442    },
443
444    /// The client could not be constructed from the given configuration.
445    #[error("client construction failed: {0}")]
446    Client(salvor_llm::Error),
447}
448
449/// Builds the canonical definition value documented at module level and
450/// hashes it.
451fn compute_def_hash(
452    model: &str,
453    system_prompt: Option<&str>,
454    tools: &ToolSet,
455    budgets: &Budgets,
456    pricing: Option<&Pricing>,
457    output_schema: Option<&Value>,
458) -> String {
459    let tool_values: Vec<Value> = tools
460        .descriptors()
461        .into_iter()
462        .map(|descriptor| {
463            let mut value = json!({
464                "description": descriptor.description,
465                "effect": descriptor.effect,
466                "input_schema": descriptor.input_schema,
467                "name": descriptor.name,
468            });
469            // `output_schema` is inserted only when the tool declares one,
470            // and never emitted as an explicit `null`. Most tools declare no
471            // output schema, and this key did not exist before it was added
472            // to `ToolDescriptor`; if every tool without one hashed as
473            // though it carried `"output_schema": null`, this would change
474            // the hash of every tool already in the field, and with it,
475            // every `agent_hash` already pinned in a checked-in graph
476            // document would stop resolving. Keying its presence on
477            // `Some`/`None` instead means a schema-less tool hashes exactly
478            // as it did before this field existed.
479            if let Some(output_schema) = descriptor.output_schema {
480                value
481                    .as_object_mut()
482                    .expect("the json! object literal above always builds a Value::Object")
483                    .insert("output_schema".to_owned(), output_schema);
484            }
485            value
486        })
487        .collect();
488    let mut value = json!({
489        "budgets": {
490            "max_cost_usd": budgets.max_cost_usd,
491            "max_steps": budgets.max_steps,
492            "max_tokens": budgets.max_tokens,
493            "max_wall_time_seconds": budgets.max_wall_time.map(|d| d.as_secs_f64()),
494        },
495        "model": model,
496        "pricing": pricing.map(|p| {
497            json!({"input_per_mtok": p.input_per_mtok, "output_per_mtok": p.output_per_mtok})
498        }),
499        "system_prompt": system_prompt,
500        "tools": tool_values,
501    });
502    // The agent's own output schema, on exactly the terms a tool's is: the
503    // key appears only when one is declared, never as an explicit `null`.
504    // Absent-not-null is what keeps a schema-less agent hashing the way it
505    // always has, and an agent hash is the thing checked-in graph documents
506    // pin, so the alternative would silently invalidate every one of them the
507    // day this field was added. `pricing` above is the older, looser style
508    // (`null` when unset) and stays that way for the same reason in reverse:
509    // it was there from the start, so its null is already in every hash.
510    if let Some(output_schema) = output_schema {
511        value
512            .as_object_mut()
513            .expect("the json! object literal above always builds a Value::Object")
514            .insert("output_schema".to_owned(), output_schema.clone());
515    }
516    hash_value(&value)
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522    use salvor_core::Effect;
523    use salvor_tools::{ToolCtx, ToolError, ToolOutcome};
524    use serde_json::json;
525
526    /// A minimal `DynTool` for definition-hash tests.
527    struct StubTool {
528        name: &'static str,
529        description: &'static str,
530    }
531
532    #[async_trait::async_trait]
533    impl DynTool for StubTool {
534        fn name(&self) -> &str {
535            self.name
536        }
537        fn description(&self) -> &str {
538            self.description
539        }
540        fn effect(&self) -> Effect {
541            Effect::Read
542        }
543        fn input_schema(&self) -> Value {
544            json!({"type": "object"})
545        }
546        async fn call_json(
547            &self,
548            _ctx: &ToolCtx,
549            input: Value,
550        ) -> Result<ToolOutcome<Value>, ToolError> {
551            Ok(ToolOutcome::Output(input))
552        }
553    }
554
555    fn base_builder() -> AgentBuilder {
556        Agent::builder()
557            .model(Config::new(), "test-model")
558            .system_prompt("prompt")
559            .tool_dyn(Box::new(StubTool {
560                name: "alpha",
561                description: "first",
562            }))
563    }
564
565    /// The same definition hashes to the same value, whatever the tool
566    /// registration order.
567    #[test]
568    fn identical_definitions_share_a_hash() {
569        let a = Agent::builder()
570            .model(Config::new(), "test-model")
571            .tool_dyn(Box::new(StubTool {
572                name: "alpha",
573                description: "first",
574            }))
575            .tool_dyn(Box::new(StubTool {
576                name: "beta",
577                description: "second",
578            }))
579            .build()
580            .unwrap();
581        let b = Agent::builder()
582            .model(Config::new(), "test-model")
583            .tool_dyn(Box::new(StubTool {
584                name: "beta",
585                description: "second",
586            }))
587            .tool_dyn(Box::new(StubTool {
588                name: "alpha",
589                description: "first",
590            }))
591            .build()
592            .unwrap();
593        assert_eq!(a.def_hash(), b.def_hash());
594        assert!(a.def_hash().starts_with("sha256:"));
595    }
596
597    /// Changing any hashed component changes the hash.
598    #[test]
599    fn any_definition_change_changes_the_hash() {
600        let base = base_builder().build().unwrap();
601
602        let model_changed = base_builder();
603        let model_changed = AgentBuilder {
604            model: Some("other-model".to_owned()),
605            ..model_changed
606        }
607        .build()
608        .unwrap();
609        assert_ne!(base.def_hash(), model_changed.def_hash());
610
611        let prompt_changed = base_builder().system_prompt("different").build().unwrap();
612        assert_ne!(base.def_hash(), prompt_changed.def_hash());
613
614        let tool_changed = Agent::builder()
615            .model(Config::new(), "test-model")
616            .system_prompt("prompt")
617            .tool_dyn(Box::new(StubTool {
618                name: "alpha",
619                description: "changed description",
620            }))
621            .build()
622            .unwrap();
623        assert_ne!(base.def_hash(), tool_changed.def_hash());
624
625        let budget_changed = base_builder()
626            .budgets(Budgets {
627                max_steps: Some(10),
628                ..Budgets::default()
629            })
630            .build()
631            .unwrap();
632        assert_ne!(base.def_hash(), budget_changed.def_hash());
633
634        let pricing_changed = base_builder()
635            .pricing(Pricing {
636                input_per_mtok: 3.0,
637                output_per_mtok: 15.0,
638            })
639            .build()
640            .unwrap();
641        assert_ne!(base.def_hash(), pricing_changed.def_hash());
642    }
643
644    /// Labels are operator/deployment metadata, not part of the definition:
645    /// setting them, changing them, or leaving them unset never changes
646    /// `def_hash`, mirroring how `record_prompts` is excluded. This is the
647    /// def-hash half of the "hashing is unaffected by labels" guarantee; the
648    /// request-hash half is proven in `salvor-runtime`'s `happy_path.rs`
649    /// integration test, alongside `record_prompts`'s identical proof.
650    #[test]
651    fn labels_never_affect_the_definition_hash() {
652        let unlabeled = base_builder().build().unwrap();
653        let labeled_a = base_builder()
654            .labels(BTreeMap::from([("build".to_owned(), "42".to_owned())]))
655            .build()
656            .unwrap();
657        let labeled_b = base_builder()
658            .labels(BTreeMap::from([
659                ("build".to_owned(), "43".to_owned()),
660                ("env".to_owned(), "staging".to_owned()),
661            ]))
662            .build()
663            .unwrap();
664
665        assert_eq!(unlabeled.def_hash(), labeled_a.def_hash());
666        assert_eq!(unlabeled.def_hash(), labeled_b.def_hash());
667        assert_eq!(unlabeled.labels(), None);
668        assert_eq!(
669            labeled_a.labels(),
670            Some(&BTreeMap::from([("build".to_owned(), "42".to_owned())]))
671        );
672    }
673
674    /// `name` is descriptive metadata, not part of the definition: setting
675    /// it, changing it, or leaving it unset never changes `def_hash`,
676    /// mirroring how `record_prompts` and `labels` are excluded. This is the
677    /// def-hash half of the "a rename must not mint a new agent identity"
678    /// guarantee the CLI's TOML `name` field relies on (see
679    /// `salvor_cli::agent_config`'s own same-TOML-plus-or-minus-`name` test).
680    #[test]
681    fn name_never_affects_the_definition_hash() {
682        let unnamed = base_builder().build().unwrap();
683        let named_a = base_builder().name("triage-agent").build().unwrap();
684        let named_b = base_builder().name("a-different-name").build().unwrap();
685
686        assert_eq!(unnamed.def_hash(), named_a.def_hash());
687        assert_eq!(unnamed.def_hash(), named_b.def_hash());
688        assert_eq!(unnamed.name(), None);
689        assert_eq!(named_a.name(), Some("triage-agent"));
690    }
691
692    /// A cost budget without pricing is a build-time error, not a run-time
693    /// surprise.
694    #[test]
695    fn cost_budget_without_pricing_fails_to_build() {
696        let result = base_builder()
697            .budgets(Budgets {
698                max_cost_usd: Some(2.0),
699                ..Budgets::default()
700            })
701            .build();
702        assert!(matches!(
703            result,
704            Err(AgentBuildError::CostBudgetWithoutPricing)
705        ));
706    }
707
708    /// Duplicate tool names and a missing model fail at build time.
709    #[test]
710    fn duplicate_tools_and_missing_model_fail_to_build() {
711        let duplicate = base_builder()
712            .tool_dyn(Box::new(StubTool {
713                name: "alpha",
714                description: "again",
715            }))
716            .build();
717        assert!(matches!(
718            duplicate,
719            Err(AgentBuildError::DuplicateTool { name }) if name == "alpha"
720        ));
721
722        assert!(matches!(
723            Agent::builder().build(),
724            Err(AgentBuildError::MissingModel)
725        ));
726    }
727
728    /// A `DynTool` identical to `StubTool` except that it also declares an
729    /// output schema, so a test can isolate that one field's effect on the
730    /// hash.
731    struct StubToolWithOutputSchema;
732
733    #[async_trait::async_trait]
734    impl DynTool for StubToolWithOutputSchema {
735        fn name(&self) -> &str {
736            "alpha"
737        }
738        fn description(&self) -> &str {
739            "first"
740        }
741        fn effect(&self) -> Effect {
742            Effect::Read
743        }
744        fn input_schema(&self) -> Value {
745            json!({"type": "object"})
746        }
747        fn output_schema(&self) -> Option<Value> {
748            Some(json!({"type": "object", "properties": {"receipt_id": {"type": "string"}}}))
749        }
750        async fn call_json(
751            &self,
752            _ctx: &ToolCtx,
753            input: Value,
754        ) -> Result<ToolOutcome<Value>, ToolError> {
755            Ok(ToolOutcome::Output(input))
756        }
757    }
758
759    /// A tool that declares no output schema must hash exactly as it did
760    /// before `output_schema` existed on the tool contract. This literal is
761    /// `base_builder().build().unwrap().def_hash()` captured before the
762    /// `output_schema` field was added to `ToolDescriptor`; if a change
763    /// starts emitting `"output_schema": null` (or otherwise touches a
764    /// schema-less tool's hashed shape), this test catches it, and with it,
765    /// every `agent_hash` already pinned in a checked-in graph document.
766    #[test]
767    fn a_tool_without_output_schema_hashes_unchanged() {
768        let agent = base_builder().build().unwrap();
769        assert_eq!(
770            agent.def_hash(),
771            "sha256:597a7a1f43de12e15bcaf634d2525136a7b02addbbfb2c9f5b0c095b1f02178f"
772        );
773    }
774
775    /// A declared output schema is part of the tool's contract, so it must
776    /// move the hash: the same name, description, effect, and input schema,
777    /// with only an output schema added, hashes differently.
778    #[test]
779    fn a_declared_output_schema_changes_the_hash() {
780        let without = base_builder().build().unwrap();
781        let with = Agent::builder()
782            .model(Config::new(), "test-model")
783            .system_prompt("prompt")
784            .tool_dyn(Box::new(StubToolWithOutputSchema))
785            .build()
786            .unwrap();
787        assert_ne!(without.def_hash(), with.def_hash());
788    }
789
790    /// The agent's OWN output schema changes the hash, and the same absent-key
791    /// discipline protects an agent that declares none: the literal pinned in
792    /// `a_tool_without_output_schema_hashes_unchanged` is the same agent,
793    /// still hashing to the same value after this field was added, which is
794    /// the proof that no existing agent file's identity moved.
795    #[test]
796    fn an_agent_output_schema_changes_the_hash() {
797        let without = base_builder().build().unwrap();
798        let with = base_builder()
799            .output_schema(json!({"type": "object", "required": ["score"]}))
800            .build()
801            .unwrap();
802        let differently = base_builder()
803            .output_schema(json!({"type": "object", "required": ["verdict"]}))
804            .build()
805            .unwrap();
806
807        assert_ne!(without.def_hash(), with.def_hash());
808        assert_ne!(with.def_hash(), differently.def_hash());
809        assert_eq!(without.output_schema(), None);
810        assert_eq!(
811            with.output_schema(),
812            Some(&json!({"type": "object", "required": ["score"]}))
813        );
814    }
815
816    /// An agent's schema and a tool's are separate keys in separate places:
817    /// the same schema declared on the agent and on its tool are two different
818    /// definitions, so they must not collide into one hash.
819    #[test]
820    fn an_agent_schema_and_a_tool_schema_are_not_the_same_declaration() {
821        let schema = json!({"type": "object", "properties": {"receipt_id": {"type": "string"}}});
822        let on_the_agent = base_builder().output_schema(schema).build().unwrap();
823        let on_the_tool = Agent::builder()
824            .model(Config::new(), "test-model")
825            .system_prompt("prompt")
826            .tool_dyn(Box::new(StubToolWithOutputSchema))
827            .build()
828            .unwrap();
829        assert_ne!(on_the_agent.def_hash(), on_the_tool.def_hash());
830    }
831}