Skip to main content

rig_ecs/
model.rs

1//! An agent's model: the catalog model it chose, the reasoning setting it
2//! sends, and how the choice is connected. The catalog and how its models
3//! are reached are rig-core's [`Connector`].
4
5use std::fmt;
6use std::sync::Arc;
7
8use bevy_ecs::prelude::*;
9use bevy_reflect::prelude::*;
10use rig_core::catalog::{Connector, ModelSpec};
11use rig_core::completion::Reasoning;
12use serde::{Deserialize, Serialize};
13
14use super::agent::{ActiveTurn, Agent, Notice};
15use super::effects::Handler;
16use super::session_log::ReflectSaved;
17
18/// The models agents can use and how each is reached: rig's built-in
19/// catalog by default. A plugin adds a sign-in with
20/// [`Connector::add_sign_in`], or replaces the catalog. After a change, the
21/// next frame connects the agents whose chosen model was not usable before.
22/// The [`Connector`] is `Send` and `Sync` except on browser wasm, where its
23/// providers may hold JavaScript values, and Bevy asks for both on every
24/// target.
25#[derive(Resource, Clone, Default)]
26pub struct Models(pub Connector);
27
28// SAFETY: browser wasm without the `atomics` target feature runs one
29// thread, so `Models` is never sent to or shared with another one.
30#[cfg(all(
31    target_arch = "wasm32",
32    target_os = "unknown",
33    not(target_feature = "atomics")
34))]
35unsafe impl Send for Models {}
36// SAFETY: as for `Send`: there is no other thread.
37#[cfg(all(
38    target_arch = "wasm32",
39    target_os = "unknown",
40    not(target_feature = "atomics")
41))]
42unsafe impl Sync for Models {}
43
44impl Models {
45    /// A connection to the catalog model `reference` (`vendor/model`), such
46    /// as for a turn or a call a plugin sends to another model than its
47    /// agent's; or why it cannot be used.
48    pub fn connect(&self, reference: &str) -> Result<Connection, String> {
49        match self.0.connect(reference) {
50            Ok((spec, handler)) => Ok(Connection {
51                spec,
52                handler: Handler(handler),
53            }),
54            Err(why) => Err(format!("Cannot use {reference}: {why}.")),
55        }
56    }
57}
58
59/// The chosen catalog model, as `vendor/model`. It never changes in place:
60/// choosing another model inserts a new one, which rebuilds the agent's
61/// [`Connection`]. Saved with the session as a string under rig-memory's
62/// `MODEL` (its type path, `rig::model`), which a library program reads.
63#[derive(Component, Reflect, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
64#[component(immutable)]
65#[reflect(Component, Saved, Clone)]
66#[type_path = "rig"]
67#[type_name = "model"]
68#[serde(transparent)]
69pub struct ModelChoice(pub String);
70
71impl ModelChoice {
72    /// The model and reasoning setting of an agent spawned by an agent with
73    /// `parent`'s: `model`, a reference to a catalog model that calls tools,
74    /// when given, else the parent's; the reasoning setting named `effort`
75    /// when given, else the parent's with the parent's model.
76    pub fn inherit(
77        models: &Connector,
78        parent: (Option<&ModelChoice>, ReasoningSetting),
79        model: Option<&str>,
80        effort: Option<&str>,
81    ) -> Result<(Option<ModelChoice>, ReasoningSetting), String> {
82        fn asked(text: Option<&str>) -> Option<&str> {
83            text.map(str::trim).filter(|text| !text.is_empty())
84        }
85        let resolve = |text: &str| models.catalog().resolve(text).map(|found| found.spec);
86        let model = match asked(model) {
87            Some(asked) => {
88                let spec = resolve(asked).map_err(|why| format!("{why}; use vendor/model"))?;
89                if !spec.tools {
90                    return Err(format!("{asked} cannot call tools"));
91                }
92                Some(ModelChoice(spec.reference()))
93            }
94            None => parent.0.cloned(),
95        };
96        let effort = match (asked(effort), &model) {
97            (Some(name), Some(model)) => {
98                let spec = resolve(&model.0).map_err(|why| why.to_string())?;
99                let named = spec.reasoning.named(name);
100                ReasoningSetting(
101                    named
102                        .map_err(|why| format!("{}: {why}", spec.display_name))?
103                        .reasoning,
104                )
105            }
106            _ if model.is_some() && model.as_ref() == parent.0 => parent.1,
107            _ => ReasoningSetting::default(),
108        };
109        Ok((model, effort))
110    }
111}
112
113/// The connected model of an agent with a [`ModelChoice`]: its catalog
114/// entry and the effect handler every model call is dispatched to. Built
115/// from the environment once per choice, and not saved: restoring the
116/// choice rebuilds it.
117#[derive(Component, Reflect, Clone)]
118#[reflect(opaque, Component, Clone)]
119pub struct Connection {
120    /// The model's catalog entry.
121    pub spec: Arc<ModelSpec>,
122    /// The model as an effect handler.
123    pub handler: Handler,
124}
125
126/// The reasoning setting sent with each request, or `None` for the
127/// provider's default. It never changes in place. Saved with the session.
128#[derive(
129    Component, Reflect, Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize,
130)]
131#[component(immutable)]
132#[reflect(opaque, Component, Saved, Default, Clone, Serialize, Deserialize)]
133pub struct ReasoningSetting(pub Option<Reasoning>);
134
135/// `default`, `off`, the level's word, or `1024 tokens`.
136impl fmt::Display for ReasoningSetting {
137    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138        match self.0 {
139            None => f.write_str("default"),
140            Some(Reasoning::Off) => f.write_str("off"),
141            Some(Reasoning::Effort(level)) => f.write_str(level.as_str()),
142            Some(Reasoning::Budget { tokens }) => write!(f, "{tokens} tokens"),
143            Some(_) => f.write_str("custom"),
144        }
145    }
146}
147
148/// Choose the agent's model by catalog reference (`vendor/model`).
149#[derive(EntityEvent, Reflect, Clone, Debug)]
150#[reflect(Event, Clone, Debug)]
151pub struct SetModel {
152    /// The agent.
153    pub entity: Entity,
154    /// The catalog reference.
155    pub model: String,
156}
157
158/// Choose the agent's reasoning setting; `None` is the provider default.
159#[derive(EntityEvent, Reflect, Clone, Debug)]
160#[reflect(Event, Clone, Debug)]
161pub struct SetReasoning {
162    /// The agent.
163    pub entity: Entity,
164    /// The setting.
165    pub effort: ReasoningSetting,
166}
167
168/// Chooses the agent's model: a known catalog model replaces the agent's
169/// [`ModelChoice`], which [`connect`] connects; an unknown one keeps it,
170/// with an error notice naming the closest models and the one kept.
171pub(crate) fn on_set_model(
172    set: On<SetModel>,
173    agents: Query<(Has<ActiveTurn>, Option<&ModelChoice>), With<Agent>>,
174    models: Res<Models>,
175    mut commands: Commands,
176    mut notices: MessageWriter<Notice>,
177) {
178    let Ok((busy, current)) = agents.get(set.entity) else {
179        return;
180    };
181    if busy {
182        notices.write(Notice::turn_running(set.entity));
183        return;
184    }
185    match models.0.catalog().resolve(&set.model) {
186        Ok(found) => {
187            commands
188                .entity(set.entity)
189                .insert(ModelChoice(found.spec.reference()));
190        }
191        Err(why) => {
192            let stop = if why.suggestions.is_empty() { "." } else { "" };
193            let kept = match current {
194                Some(ModelChoice(model)) => format!(" Still on {model}."),
195                None => String::new(),
196            };
197            let text = format!("Not picked: {why}{stop}{kept}");
198            notices.write(Notice::error(set.entity, text));
199        }
200    }
201}
202
203/// Connects an agent whose [`ModelChoice`] was inserted, by [`SetModel`],
204/// by restoring a session or by a plugin, unless it is connected to that
205/// model already: each choice is connected, or refused, once, so requests
206/// never re-resolve the provider. Only a change of a connected agent's
207/// model is announced: spawning and restoring an agent are silent, since
208/// views show its model anyway.
209pub(crate) fn connect(
210    inserted: On<Insert<ModelChoice>>,
211    agents: Query<(&ModelChoice, Option<&Connection>)>,
212    models: Res<Models>,
213    mut commands: Commands,
214    mut notices: MessageWriter<Notice>,
215) {
216    let agent = inserted.entity;
217    let Ok((choice, connected)) = agents.get(agent) else {
218        return;
219    };
220    if connected.is_some_and(|connection| connection.spec.reference() == choice.0) {
221        return;
222    }
223    match models.connect(&choice.0) {
224        Ok(connection) => {
225            if connected.is_some() {
226                let model = format!("Model: {} ({}).", connection.spec.display_name, choice.0);
227                notices.write(Notice::info(agent, model));
228            }
229            commands.entity(agent).insert(connection);
230        }
231        Err(why) => {
232            commands.entity(agent).remove::<Connection>();
233            notices.write(Notice::error(agent, why));
234        }
235    }
236}
237
238/// Connects each agent whose [`ModelChoice`] is not connected once the
239/// [`Models`] changed, such as a model a plugin found or added to the
240/// catalog after the choice was restored, quietly as [`connect`] does for a
241/// restored choice. A choice still not usable is left as it is: its
242/// refusal was shown when it was made.
243pub(crate) fn reconnect(
244    agents: Query<(Entity, &ModelChoice), (With<Agent>, Without<Connection>)>,
245    models: Res<Models>,
246    mut commands: Commands,
247) {
248    for (agent, choice) in &agents {
249        if let Ok(connection) = models.connect(&choice.0) {
250            commands.entity(agent).try_insert(connection);
251        }
252    }
253}
254
255/// Resets a reasoning setting the agent's model does not take, whichever
256/// of the two was inserted last.
257pub(crate) fn check_effort(
258    inserted: On<Insert<(Connection, ReasoningSetting)>>,
259    agents: Query<(&Connection, &ReasoningSetting)>,
260    mut commands: Commands,
261    mut notices: MessageWriter<Notice>,
262) {
263    let agent = inserted.entity;
264    let Ok((Connection { spec, .. }, effort)) = agents.get(agent) else {
265        return;
266    };
267    if let Err(refusal) = spec.validate(&spec.default_options(effort.0)) {
268        commands.entity(agent).insert(ReasoningSetting(None));
269        notices.write(Notice::info(
270            agent,
271            format!("Reasoning reset to default: {refusal}."),
272        ));
273    }
274}
275
276/// Sets the agent's reasoning setting after checking it against the model.
277pub(crate) fn on_set_reasoning(
278    set: On<SetReasoning>,
279    agents: Query<(Option<&Connection>, Has<ActiveTurn>), With<Agent>>,
280    mut commands: Commands,
281    mut notices: MessageWriter<Notice>,
282) {
283    let Ok((connection, busy)) = agents.get(set.entity) else {
284        return;
285    };
286    if busy {
287        notices.write(Notice::turn_running(set.entity));
288        return;
289    }
290    let Some(connection) = connection else {
291        notices.write(Notice::no_model(set.entity));
292        return;
293    };
294    let spec = &connection.spec;
295    match spec.validate(&spec.default_options(set.effort.0)) {
296        Ok(()) => {
297            commands.entity(set.entity).insert(set.effort);
298            notices.write(Notice::info(
299                set.entity,
300                format!("Reasoning: {}.", set.effort),
301            ));
302        }
303        Err(refusal) => {
304            notices.write(Notice::error(set.entity, format!("{refusal}.")));
305        }
306    }
307}