Skip to main content

agents_kit/
model.rs

1//! Backend-advertised model, personality, and reasoning selectors.
2//!
3//! Selectors preserve their exact strings. Construction validates shape, not
4//! availability; use [`crate::AgentBackend::list_models`] for the selected
5//! backend's inventory. [`ModelInfo`] reports optional backend metadata without
6//! turning it into a cross-provider capability promise.
7
8//!
9//! # Supplied selector restrictions
10//!
11//! | Backend | Reasoning | Personality |
12//! | --- | --- | --- |
13//! | Codex | Exact effort advertised for the selected model | Rejected: the selected native protocol retired personality selection |
14//! | OpenCode | Portable override is Unsupported | Portable override is Unsupported |
15//! | Custom | `minimal`, `low`, `medium`, `high`, `maximum`, mapped to Provider's portable reasoning enum | Unsupported |
16//!
17//! Custom's selected Provider Model still validates whether it can honor the
18//! mapped effort. Arbitrary native efforts such as `xhigh` are not a Custom
19//! escape hatch. An omitted override stays omitted at the Agent request boundary;
20//! effective values also depend on conversation/workspace defaults. Availability
21//! of a model name or a syntactically valid selector is not execution evidence.
22//! Caller implementations may support their own advertised selectors.
23//!
24//! # Exact Codex selection
25//!
26//! This example requires the supported Codex executable on `PATH`, native
27//! authentication and access to the exact model. Building starts a subprocess;
28//! listing and prompting use the real native service, so this compiles without
29//! running as a doctest. No caller Provider is constructed for a native Agent.
30//!
31//! ```no_run
32//! use std::{num::NonZeroUsize, time::Duration};
33//! use agents_kit::{AgentBackend, AgentBuilder, AgentStorage, ConversationBackend, WorkspaceBackend};
34//! use agents_kit::config::{AgentConfig, CodexConfig};
35//! use agents_kit::conversation::NewConversation;
36//! use agents_kit::model::{ModelSelector, ReasoningEffort};
37//! use agents_kit::prompt::PromptRequest;
38//! use agents_kit::workspace::{NewWorkspace, WorkspaceContext, WorkspaceDefaults,
39//!     WorkspaceExecutionPolicy, WorkspaceNetworkAccess, WorkspaceRoot,
40//!     WorkspaceRootAccess, WorkspaceSandboxMode};
41//! use storage_kit::{StateStore,
42//!     state::config::{LanceStateStoreConfig, StateStoreConfig}};
43//! # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
44//! # runtime.block_on(async {
45//! let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
46//! let config = AgentConfig::new(CodexConfig::spawn(Duration::from_secs(15),
47//!     Duration::from_secs(90))?.into(), Duration::from_secs(60), NonZeroUsize::MIN)?;
48//! let agent = AgentBuilder::create(config, AgentStorage::new(store)).build().await?;
49//! let outcome = async {
50//!     let selected = ModelSelector::new("gpt-6-luna")?;
51//!     let effort = ReasoningEffort::medium();
52//!     let inventory = agent.list_models().await?;
53//!     let model = inventory.iter().find(|item| item.selector() == &selected)
54//!         .ok_or_else(|| agents_kit::KitError::unsupported("required exact model is unavailable"))?;
55//!     if !model.reasoning_efforts().contains(&effort) {
56//!         return Err(agents_kit::KitError::unsupported("required effort is unavailable").into());
57//!     }
58//!     let cwd = std::env::current_dir()?;
59//!     let context = WorkspaceContext::new(cwd.clone(),
60//!         vec![WorkspaceRoot::new(cwd, WorkspaceRootAccess::ReadOnly)?],
61//!         WorkspaceExecutionPolicy::new(WorkspaceSandboxMode::ReadOnly, WorkspaceNetworkAccess::Disabled))?;
62//!     let workspace = agent.new_workspace(NewWorkspace::new("review", context, WorkspaceDefaults::empty())?).await?;
63//!     let conversation = workspace.new_conversation(NewConversation::new(None, None, vec![])?).await?;
64//!     let response = conversation.prompt(PromptRequest::new("Describe the current task without changing files.")?
65//!         .with_model(selected).with_reasoning_effort(effort)
66//!         .with_timeout(Duration::from_secs(90))?).await;
67//!     let close = conversation.close().await;
68//!     let workspace_close = workspace.close().await;
69//!     let response = response?;
70//!     close?;
71//!     workspace_close?;
72//!     assert!(!response.output().trim().is_empty()); // progress, not a prescribed wording grade
73//!     Ok::<(), Box<dyn std::error::Error>>(())
74//! }.await;
75//! let shutdown = agent.shutdown().await;
76//! outcome?;
77//! shutdown?;
78//! # Ok::<(), Box<dyn std::error::Error>>(())
79//! # })?;
80//! # Ok::<(), Box<dyn std::error::Error>>(())
81//! ```
82
83use serde::{Deserialize, Deserializer, Serialize, Serializer};
84
85use crate::KitError;
86
87#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
88/// Selects one backend model by its canonical opaque value.
89///
90/// ```
91/// use agents_kit::model::ModelSelector;
92///
93/// let selector = ModelSelector::new("model-slug")?;
94/// assert_eq!(selector.as_str(), "model-slug");
95/// # Ok::<(), agents_kit::KitError>(())
96/// ```
97pub struct ModelSelector {
98    value: String,
99}
100
101impl ModelSelector {
102    /// Validates and constructs a nonempty, unpadded selector.
103    ///
104    /// # Errors
105    /// Returns [`crate::error::ErrorKind::InvalidInput`] for an empty or padded value.
106    ///
107    /// ```
108    /// use agents_kit::model::ModelSelector;
109    /// let model = ModelSelector::new("exact-model-id")?;
110    /// assert_eq!(model.as_str(), "exact-model-id");
111    /// assert!(ModelSelector::new(" model ").is_err());
112    /// # Ok::<(), agents_kit::KitError>(())
113    /// ```
114    pub fn new(value: impl Into<String>) -> Result<Self, KitError> {
115        let value = value.into();
116        if value.is_empty() || value.trim() != value {
117            return Err(KitError::invalid_input(
118                "model selector must be nonempty and unpadded",
119            ));
120        }
121        Ok(Self { value })
122    }
123
124    /// Returns the canonical selector value.
125    ///
126    /// ```
127    /// use agents_kit::model::ModelSelector;
128    /// assert_eq!(ModelSelector::new("model-id")?.as_str(), "model-id");
129    /// # Ok::<(), agents_kit::KitError>(())
130    /// ```
131    pub fn as_str(&self) -> &str {
132        &self.value
133    }
134}
135
136impl std::fmt::Display for ModelSelector {
137    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138        formatter.write_str(&self.value)
139    }
140}
141
142impl Serialize for ModelSelector {
143    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
144    where
145        S: Serializer,
146    {
147        serializer.serialize_str(&self.value)
148    }
149}
150
151impl<'de> Deserialize<'de> for ModelSelector {
152    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
153    where
154        D: Deserializer<'de>,
155    {
156        let value = String::deserialize(deserializer)?;
157        Self::new(value).map_err(serde::de::Error::custom)
158    }
159}
160
161#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
162/// Selects one backend-advertised personality by its canonical opaque value.
163///
164/// ```
165/// use agents_kit::model::PersonalitySelector;
166/// let personality = PersonalitySelector::new("concise")?;
167/// assert_eq!(personality.as_str(), "concise");
168/// # Ok::<(), agents_kit::KitError>(())
169/// ```
170pub struct PersonalitySelector {
171    value: String,
172}
173
174impl PersonalitySelector {
175    /// Validates and constructs a nonempty, unpadded personality selector.
176    ///
177    /// # Errors
178    /// Returns [`crate::error::ErrorKind::InvalidInput`] for an empty or padded value.
179    /// This does not check whether a backend advertises the personality.
180    ///
181    /// ```
182    /// use agents_kit::model::PersonalitySelector;
183    /// assert!(PersonalitySelector::new("").is_err());
184    /// assert_eq!(PersonalitySelector::new("concise")?.as_str(), "concise");
185    /// # Ok::<(), agents_kit::KitError>(())
186    /// ```
187    pub fn new(value: impl Into<String>) -> Result<Self, KitError> {
188        let value = value.into();
189        if value.is_empty() || value.trim() != value {
190            return Err(KitError::invalid_input(
191                "personality selector must be nonempty and unpadded",
192            ));
193        }
194        Ok(Self { value })
195    }
196
197    /// Returns the canonical selector value.
198    ///
199    /// ```
200    /// use agents_kit::model::PersonalitySelector;
201    /// assert_eq!(PersonalitySelector::new("concise")?.as_str(), "concise");
202    /// # Ok::<(), agents_kit::KitError>(())
203    /// ```
204    pub fn as_str(&self) -> &str {
205        &self.value
206    }
207}
208
209impl std::fmt::Display for PersonalitySelector {
210    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211        formatter.write_str(&self.value)
212    }
213}
214
215impl Serialize for PersonalitySelector {
216    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
217    where
218        S: Serializer,
219    {
220        serializer.serialize_str(&self.value)
221    }
222}
223
224impl<'de> Deserialize<'de> for PersonalitySelector {
225    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
226    where
227        D: Deserializer<'de>,
228    {
229        let value = String::deserialize(deserializer)?;
230        Self::new(value).map_err(serde::de::Error::custom)
231    }
232}
233
234#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
235/// Describes an open-ended backend reasoning-effort value.
236///
237/// The conventional constructors do not establish support on a selected model.
238/// See the [supplied selector restrictions](self#supplied-selector-restrictions).
239///
240/// ```
241/// use agents_kit::model::ReasoningEffort;
242/// assert_eq!(ReasoningEffort::high().as_str(), "high");
243/// ```
244pub struct ReasoningEffort {
245    value: String,
246}
247
248impl ReasoningEffort {
249    /// Validates and constructs a nonempty, unpadded effort value.
250    ///
251    /// # Errors
252    /// Returns [`crate::error::ErrorKind::InvalidInput`] for an empty or padded value.
253    ///
254    /// ```
255    /// use agents_kit::model::ReasoningEffort;
256    /// assert_eq!(ReasoningEffort::new("custom-effort")?.as_str(), "custom-effort");
257    /// assert!(ReasoningEffort::new(" high").is_err());
258    /// # Ok::<(), agents_kit::KitError>(())
259    /// ```
260    pub fn new(value: impl Into<String>) -> Result<Self, KitError> {
261        let value = value.into();
262        if value.is_empty() || value.trim() != value {
263            return Err(KitError::invalid_input(
264                "reasoning effort must be nonempty and unpadded",
265            ));
266        }
267        Ok(Self { value })
268    }
269
270    /// Constructs the conventional low effort value.
271    ///
272    /// ```
273    /// assert_eq!(agents_kit::model::ReasoningEffort::low().as_str(), "low");
274    /// ```
275    pub fn low() -> Self {
276        Self {
277            value: "low".to_owned(),
278        }
279    }
280
281    /// Constructs the conventional medium effort value.
282    ///
283    /// ```
284    /// assert_eq!(agents_kit::model::ReasoningEffort::medium().as_str(), "medium");
285    /// ```
286    pub fn medium() -> Self {
287        Self {
288            value: "medium".to_owned(),
289        }
290    }
291
292    /// Constructs the conventional high effort value.
293    ///
294    /// ```
295    /// assert_eq!(agents_kit::model::ReasoningEffort::high().as_str(), "high");
296    /// ```
297    pub fn high() -> Self {
298        Self {
299            value: "high".to_owned(),
300        }
301    }
302
303    /// Returns the canonical effort value.
304    ///
305    /// ```
306    /// use agents_kit::model::ReasoningEffort;
307    /// let effort = ReasoningEffort::low();
308    /// assert_eq!(effort.as_str(), "low");
309    /// ```
310    pub fn as_str(&self) -> &str {
311        &self.value
312    }
313}
314
315impl std::fmt::Display for ReasoningEffort {
316    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
317        formatter.write_str(&self.value)
318    }
319}
320
321impl Serialize for ReasoningEffort {
322    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
323    where
324        S: Serializer,
325    {
326        serializer.serialize_str(&self.value)
327    }
328}
329
330impl<'de> Deserialize<'de> for ReasoningEffort {
331    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
332    where
333        D: Deserializer<'de>,
334    {
335        let value = String::deserialize(deserializer)?;
336        Self::new(value).map_err(serde::de::Error::custom)
337    }
338}
339
340#[derive(Clone, Debug, Eq, PartialEq)]
341/// Describes one selectable model returned by agent inventory.
342///
343/// ```
344/// use agents_kit::model::{ModelInfo, ModelSelector};
345/// let info = ModelInfo::new(ModelSelector::new("model-id")?, None, vec![], true)?;
346/// assert!(info.is_default());
347/// assert_eq!(info.selector().as_str(), "model-id");
348/// # Ok::<(), agents_kit::KitError>(())
349/// ```
350pub struct ModelInfo {
351    selector: ModelSelector,
352    display_name: Option<String>,
353    reasoning_efforts: Vec<ReasoningEffort>,
354    personalities: Vec<PersonalitySelector>,
355    is_default: bool,
356}
357
358impl ModelInfo {
359    /// Constructs validated model inventory information.
360    ///
361    /// # Errors
362    /// Returns [`crate::error::ErrorKind::InvalidInput`] for a present empty or
363    /// padded display name. It performs no model discovery or network request.
364    ///
365    /// ```
366    /// use agents_kit::model::{ModelInfo, ModelSelector, ReasoningEffort};
367    /// let info = ModelInfo::new(ModelSelector::new("model-id")?,
368    ///     Some("Example model".into()), vec![ReasoningEffort::low()], false)?;
369    /// assert_eq!(info.display_name(), Some("Example model"));
370    /// # Ok::<(), agents_kit::KitError>(())
371    /// ```
372    pub fn new(
373        selector: ModelSelector,
374        display_name: Option<String>,
375        reasoning_efforts: Vec<ReasoningEffort>,
376        is_default: bool,
377    ) -> Result<Self, KitError> {
378        if display_name
379            .as_deref()
380            .is_some_and(|name| name.is_empty() || name.trim() != name)
381        {
382            return Err(KitError::invalid_input(
383                "model display name must be nonempty and unpadded when present",
384            ));
385        }
386        Ok(Self {
387            selector,
388            display_name,
389            reasoning_efforts,
390            personalities: Vec::new(),
391            is_default,
392        })
393    }
394
395    /// Replaces the ordered backend-advertised personality values.
396    ///
397    /// ```
398    /// use agents_kit::model::{ModelInfo, ModelSelector, PersonalitySelector};
399    /// let info = ModelInfo::new(ModelSelector::new("model-id")?, None, vec![], false)?
400    ///     .with_personalities(vec![PersonalitySelector::new("concise")?]);
401    /// assert_eq!(info.personalities()[0].as_str(), "concise");
402    /// # Ok::<(), agents_kit::KitError>(())
403    /// ```
404    #[must_use]
405    pub fn with_personalities(mut self, personalities: Vec<PersonalitySelector>) -> Self {
406        self.personalities = personalities;
407        self
408    }
409
410    /// Returns the selector callers can round-trip into prompts and defaults.
411    ///
412    /// ```
413    /// use agents_kit::model::{ModelInfo, ModelSelector};
414    /// let info = ModelInfo::new(ModelSelector::new("model-id")?, None, vec![], false)?;
415    /// let request = agents_kit::prompt::PromptRequest::new("Hello")?.with_model(info.selector().clone());
416    /// assert_eq!(request.model(), Some(info.selector()));
417    /// # Ok::<(), agents_kit::KitError>(())
418    /// ```
419    pub fn selector(&self) -> &ModelSelector {
420        &self.selector
421    }
422
423    /// Returns the optional caller-facing model name.
424    ///
425    /// ```
426    /// use agents_kit::model::{ModelInfo, ModelSelector};
427    /// let info = ModelInfo::new(ModelSelector::new("model-id")?, None, vec![], false)?;
428    /// assert_eq!(info.display_name(), None);
429    /// # Ok::<(), agents_kit::KitError>(())
430    /// ```
431    pub fn display_name(&self) -> Option<&str> {
432        self.display_name.as_deref()
433    }
434
435    /// Returns the advertised open-ended reasoning-effort values.
436    ///
437    /// ```
438    /// use agents_kit::model::{ModelInfo, ModelSelector, ReasoningEffort};
439    /// let info = ModelInfo::new(ModelSelector::new("model-id")?, None,
440    ///     vec![ReasoningEffort::low()], false)?;
441    /// assert_eq!(info.reasoning_efforts(), &[ReasoningEffort::low()]);
442    /// # Ok::<(), agents_kit::KitError>(())
443    /// ```
444    pub fn reasoning_efforts(&self) -> &[ReasoningEffort] {
445        &self.reasoning_efforts
446    }
447
448    /// Returns the ordered backend-advertised personality values.
449    ///
450    /// ```
451    /// use agents_kit::model::{ModelInfo, ModelSelector};
452    /// let info = ModelInfo::new(ModelSelector::new("model-id")?, None, vec![], false)?;
453    /// assert!(info.personalities().is_empty());
454    /// # Ok::<(), agents_kit::KitError>(())
455    /// ```
456    pub fn personalities(&self) -> &[PersonalitySelector] {
457        &self.personalities
458    }
459
460    /// Reports whether this is the sourced agent-wide default model.
461    ///
462    /// ```
463    /// use agents_kit::model::{ModelInfo, ModelSelector};
464    /// let info = ModelInfo::new(ModelSelector::new("model-id")?, None, vec![], true)?;
465    /// assert!(info.is_default());
466    /// # Ok::<(), agents_kit::KitError>(())
467    /// ```
468    pub fn is_default(&self) -> bool {
469        self.is_default
470    }
471}