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}