Skip to main content

rig_ecs/
session_log.rs

1//! The session log in the app. Each agent's log, in the session's
2//! [`SessionStore`], gets every change of the agent's [`Conversation`] and
3//! of its [`ReflectSaved`] components, such as its model, reasoning
4//! setting, system prompt, tool access and last usage. The format, its
5//! writer and its readers are rig-memory's.
6//!
7//! Each change waits in the conversation's outbox until the kernel saves
8//! it: between the stages of every pass, at the end of the frame, before a
9//! tool that may change something starts, and when a turn is stopped.
10//! Saving announces each as a [`ConversationChanged`] and queues it, and
11//! the queue is written at the end of the frame, a subagent's log before
12//! its parent's. Records are only appended; [`restore`](super::restore)
13//! reads the logs back at startup.
14
15use std::sync::Arc;
16
17use bevy_app::prelude::*;
18use bevy_ecs::lifecycle::HookContext;
19use bevy_ecs::prelude::*;
20use bevy_ecs::reflect::AppTypeRegistry;
21use bevy_ecs::resource::IsResource;
22use bevy_ecs::schedule::ScheduleLabel;
23use bevy_ecs::system::SystemParam;
24use bevy_ecs::world::DeferredWorld;
25use bevy_log::error;
26use bevy_reflect::serde::TypedReflectSerializer;
27use bevy_reflect::{CreateTypeData, Reflect, TypePath};
28use rig_core::conversation::ChangeRecord;
29use rig_memory::{Repair, SESSION, Session, SessionError, Store, Writer};
30
31use super::agent::{Agent, AgentId, Conversation, Notice, SpawnedBy};
32use super::restore::SavedKeys;
33use super::{SaveSystems, WriteLogs};
34
35/// Saves an agent component or a resource with the session: derive
36/// `Reflect` and add `Saved`, as `#[reflect(Component, Saved)]` or
37/// `#[reflect(Resource, Saved)]`.
38///
39/// - Each change is logged at the end of the frame under its type path, or
40///   the key its plugin gives it ([`SavedKeys`]): an agent's in its log, a
41///   resource in the [`SESSION`] log. A value that serializes to `null`, or
42///   a removed component, is saved as absent.
43/// - On restore it is inserted before [`SessionRestored`] exists, on the
44///   agent it was saved with, so an `On<Insert<T>>` observer re-arms what
45///   it says. A resource replaces the one its plugin inserted while the app
46///   was built.
47/// - A type reflected opaquely with serde
48///   (`#[reflect(opaque, Component, Saved, Clone, Serialize, Deserialize)]`)
49///   is saved through serde: the way to save rig-core data such as a
50///   [`MessageId`](rig_core::conversation::MessageId).
51/// - A generic type is saved only once registered. A logged type no build
52///   registers is reported and kept in the log. A value that no longer fits
53///   its type is reported and not inserted; its next save replaces it.
54#[derive(Clone)]
55pub struct ReflectSaved {
56    watch: fn(&mut App),
57}
58
59impl<T: Component + Reflect + TypePath> CreateTypeData<T> for ReflectSaved {
60    fn create_type_data(_: ()) -> Self {
61        // `Changed` also sees an immutable value inserted in place of
62        // another.
63        let watch = |app: &mut App| {
64            app.add_systems(Last, log_changed::<T>.in_set(SaveSystems));
65        };
66        Self { watch }
67    }
68}
69
70/// Logs each change of the saved component `T` on the agents or as a
71/// resource, and its removal from an agent; reflection runs only for a
72/// change. A change starts its agent's log, as a message does, unless it
73/// is a value the agent was spawned with, so a session nobody used is not
74/// kept.
75fn log_changed<T: Component + Reflect + TypePath>(
76    changed: Query<
77        (Entity, Option<&AgentId>, &T),
78        (Or<(With<Agent>, With<IsResource>)>, Changed<T>),
79    >,
80    mut removed: RemovedComponents<T>,
81    agents: Query<&AgentId, With<Agent>>,
82    spawned: Query<(), Added<Agent>>,
83    log: Res<SessionLog>,
84    registry: Res<AppTypeRegistry>,
85    keys: Res<SavedKeys>,
86) {
87    if !log.0.is_live() {
88        return;
89    }
90    let path = T::type_path();
91    let name = keys.key(path).unwrap_or(path);
92    let registry = registry.read();
93    for (entity, id, component) in &changed {
94        let value = TypedReflectSerializer::new(component.as_partial_reflect(), &registry);
95        let log_name = id.map_or(SESSION, |id| id.0.as_str());
96        let starts = id.is_some() && !spawned.contains(entity);
97        match serde_json::to_value(value) {
98            Ok(value) => log.0.save_value(log_name, name, Some(value), starts),
99            Err(failure) => error!("not logging {name}: {failure}"),
100        }
101    }
102    for entity in removed.read() {
103        if let Ok(id) = agents.get(entity)
104            && !changed.contains(entity)
105        {
106            log.0
107                .save_value(&id.0, name, None, !spawned.contains(entity));
108        }
109    }
110}
111
112/// Where the session is kept, such as a rig-memory `MemoryStore` or
113/// `DirStore`: inserted while the app is built, before it runs, by any
114/// plugin, or by [`open_session`]. Without one nothing is kept.
115#[derive(Resource, Clone)]
116pub struct SessionStore(pub Arc<dyn Store>);
117
118impl SessionStore {
119    /// The session kept in `store`.
120    pub fn new(store: impl Store) -> Self {
121        Self(Arc::new(store))
122    }
123}
124
125/// The session's agent logs: rig-memory's [`Writer`] of the records queued
126/// for them and each agent's latest-wins state. Nothing is logged before
127/// the session was restored, or without a [`SessionStore`].
128#[derive(Resource, Clone, Default)]
129pub struct SessionLog(pub(crate) Writer);
130
131impl SessionLog {
132    /// Writes every queued record ([`Writer::flush`]); an error once the
133    /// logging stopped.
134    pub(crate) fn flush(&self) -> Result<(), SessionError> {
135        self.0.flush()
136    }
137
138    /// Whether a write stored a record ([`Writer::has_written`]), such as
139    /// for a host that marks its session saved once something is.
140    pub fn has_written(&self) -> bool {
141        self.0.has_written()
142    }
143
144    /// Why the logging stopped, if a write failed ([`Writer::failure`]):
145    /// for a front end that tells the user at exit, where the [`Notice`]
146    /// sent in [`WriteLogs`] comes too late to be read.
147    pub fn failure(&self) -> Option<String> {
148        self.0.failure()
149    }
150}
151
152/// Inserted once the session was restored from its [`SessionStore`] and is
153/// logged: a choice made afterwards is the user's, such as a model to
154/// remember (`run_if(resource_exists::<SessionRestored>)`). It holds what
155/// reading the session repaired, such as a last line a crash cut short
156/// and set aside, each also shown as a notice. It stays when a log write
157/// fails later, which stops only the logging. An app without a store, or
158/// whose session was refused, never gets one.
159#[derive(Resource, Clone, Debug, Default)]
160pub struct SessionRestored {
161    /// What reading the session repaired.
162    pub repairs: Vec<Repair>,
163}
164
165/// The session [`open_session`] read, until the restore takes it.
166#[derive(Resource)]
167pub(crate) struct Opened(pub(crate) Session);
168
169/// Opens `store` for this app: reads the session it holds, refusing one it
170/// cannot read whole (made by another version of rig, or broken) before
171/// anything is written, sets each torn last line
172/// aside, and keeps what it read for the restore at startup. On success
173/// `store` is the app's [`SessionStore`] and its log writes there once the
174/// session is restored; on failure the app keeps nothing of it. An app that
175/// only inserts a `SessionStore` has it opened by the restore, which shows
176/// a refusal as a notice.
177///
178/// # Errors
179///
180/// The [`SessionError`] reading the session gave: a session from before
181/// `rig-session/2`, a log in another format, a record that cannot be read,
182/// or the store failing.
183pub fn open_session(world: &mut World, store: Arc<dyn Store>) -> Result<(), SessionError> {
184    let (writer, session) = Writer::open(store.clone())?;
185    world.insert_resource(SessionStore(store));
186    world.insert_resource(SessionLog(writer));
187    world.insert_resource(Opened(session));
188    Ok(())
189}
190
191/// A change saved from an agent's conversation, in the order made. A
192/// restore makes none; a change made while nothing is logged (no store, or
193/// before the restore) is announced all the same.
194#[derive(Message, Clone, Debug)]
195pub struct ConversationChanged {
196    /// The agent.
197    pub agent: Entity,
198    /// The change: what changed, who made it and when.
199    pub change: ChangeRecord,
200}
201
202/// Retires the agent `entity` for good: its log says so, so a restore
203/// brings back neither it nor the agents it spawned, and it is despawned
204/// with them and their work, its conversation saved first.
205#[derive(EntityEvent, Clone, Debug)]
206pub struct Retire {
207    /// The agent.
208    pub entity: Entity,
209}
210
211/// Saves the outbox of `conversation`, that of `agent` whose id is `id`,
212/// to `log`; one [`ConversationChanged`] per change saved.
213pub(crate) fn save_conversation(
214    log: &SessionLog,
215    agent: Entity,
216    id: &AgentId,
217    conversation: &mut Conversation,
218) -> Vec<ConversationChanged> {
219    let saved = log.0.save(&id.0, conversation);
220    let committed = |change| ConversationChanged { agent, change };
221    saved.into_iter().map(committed).collect()
222}
223
224/// Saves every agent conversation changed since it last ran, without
225/// marking any changed again.
226#[derive(SystemParam)]
227pub(crate) struct Drain<'w, 's> {
228    agents:
229        Query<'w, 's, (Entity, &'static AgentId, &'static mut Conversation), Changed<Conversation>>,
230    log: Res<'w, SessionLog>,
231    committed: MessageWriter<'w, ConversationChanged>,
232}
233
234impl Drain<'_, '_> {
235    /// Saves them; an agent whose conversation had changes, if any did.
236    pub(crate) fn save(&mut self) -> Option<Entity> {
237        let mut changed = None;
238        for (agent, id, mut conversation) in &mut self.agents {
239            let conversation = conversation.bypass_change_detection();
240            let saved = save_conversation(&self.log, agent, id, conversation);
241            if !saved.is_empty() {
242                changed = Some(agent);
243                self.committed.write_batch(saved);
244            }
245        }
246        changed
247    }
248}
249
250/// The schedule that saves the agent conversations changed since it last
251/// ran ([`save_now`]): right before `Update`, between the stages of each
252/// agent pass and in `Last` ([`SaveSystems`]).
253#[derive(ScheduleLabel, Clone, Debug, PartialEq, Eq, Hash)]
254pub(crate) struct SaveConversations;
255
256/// An agent whose conversation [`save_conversations`] saved changes of,
257/// since [`save_now`] last took it.
258#[derive(Resource, Default)]
259pub(crate) struct SavedOne(Option<Entity>);
260
261/// Saves the conversations changed since it last ran, in
262/// [`SaveConversations`].
263pub(crate) fn save_conversations(mut drain: Drain, mut saved: ResMut<SavedOne>) {
264    if let Some(agent) = drain.save() {
265        saved.0 = Some(agent);
266    }
267}
268
269/// Runs [`SaveConversations`]: an agent whose conversation had changes,
270/// if any did; one the run before `Update` saved is not counted.
271pub(crate) fn save_now(world: &mut World) -> Option<Entity> {
272    world.get_resource_mut::<SavedOne>()?.0 = None;
273    world.try_run_schedule(SaveConversations).ok()?;
274    world.get_resource_mut::<SavedOne>()?.0.take()
275}
276
277/// Saves what a conversation about to be replaced or removed did not save
278/// yet, so neither a value inserted over it nor its agent despawned loses
279/// a change.
280pub(crate) fn save_discarded(mut world: DeferredWorld, context: HookContext) {
281    let agent = context.entity;
282    let (Some(log), Some(id)) = (
283        world.get_resource::<SessionLog>().cloned(),
284        world.get::<AgentId>(agent).cloned(),
285    ) else {
286        return;
287    };
288    let Some(mut conversation) = world.get_mut::<Conversation>(agent) else {
289        return;
290    };
291    if conversation.unsaved().is_empty() {
292        return;
293    }
294    let conversation = conversation.bypass_change_detection();
295    for committed in save_conversation(&log, agent, &id, conversation) {
296        world.write_message(committed);
297    }
298}
299
300/// Retires an agent, as [`Retire`] says.
301pub(crate) fn retire(
302    retire: On<Retire>,
303    mut agents: Query<(&AgentId, &mut Conversation)>,
304    log: Res<SessionLog>,
305    mut committed: MessageWriter<ConversationChanged>,
306    mut commands: Commands,
307) {
308    let agent = retire.entity;
309    let Ok((id, mut conversation)) = agents.get_mut(agent) else {
310        return;
311    };
312    let conversation = conversation.bypass_change_detection();
313    committed.write_batch(save_conversation(&log, agent, id, conversation));
314    log.0.retire(&id.0);
315    commands.entity(agent).try_despawn();
316}
317
318/// Restores the session at startup, settles what a crash or restart left
319/// half done, and logs everything after.
320pub struct SessionLogPlugin;
321
322impl Plugin for SessionLogPlugin {
323    fn build(&self, app: &mut App) {
324        app.add_systems(PreStartup, super::restore::restore_session)
325            .add_systems(Startup, super::restore::reconcile)
326            .add_systems(Last, write_logs.in_set(WriteLogs))
327            .add_observer(open_child_log)
328            .init_resource::<SavedKeys>();
329    }
330
331    /// Watches every type registered as [`ReflectSaved`], once every plugin
332    /// had its say in the registry.
333    fn finish(&self, app: &mut App) {
334        let watches: Vec<fn(&mut App)> = app
335            .world()
336            .get_resource::<AppTypeRegistry>()
337            .map(|registry| {
338                let registry = registry.read();
339                let saved = registry.iter_with_data::<ReflectSaved>();
340                saved.map(|(_, saved)| saved.watch).collect()
341            })
342            .unwrap_or_default();
343        for watch in watches {
344            watch(app);
345        }
346    }
347}
348
349/// Starts the log of an agent spawned by another, so its header names
350/// that agent and a restore links them again.
351fn open_child_log(
352    spawned: On<Add<SpawnedBy>>,
353    agents: Query<(&AgentId, &SpawnedBy)>,
354    ids: Query<&AgentId>,
355    log: Res<SessionLog>,
356) {
357    if let Ok((child, parent)) = agents.get(spawned.entity)
358        && let Ok(parent) = ids.get(parent.0)
359    {
360        log.0.open_log(&child.0, Some(&parent.0));
361    }
362}
363
364/// Writes the frame's records. A failure is shown once.
365fn write_logs(log: Res<SessionLog>, mut reported: Local<bool>, mut notices: MessageWriter<Notice>) {
366    if let Err(failure) = log.flush()
367        && !*reported
368    {
369        *reported = true;
370        error!("the session log stopped: {failure}");
371        notices.write(Notice::error(
372            None,
373            format!("The session is no longer saved: {failure}"),
374        ));
375    }
376}
377
378#[cfg(test)]
379pub(crate) mod tests;