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