rig_harness/ask.rs
1//! Questions for the user that any front answers: an approval, a choice or a
2//! line of text. An [`Ask`] is an open effect, recorded like any and answered
3//! by deciding it ([`Decision`]) with [`Answer::outcome`]; the first decision
4//! wins. A plugin that waits on the answer spawns it as a hold
5//! (`HoldOf(call)`) on what it asks about, so stopping the turn closes it in
6//! every front at once.
7//!
8//! A front that shows questions spawns an [`Answerer`] for its agents, shows
9//! the open ones (`Query<(Entity, &Ask), Without<Outcome>>`) and answers in
10//! Gate after [`AskSystems`], so a line the user typed wins. A plugin asks only
11//! when [`Answerers::can_answer`], else it applies its own policy, such as
12//! refusing. While a question that takes text is open, the user's next line to
13//! the agent answers it, unless the question is [`inline`](Ask::inline). The
14//! plugin guide's
15//! [questions section](crate::plugin_guide#asking-the-user-questions-and-pickers)
16//! has the rest.
17//!
18//! ```
19//! use rig_harness::prelude::*;
20//!
21//! /// Asks whether to keep the agent's changes, when someone can answer.
22//! fn ask(agent: Entity, answerers: Answerers, mut commands: Commands) {
23//! if answerers.can_answer(Some(agent)) {
24//! let ask = Ask::yes_no("Keep the changes?").body("3 files changed");
25//! commands.spawn((ask, ForAgent(agent)));
26//! }
27//! }
28//!
29//! /// Reads each answer as it comes.
30//! fn answered(asks: Query<(&Ask, &Outcome), Added<Outcome>>) {
31//! for (ask, outcome) in &asks {
32//! let keep = Answer::of(outcome).is_some_and(|answer| answer.is("yes"));
33//! println!("{}: {keep}", ask.title);
34//! }
35//! }
36//! ```
37
38use bevy_app::prelude::*;
39use bevy_ecs::lifecycle::HookContext;
40use bevy_ecs::prelude::*;
41use bevy_ecs::system::SystemParam;
42use bevy_ecs::world::DeferredWorld;
43use rig_core::conversation::AuthorKind;
44use rig_core::effect::{Answered as EffectOutcome, Decider, EffectKind};
45use rig_ecs::inbox::Incoming;
46use rig_ecs::stage::{
47 AgentPass, Decision, Effect, EffectRequest, ForAgent, IncomingMessages, Outcome, PluginEffects,
48 ReadyToStart, Settled, Stage,
49};
50
51use crate::commands::CommandSystems;
52use serde::{Deserialize, Serialize};
53
54/// A question for the user. Spawn it with the agent it is about
55/// (`ForAgent`), if any; it is an open effect, recorded with its answer.
56/// To change an open question, spawn the new one and insert
57/// `SupersededBy(new)` on the old one: the old one settles as superseded
58/// and a front shows the new one in its place, keeping the user's ticks.
59/// An old one that is a hold releases what it holds as it settles, so move
60/// the hold first.
61#[derive(Component, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
62#[component(on_add = request)]
63#[require(Name = Name::new("ask"))]
64pub struct Ask {
65 /// The question, in a line.
66 pub title: String,
67 /// What it is about, such as a command to run; may be empty.
68 pub body: String,
69 /// The answers to choose from, in order; the first is the default.
70 pub choices: Vec<Choice>,
71 /// Whether a line of text answers it too, alone or as a note on a
72 /// choice.
73 pub free_text: bool,
74 /// The text a typed answer starts from, for the user to edit.
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub initial: Option<String>,
77 /// Whether a typed answer may span lines.
78 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
79 pub multiline: bool,
80 /// Whether a front shows it beside the work, leaving the keys to the
81 /// input, rather than in front of it. The user's next line to the
82 /// agent is then a message, not its answer.
83 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
84 pub inline: bool,
85 /// Whether the user may take several of its choices, which the
86 /// [`Answer`] then holds.
87 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
88 pub multiple: bool,
89}
90
91/// One answer an [`Ask`] offers.
92#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
93pub struct Choice {
94 /// What an [`Answer`] names it by, such as `yes`.
95 pub id: String,
96 /// What the user reads, such as "Yes, run it".
97 pub label: String,
98 /// Whether it starts ticked, in a question that takes several
99 /// ([`Ask::multiple`]).
100 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
101 pub checked: bool,
102 /// More about it than its label, such as a call's full arguments, which
103 /// a front shows when the user asks.
104 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub more: Option<String>,
106}
107
108impl Choice {
109 /// The choice `id`, read as `label`.
110 pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
111 Self {
112 id: id.into(),
113 label: label.into(),
114 checked: false,
115 more: None,
116 }
117 }
118
119 /// The same choice, ticked at first.
120 #[must_use]
121 pub fn checked(mut self) -> Self {
122 self.checked = true;
123 self
124 }
125
126 /// The same choice, with `text` to show when the user asks for more.
127 #[must_use]
128 pub fn more(mut self, text: impl Into<String>) -> Self {
129 self.more = Some(text.into());
130 self
131 }
132}
133
134impl Ask {
135 /// A question titled `title`, without choices yet.
136 pub fn new(title: impl Into<String>) -> Self {
137 Self {
138 title: title.into(),
139 body: String::new(),
140 choices: Vec::new(),
141 free_text: false,
142 initial: None,
143 multiline: false,
144 inline: false,
145 multiple: false,
146 }
147 }
148
149 /// A question answered `yes` or `no`.
150 pub fn yes_no(title: impl Into<String>) -> Self {
151 Self::new(title).choice("yes", "Yes").choice("no", "No")
152 }
153
154 /// The same question, about `body`.
155 pub fn body(mut self, body: impl Into<String>) -> Self {
156 self.body = body.into();
157 self
158 }
159
160 /// The same question, offering `id` read as `label`.
161 pub fn choice(self, id: impl Into<String>, label: impl Into<String>) -> Self {
162 self.with(Choice::new(id, label))
163 }
164
165 /// The same question, offering `choice`.
166 pub fn with(mut self, choice: Choice) -> Self {
167 self.choices.push(choice);
168 self
169 }
170
171 /// The same question, also answered by a line of text.
172 pub fn free_text(mut self) -> Self {
173 self.free_text = true;
174 self
175 }
176
177 /// The same question, answered by text that starts from `text`, which
178 /// the user edits.
179 pub fn initial(mut self, text: impl Into<String>) -> Self {
180 self.initial = Some(text.into());
181 self.free_text = true;
182 self
183 }
184
185 /// The same question, answered by text that may span lines.
186 pub fn multiline(mut self) -> Self {
187 self.multiline = true;
188 self.free_text = true;
189 self
190 }
191
192 /// The same question, shown beside the work: the user keeps the input
193 /// and answers it when they choose.
194 pub fn inline(mut self) -> Self {
195 self.inline = true;
196 self
197 }
198
199 /// The same question, answered by any number of its choices, none
200 /// included.
201 pub fn multiple(mut self) -> Self {
202 self.multiple = true;
203 self
204 }
205}
206
207/// How an [`Ask`] was answered: the choices taken and the text typed,
208/// either or both, such as a choice with a note on it.
209#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
210pub struct Answer {
211 /// The choices taken, by id in the question's order: one, or any
212 /// number for a question that takes several ([`Ask::multiple`]); none
213 /// for an answer only typed.
214 #[serde(default, skip_serializing_if = "Vec::is_empty")]
215 pub choices: Vec<String>,
216 /// The text the user typed, alone or as a note on the choices, for a
217 /// question that takes text ([`Ask::free_text`]).
218 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub text: Option<String>,
220}
221
222impl Answer {
223 /// The answer that takes the choice `id`.
224 pub fn choice(id: impl Into<String>) -> Self {
225 Self::choices([id])
226 }
227
228 /// The answer that takes the choices `ids`, in the question's order.
229 pub fn choices(ids: impl IntoIterator<Item = impl Into<String>>) -> Self {
230 Self {
231 choices: ids.into_iter().map(Into::into).collect(),
232 text: None,
233 }
234 }
235
236 /// The answer the user typed as `text`.
237 pub fn text(text: impl Into<String>) -> Self {
238 Self::default().with_text(text)
239 }
240
241 /// The same answer, with `text` typed as its note.
242 pub fn with_text(mut self, text: impl Into<String>) -> Self {
243 self.text = Some(text.into());
244 self
245 }
246
247 /// The outcome that answers an `Ask` so.
248 pub fn outcome(&self) -> Outcome {
249 Outcome::custom(serde_json::to_value(self).unwrap_or_default())
250 }
251
252 /// The answer an `Ask`'s outcome holds: `None` for one cancelled or
253 /// refused.
254 pub fn of(outcome: &Outcome) -> Option<Self> {
255 match &outcome.0 {
256 Ok(EffectOutcome::Custom { payload }) => Self::deserialize(payload).ok(),
257 _ => None,
258 }
259 }
260
261 /// Whether it takes the choice `id`.
262 pub fn is(&self, id: &str) -> bool {
263 self.choices.iter().any(|choice| choice == id)
264 }
265}
266
267/// On a front's entity: it shows and answers questions about this agent,
268/// or with `None`, about every agent and the app's own.
269#[derive(Component, Clone, Copy, Debug, Default, PartialEq, Eq)]
270pub struct Answerer {
271 /// The agent, or `None` for all.
272 pub agent: Option<Entity>,
273}
274
275/// Who can answer questions, for a plugin deciding whether to ask.
276#[derive(SystemParam)]
277pub struct Answerers<'w, 's> {
278 answerers: Query<'w, 's, &'static Answerer>,
279}
280
281impl Answerers<'_, '_> {
282 /// Whether some front answers questions about `agent` (`None`: the
283 /// app's own questions).
284 pub fn can_answer(&self, agent: Option<Entity>) -> bool {
285 self.answerers
286 .iter()
287 .any(|answerer| answerer.agent.is_none() || answerer.agent == agent)
288 }
289}
290
291/// Takes a line the user types as the answer to a question that takes text.
292pub struct AskPlugin;
293
294/// The Gate system that answers a question with the line the user typed,
295/// after [`CommandSystems`]. A front that answers questions in Gate runs
296/// after it.
297#[derive(SystemSet, Clone, Copy, Debug, PartialEq, Eq, Hash)]
298pub struct AskSystems;
299
300impl Plugin for AskPlugin {
301 fn build(&self, app: &mut App) {
302 app.configure_sets(
303 AgentPass,
304 AskSystems.in_set(Stage::Gate).after(CommandSystems),
305 )
306 .add_systems(AgentPass, answer_with_text.in_set(AskSystems));
307 }
308}
309
310/// Makes the question an open effect the kernel records.
311fn request(mut world: DeferredWorld, context: HookContext) {
312 let Some(ask) = world.get::<Ask>(context.entity) else {
313 return;
314 };
315 let kind = EffectKind::Custom {
316 kind: "ask".into(),
317 payload: serde_json::to_value(ask).unwrap_or_default(),
318 };
319 world
320 .commands()
321 .entity(context.entity)
322 .insert_if_new(EffectRequest {
323 kind,
324 handler: None,
325 });
326}
327
328/// The user's next line to an agent with a question open that takes text
329/// answers the oldest such question that is not inline, and goes no
330/// further.
331fn answer_with_text(
332 mut typed: Query<(&Incoming, &ForAgent, &mut Decision), (ReadyToStart, IncomingMessages)>,
333 mut asks: Query<
334 (Entity, &Ask, &ForAgent, &Effect, &mut Decision),
335 (PluginEffects, Without<Outcome>, Without<Settled>),
336 >,
337) {
338 for (incoming, &ForAgent(agent), mut taken) in &mut typed {
339 let typed_by_user = incoming.author.kind == AuthorKind::User;
340 if !typed_by_user || incoming.command().is_some() || taken.is_decided() {
341 continue;
342 }
343 let open = asks
344 .iter()
345 .filter(|(_, question, to, _, decision)| {
346 question.free_text && !question.inline && to.0 == agent && !decision.is_decided()
347 })
348 .min_by_key(|(_, _, _, effect, _)| effect.id())
349 .map(|(ask, ..)| ask);
350 let Some(Ok((.., mut decision))) = open.map(|ask| asks.get_mut(ask)) else {
351 continue;
352 };
353 let answer = Answer::text(incoming.text()).outcome();
354 decision.decide(Decider::Person, answer);
355 taken.decide(Decider::plugin("rig-harness"), Outcome::custom("answer"));
356 }
357}
358
359#[cfg(test)]
360mod tests;