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