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}