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//! 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}