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}