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