rig_ecs/agent/
checkpoint.rs1use bevy_reflect::Reflect;
16use std::collections::BTreeMap;
17
18use bevy_ecs::prelude::*;
19use serde::{Deserialize, Serialize};
20
21use super::{Failed, Run, Settled};
22
23#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Reflect)]
26#[reflect(Component)]
27pub struct ToolTurnCommit {
28 pub turn: usize,
30}
31
32#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Reflect)]
34#[relationship(relationship_target = AssistantForTurns)]
35#[reflect(Component)]
36pub struct TurnAssistant(pub Entity);
37
38#[derive(Component, Debug, Default, Reflect)]
40#[relationship_target(relationship = TurnAssistant)]
41#[reflect(Component)]
42pub struct AssistantForTurns(Vec<Entity>);
43
44#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Reflect)]
46#[relationship(relationship_target = ResultsForTurns)]
47#[reflect(Component)]
48pub struct TurnResults(pub Entity);
49
50#[derive(Component, Debug, Default, Reflect)]
52#[relationship_target(relationship = TurnResults)]
53#[reflect(Component)]
54pub struct ResultsForTurns(Vec<Entity>);
55
56#[derive(Component, Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, Reflect)]
59#[reflect(Component)]
60pub struct ToolTurnHolds(BTreeMap<String, usize>);
61
62impl ToolTurnHolds {
63 pub fn owners(&self) -> impl Iterator<Item = (&str, usize)> {
65 self.0.iter().map(|(owner, turn)| (owner.as_str(), *turn))
66 }
67
68 pub fn blocks(&self, committed_turn: usize) -> bool {
70 self.0.values().any(|turn| *turn <= committed_turn)
71 }
72}
73
74#[derive(Event, Debug, Clone, Copy)]
80pub struct ToolTurnCommitted {
81 pub run: Entity,
83 pub turn: Entity,
85}
86
87#[derive(Debug, thiserror::Error, PartialEq, Eq)]
89pub enum TurnHoldError {
90 #[error("checkpoint hold needs a live run")]
92 NotLiveRun,
93 #[error("checkpoint hold owner must not be empty")]
95 EmptyOwner,
96 #[error("checkpoint turn must be at least one")]
98 ZeroTurn,
99}
100
101pub fn hold_after_tool_turn(
107 world: &mut World,
108 run: Entity,
109 owner: impl Into<String>,
110 turn: usize,
111) -> Result<bool, TurnHoldError> {
112 if world.get::<Run>(run).is_none()
113 || world.get::<Failed>(run).is_some()
114 || world.get::<Settled>(run).is_some()
115 {
116 return Err(TurnHoldError::NotLiveRun);
117 }
118 let owner = owner.into();
119 if owner.is_empty() {
120 return Err(TurnHoldError::EmptyOwner);
121 }
122 if turn == 0 {
123 return Err(TurnHoldError::ZeroTurn);
124 }
125 let mut holds = world.get::<ToolTurnHolds>(run).cloned().unwrap_or_default();
126 let previous = holds.0.get(&owner).copied();
127 if previous.is_some_and(|previous| previous <= turn) {
128 return Ok(false);
129 }
130 holds.0.insert(owner, turn);
131 world.entity_mut(run).insert(holds);
132 Ok(true)
133}
134
135pub fn release_tool_turn_hold(
141 world: &mut World,
142 run: Entity,
143 owner: &str,
144) -> Result<bool, TurnHoldError> {
145 if world.get::<Run>(run).is_none() {
146 return Err(TurnHoldError::NotLiveRun);
147 }
148 if owner.is_empty() {
149 return Err(TurnHoldError::EmptyOwner);
150 }
151 let Some(mut holds) = world.get_mut::<ToolTurnHolds>(run) else {
152 return Ok(false);
153 };
154 let removed = holds.0.remove(owner).is_some();
155 if holds.0.is_empty() {
156 world.entity_mut(run).remove::<ToolTurnHolds>();
157 }
158 Ok(removed)
159}
160
161pub(crate) fn validate(
166 world: &World,
167 entity: Entity,
168) -> Result<(), crate::checkpoint::CheckpointError> {
169 use super::{Batch, Cursor, Role, Turn, Utterance};
170 let refused = crate::checkpoint::CheckpointError::InvalidGraph;
171 if let Some(holds) = world.get::<ToolTurnHolds>(entity)
172 && (world.get::<Run>(entity).is_none()
173 || holds
174 .owners()
175 .any(|(owner, turn)| owner.is_empty() || turn == 0))
176 {
177 return Err(refused("invalid tool-turn hold"));
178 }
179 let assistant = world.get::<TurnAssistant>(entity);
180 let results = world.get::<TurnResults>(entity);
181 let commit = world.get::<ToolTurnCommit>(entity);
182 if assistant.is_none() && results.is_none() && commit.is_none() {
183 return Ok(());
184 }
185 let run = world
186 .get::<ChildOf>(entity)
187 .map(ChildOf::parent)
188 .filter(|run| world.get::<Run>(*run).is_some())
189 .ok_or(refused("tool-turn link has no run"))?;
190 if world.get::<Turn>(entity).is_none() {
191 return Err(refused("tool-turn link is not on a turn"));
192 }
193 for (target, role) in [
194 (assistant.map(|link| link.0), Role::Assistant),
195 (results.map(|link| link.0), Role::User),
196 ] {
197 if let Some(target) = target
198 && (world.get::<ChildOf>(target).map(ChildOf::parent) != Some(run)
199 || world.get::<Utterance>(target).is_none()
200 || world.get::<Role>(target) != Some(&role))
201 {
202 return Err(refused("tool-turn utterance has invalid ownership or role"));
203 }
204 }
205 if let Some(commit) = commit {
206 if commit.turn == 0
207 || assistant.is_none()
208 || results.is_none()
209 || world.get::<Batch>(entity).is_some()
210 || world
211 .get::<Cursor>(run)
212 .is_none_or(|cursor| commit.turn > cursor.turn)
213 {
214 return Err(refused("invalid committed tool turn"));
215 }
216 } else if results.is_some() {
217 return Err(refused("tool results have no commit"));
218 }
219 Ok(())
220}