Skip to main content

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;