rig_ecs/agent.rs
1//! The agent entity: its components, the calls it owns, and the requests a
2//! view sends it.
3
4use std::ops::{Deref, DerefMut};
5
6use bevy_ecs::prelude::*;
7use bevy_reflect::prelude::*;
8use rig_core::completion::{Message, Usage};
9use rig_core::conversation::{self, MessageId, short_id};
10use rig_core::effect::EffectId;
11use rig_memory::Anchor;
12use serde::{Deserialize, Serialize};
13
14use super::model::ReasoningSetting;
15use super::session_log::{ReflectSaved, save_discarded};
16use super::stage::moved;
17use super::turn::{NO_MODEL, Recovery, start_turn};
18
19/// Marks an agent. Spawning it adds every per-agent component with its
20/// default, including a fresh [`AgentId`]. An agent has no
21/// [`ModelChoice`](super::model::ModelChoice) until one is picked.
22#[derive(Component, Reflect, Default)]
23#[reflect(Component)]
24#[require(
25 AgentId,
26 Conversation,
27 ReasoningSetting,
28 LastUsage,
29 SystemPrompt,
30 ToolAccess
31)]
32pub struct Agent;
33
34/// The agent's stable id, used in its log's name, effect scopes and logs.
35/// `Entity` ids are not stable across a restart; this one is. It never
36/// changes after spawn.
37#[derive(Component, Reflect, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
38#[component(immutable)]
39#[reflect(Component)]
40#[serde(transparent)]
41pub struct AgentId(pub String);
42
43impl Default for AgentId {
44 fn default() -> Self {
45 Self(uuid::Uuid::new_v4().to_string())
46 }
47}
48
49impl From<AgentId> for String {
50 fn from(id: AgentId) -> Self {
51 id.0
52 }
53}
54
55impl AgentId {
56 /// The first characters of the id, enough to tell agents apart.
57 pub fn short(&self) -> &str {
58 short_id(&self.0)
59 }
60
61 /// The agent among `agents` whose id this is, such as in a
62 /// `Query<(Entity, &AgentId)>`: ids name agents across a restart, where
63 /// entities do not, as in an [`Author`](conversation::Author)'s `from`.
64 pub fn find_in<'a>(
65 &self,
66 agents: impl IntoIterator<Item = (Entity, &'a Self)>,
67 ) -> Option<Entity> {
68 let mut agents = agents.into_iter();
69 agents.find_map(|(agent, id)| (id == self).then_some(agent))
70 }
71}
72
73/// The key the agent's model requests share a provider's prompt cache
74/// under (`prompt_cache_key`); empty for the agent's [`AgentId`]. An agent
75/// forked from another keeps the other's ([`ForkAgent`](super::fork::ForkAgent)),
76/// so the fork's requests read what the source's wrote. Saved with the
77/// session.
78#[derive(Component, Reflect, Clone, Debug, Default, PartialEq, Eq)]
79#[reflect(Component, Saved, Default, Clone, Debug)]
80pub struct CacheKey(pub String);
81
82impl CacheKey {
83 /// The key the requests of the agent `id`, with `key`, are sent under.
84 pub fn of<'a>(key: Option<&'a Self>, id: &'a AgentId) -> &'a str {
85 match key {
86 Some(Self(key)) if !key.is_empty() => key,
87 _ => &id.0,
88 }
89 }
90}
91
92/// The agent that spawned this one, such as the agent whose tool call
93/// started it. Despawning that agent despawns this one, and stopping its
94/// turn ([`StopTurn`]) stops this one's too: work meant to outlive its
95/// parent is spawned without it. The kernel gives it no other meaning: a
96/// restore links the agents again by their logs' headers, and views list
97/// agents by it.
98#[derive(Component, Reflect, Debug)]
99#[reflect(Component)]
100#[relationship(relationship_target = Spawned)]
101pub struct SpawnedBy(pub Entity);
102
103/// The agents this one spawned, in the order it spawned them.
104#[derive(Component, Reflect, Debug)]
105#[reflect(Component)]
106#[relationship_target(relationship = SpawnedBy, linked_spawn)]
107pub struct Spawned(Vec<Entity>);
108
109/// The effect the agent's model calls are recorded under, such as the
110/// tool call that asked it for the work it does now, so the effect log
111/// nests that work under the call. An agent without one records its model
112/// calls at the top level. Not saved: effect ids do not outlive a run.
113#[derive(Component, Reflect, Clone, Copy, Debug, Serialize, Deserialize)]
114#[reflect(opaque, Component, Clone, Debug, Serialize, Deserialize)]
115#[serde(transparent)]
116pub struct EffectParent(pub EffectId);
117
118/// The agent's conversation: rig-core's
119/// [`Conversation`](conversation::Conversation), which it derefs to, so a
120/// plugin reads and changes it with that type's methods. Requests send it as
121/// it is.
122///
123/// Each change waits in the conversation's outbox until the kernel saves it
124/// and announces it as a
125/// [`ConversationChanged`](super::session_log::ConversationChanged). A value
126/// inserted in place of the agent's own is saved as one reset, after the
127/// changes the old one had not saved. Saved through serde.
128///
129/// ```
130/// use rig_ecs::prelude::*;
131///
132/// // `/undo`: the user's last message and everything after it go.
133/// fn undo(mut agents: Query<&mut Conversation, With<Agent>>) {
134/// for mut conversation in &mut agents {
135/// let last = conversation.entries().rev().find(|entry| entry.is_users_own());
136/// if let Some(last) = last.map(|entry| entry.index) {
137/// conversation.splice(last.., [], Author::plugin("undo"));
138/// }
139/// }
140/// }
141/// # let _ = undo;
142/// ```
143#[derive(Component, Reflect, Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
144#[component(on_discard = save_discarded)]
145#[reflect(opaque, Component, Default, Clone, Serialize, Deserialize)]
146#[serde(transparent)]
147pub struct Conversation(pub conversation::Conversation);
148
149impl Deref for Conversation {
150 type Target = conversation::Conversation;
151
152 fn deref(&self) -> &Self::Target {
153 &self.0
154 }
155}
156
157impl DerefMut for Conversation {
158 fn deref_mut(&mut self) -> &mut Self::Target {
159 &mut self.0
160 }
161}
162
163impl From<conversation::Conversation> for Conversation {
164 fn from(conversation: conversation::Conversation) -> Self {
165 Self(conversation)
166 }
167}
168
169/// The usage of the agent's last model reply that reported its tokens,
170/// with the last entry of the conversation it counts (rig-memory's
171/// [`Anchor`]), `None` before one did. Only the kernel writes it, from
172/// what the provider reported. Saved with the session.
173#[derive(Component, Reflect, Clone, Copy, Debug, Default, Serialize, Deserialize)]
174#[reflect(opaque, Component, Saved, Default, Clone, Serialize, Deserialize)]
175#[serde(transparent)]
176pub struct LastUsage(pub Option<Anchor>);
177
178impl LastUsage {
179 /// What the provider reported for the last reply.
180 pub fn usage(&self) -> Option<&Usage> {
181 self.0.as_ref().map(|anchor| &anchor.usage)
182 }
183
184 /// The tokens the last reply read and wrote, which the next request
185 /// sends again, when it reported them.
186 pub fn context(&self) -> Option<u64> {
187 self.usage()?.context_tokens()
188 }
189}
190
191/// The agent's own part of its system prompt: who it is and how it works.
192/// Each request's prompt adds the app's
193/// [`PromptSection`](super::prompt::PromptSection)s and the agent's own.
194/// Empty, the agent has
195/// none of its own, and the app's plugins may give it one in `Prepare`,
196/// as rig-basics gives rig's. Saved with the session, as `null` when empty,
197/// so a restored agent without its own gets what the build that restores
198/// it gives.
199#[derive(Component, Reflect, Clone, Default, Serialize, Deserialize)]
200#[reflect(opaque, Component, Saved, Default, Clone, Serialize, Deserialize)]
201#[serde(from = "Option<String>", into = "Option<String>")]
202pub struct SystemPrompt(pub String);
203
204impl From<SystemPrompt> for Option<String> {
205 fn from(prompt: SystemPrompt) -> Self {
206 (!prompt.0.is_empty()).then_some(prompt.0)
207 }
208}
209
210impl From<Option<String>> for SystemPrompt {
211 fn from(prompt: Option<String>) -> Self {
212 Self(prompt.unwrap_or_default())
213 }
214}
215
216/// Which registered tools the agent may call. Saved with the session.
217#[derive(Component, Reflect, Clone, Default, Serialize, Deserialize)]
218#[reflect(opaque, Component, Saved, Default, Clone, Serialize, Deserialize)]
219pub enum ToolAccess {
220 /// Every registered tool.
221 #[default]
222 All,
223 /// Only the tools named.
224 Only(Vec<String>),
225}
226
227impl ToolAccess {
228 /// Whether the tool `name` may be called.
229 pub fn allows(&self, name: &str) -> bool {
230 match self {
231 Self::All => true,
232 Self::Only(names) => names.iter().any(|allowed| allowed == name),
233 }
234 }
235}
236
237/// A running turn of the agent it names, from a user message to the reply
238/// that ends it; at most one per agent. It ends as its [`TurnOutcome`] says:
239/// Commit then removes this, so the agent is idle, and marks the turn
240/// [`Settled`](super::stage::Settled). The turn stays [`ForAgent`] its agent,
241/// and goes with it. Its retries are counted in a [`Recovery`]; its `Name` is
242/// `turn` unless spawned with another.
243///
244/// [`ForAgent`]: super::stage::ForAgent
245#[derive(Component, Reflect, Debug)]
246#[reflect(Component)]
247#[relationship(relationship_target = ActiveTurn)]
248#[require(Recovery, Turn, Name = Name::new("turn"))]
249pub struct TurnOf(pub Entity);
250
251/// Where a turn stands, on the turn. The kernel fills it in when the turn
252/// is spawned, from the turn's record in its agent's conversation
253/// ([`TurnRecord`](conversation::TurnRecord)), and keeps it.
254#[derive(Component, Reflect, Clone, Debug, Default)]
255#[component(on_add = start_turn)]
256#[reflect(Component, Default)]
257pub struct Turn {
258 /// How many turns its agent started before this one, over the whole
259 /// session.
260 pub index: u64,
261 /// Where the turn begins in its agent's [`Conversation`]: the id of
262 /// the user's message it answers, or, when the conversation does not
263 /// end in one, of the first message the turn adds. Its index now is
264 /// [`conversation::Conversation::start_of`] it, as splices move messages.
265 /// The entries it answers are
266 /// [`conversation::Conversation::asked`], the message that asked for it
267 /// [`conversation::Conversation::request`].
268 /// Not reflected: rig-core's types are not `Reflect`.
269 #[reflect(ignore)]
270 pub start: MessageId,
271 /// The model call whose reply's tool calls run now, their effects'
272 /// parent, until their results are committed. Other calls of the turn
273 /// keep it open but are never committed as tool results.
274 #[reflect(ignore)]
275 pub reply: Option<EffectId>,
276}
277
278/// The agent's running turn. An agent with it is busy.
279#[derive(Component, Reflect, Debug)]
280#[reflect(Component)]
281#[relationship_target(relationship = TurnOf, linked_spawn)]
282pub struct ActiveTurn(Entity);
283
284impl ActiveTurn {
285 /// The turn entity.
286 pub fn turn(&self) -> Entity {
287 self.0
288 }
289}
290
291/// A model or tool call of the turn it names. Despawning the turn despawns
292/// its calls, which cancels their tasks.
293#[derive(Component, Reflect, Debug)]
294#[reflect(Component)]
295#[relationship(relationship_target = Calls)]
296pub struct CallOf(pub Entity);
297
298/// The calls of a turn, in the order they were spawned: the tool calls of
299/// a reply in the reply's order.
300#[derive(Component, Reflect, Debug)]
301#[reflect(Component)]
302#[relationship_target(relationship = CallOf, linked_spawn)]
303pub struct Calls(Vec<Entity>);
304
305/// Text streamed so far by an in-flight model call, for views. A plugin
306/// that continues a reply in a successor call may start the successor's
307/// `Partial` with the text so far: views show one growing reply, and a
308/// stop saves what the user saw.
309#[derive(Component, Reflect, Default)]
310#[reflect(Component, Default)]
311pub struct Partial {
312 /// Answer text.
313 pub text: String,
314 /// Reasoning text.
315 pub reasoning: String,
316}
317
318/// Send the agent's conversation to its model again as it stands: after a
319/// turn that failed on a transient error and kept the user's message, or
320/// one that stopped before the model answered the last tool results.
321#[derive(EntityEvent, Reflect, Clone, Debug)]
322#[reflect(Event, Clone, Debug)]
323pub struct Retry {
324 /// The agent.
325 pub entity: Entity,
326}
327
328/// Stop the agent's running turn at once, and the running turns of every
329/// agent below it in [`Spawned`], even when the agent itself is idle:
330/// stopping follows ancestry, so work meant to outlive its parent is
331/// spawned without [`SpawnedBy`]. Each turn ends [`TurnOutcome::Stopped`]
332/// with `reason`, the text its reply streamed so far is kept, every call of
333/// it that has not settled is answered as stopped (rig-core's
334/// [`not_finished`](rig_core::tool::feedback::not_finished)), and whatever
335/// held them is despawned. It cannot be reviewed or held.
336#[derive(EntityEvent, Reflect, Clone, Debug)]
337#[reflect(Event, Clone, Debug)]
338pub struct StopTurn {
339 /// The agent.
340 pub entity: Entity,
341 /// Why, when it is not the user who stops it.
342 pub reason: Option<String>,
343}
344
345impl StopTurn {
346 /// The user stopping `agent`'s turn.
347 pub fn new(agent: Entity) -> Self {
348 Self {
349 entity: agent,
350 reason: None,
351 }
352 }
353}
354
355/// How a [`Notice`] is shown and logged.
356#[derive(Reflect, Clone, Copy, Debug, Default, PartialEq, Eq)]
357pub enum NoticeLevel {
358 /// Information.
359 #[default]
360 Info,
361 /// Something went wrong that did not stop anything, such as a refused
362 /// key with another one used instead: shown like an error, but never a
363 /// failure.
364 Warn,
365 /// Something failed or was refused.
366 Error,
367}
368
369/// A line for the user, shown by views. A system reads the notices sent
370/// before it in the frame and, from the frame before, those sent after it.
371/// So one sent in `Last`, such as the session log's write failure (in
372/// [`WriteLogs`](crate::WriteLogs)), is read a frame late and, on the exit
373/// frame, only by a system ordered after its sender.
374#[derive(Message, Clone, Debug)]
375pub struct Notice {
376 /// The agent it is about, or `None` when it is about the whole app.
377 pub agent: Option<Entity>,
378 /// The text.
379 pub text: String,
380 /// Whether it informs, warns or reports a failure.
381 pub level: NoticeLevel,
382}
383
384impl Notice {
385 fn new(agent: Option<Entity>, text: String, level: NoticeLevel) -> Self {
386 Self { agent, text, level }
387 }
388
389 /// Information about `agent`, or about the whole app with `None`.
390 pub fn info(agent: impl Into<Option<Entity>>, text: impl Into<String>) -> Self {
391 Self::new(agent.into(), text.into(), NoticeLevel::Info)
392 }
393
394 /// A warning about `agent`, or about the whole app with `None`:
395 /// something went wrong, but nothing failed.
396 pub fn warn(agent: impl Into<Option<Entity>>, text: impl Into<String>) -> Self {
397 Self::new(agent.into(), text.into(), NoticeLevel::Warn)
398 }
399
400 /// A failure or refusal concerning `agent`, or the whole app with
401 /// `None`.
402 pub fn error(agent: impl Into<Option<Entity>>, text: impl Into<String>) -> Self {
403 Self::new(agent.into(), text.into(), NoticeLevel::Error)
404 }
405
406 /// The refusal of what would change `agent`, or the whole app with
407 /// `None`, while a turn runs: the rest of the turn would go on with
408 /// something it did not start with.
409 pub fn turn_running(agent: impl Into<Option<Entity>>) -> Self {
410 Self::info(agent, "A turn is running; stop it first.")
411 }
412
413 /// The refusal of what needs a model when `agent` has none.
414 pub fn no_model(agent: Entity) -> Self {
415 Self::info(agent, NO_MODEL)
416 }
417}
418
419/// How a turn ends, on the turn.
420///
421/// - **Proposed:** the kernel proposes `Answered` or `Failed` in Commit.
422/// - **Reviewed:** in the next pass Review may edit it in place
423/// (`Query<&mut TurnOutcome, With<TurnOf>>`) or hold it
424/// ([`HoldOf`](super::stage::HoldOf) the turn). A `Queue` or `Steer`
425/// message the agent accepts takes an `Answered` proposal away, and its
426/// next [`TurnCall`](super::turn::TurnCall), once admitted, takes any;
427/// the holds on a proposal taken away are despawned with it.
428/// - **Stopped:** a plugin may insert `Failed` or `Stopped` at any time with
429/// a plain `insert`, so no pending proposal loses it. The turn's calls not
430/// started never start, the others are answered as stopped, and the turn
431/// ends once they settled.
432///
433/// A turn that ended is [`Settled`](super::stage::Settled) with it until the
434/// next frame: `On<Add<Settled>>` or `Added<Settled>` readers of
435/// `(&TurnOutcome, &ForAgent)` see each turn's last outcome once.
436#[derive(Component, Clone, Debug)]
437#[component(on_insert = moved::<TurnOutcome>, on_remove = moved::<TurnOutcome>)]
438pub enum TurnOutcome {
439 /// The model answered: its last message, which ended the turn.
440 Answered(Message),
441 /// The turn failed, for the reason given.
442 Failed(String),
443 /// The turn was stopped before an answer: by the user when no reason
444 /// is given, or on exit.
445 Stopped(Option<String>),
446}
447
448impl TurnOutcome {
449 /// Why the calls of a turn that ends this way did not finish, when it
450 /// stops them: `Failed` and `Stopped` do, `Answered` does not. A tool
451 /// call reads it through rig-core's
452 /// [`not_finished`](rig_core::tool::feedback::not_finished).
453 pub(crate) fn stops(&self) -> Option<String> {
454 match self {
455 Self::Answered(_) => None,
456 Self::Failed(why) => Some(format!("the turn failed: {why}")),
457 Self::Stopped(None) => Some("the user stopped the turn".to_owned()),
458 Self::Stopped(Some(why)) => Some(why.clone()),
459 }
460 }
461}