Skip to main content

gpui_kit/agent/
tool_call.rs

1//! One invocation of one tool: what was called, with what, what came back,
2//! and how long it took.
3//!
4//! # A refusal is not an absence and not an error
5//!
6//! Five states, five renderings, and one published name each. A host that
7//! declined to run a tool made a decision, so [`ToolCallState::Refused`]
8//! carries the host's reason and reads as a decision. It is not
9//! [`ToolCallState::Failed`], which blames the tool for something it did; and
10//! it is not [`ToolOutput::Silent`], which claims the tool ran and returned
11//! nothing. A card that rendered any of those three the same way would be
12//! telling the reader something nobody established.
13//!
14//! # Nothing here is this crate's data
15//!
16//! Arguments, results, errors and refusal reasons are the caller's, may be
17//! long, and may be secret. So a [`ToolBody`] publishes its *shape* — how many
18//! lines it holds, and how many of them are on screen — and never its text,
19//! the rule [`DescriptionValue::Redacted`](crate::display::description_list::DescriptionValue)
20//! already keeps. Truncation is stated in the same words it publishes: a body
21//! cut to three of twelve lines says so where the cut happens, rather than
22//! fading out and leaving the reader to guess how much is missing.
23//!
24//! An elapsed time is a string the caller already wrote, for the reason
25//! [`Timeline`](crate::display::timeline::Timeline) takes one: turning a
26//! duration into words is locale work this crate does not do.
27
28use std::rc::Rc;
29
30use gpui::{App, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div, px};
31use gpui_kit_assets::Icon as Glyph;
32use gpui_kit_semantics::{NodeSpec, Role, Semantic};
33use gpui_kit_theme::{ActiveTheme, Elevation, Radius, Space, Surface, TextTone, Theme, TypeScale};
34
35use crate::controls::button::Button;
36use crate::display::badge::{Badge, Tone};
37use crate::display::icon::{Icon as IconView, IconTone};
38use crate::display::status::Callout;
39use crate::foundation::{Ident, Sizable, StyledExt, text};
40use crate::strings::{ActiveStrings, StringKey};
41
42type RetryHandler = Rc<dyn Fn(&mut Window, &mut App)>;
43
44/// A block of caller-owned text — the arguments a tool was called with, or
45/// what it returned — with an optional limit on how much of it is drawn.
46///
47/// The body keeps the whole text so the component can say how much it left
48/// out, and draws only what the limit allows. Nothing but the measurement
49/// reaches the semantic tree.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct ToolBody {
52    text: SharedString,
53    max_lines: Option<usize>,
54}
55
56impl ToolBody {
57    pub fn new(text: impl Into<SharedString>) -> Self {
58        Self {
59            text: text.into(),
60            max_lines: None,
61        }
62    }
63
64    /// Draws at most this many lines and states how many there are in total.
65    ///
66    /// A limit of zero is one line: a body that drew nothing at all and called
67    /// itself truncated would be indistinguishable from a body nobody passed.
68    pub fn max_lines(mut self, lines: usize) -> Self {
69        self.max_lines = Some(lines.max(1));
70        self
71    }
72
73    /// The text as the caller gave it. It is the caller's own data coming
74    /// back, and the component never publishes it.
75    pub fn text(&self) -> &SharedString {
76        &self.text
77    }
78
79    pub fn line_count(&self) -> usize {
80        self.text.lines().count().max(1)
81    }
82
83    pub fn shown_line_count(&self) -> usize {
84        match self.max_lines {
85            Some(limit) => limit.min(self.line_count()),
86            None => self.line_count(),
87        }
88    }
89
90    pub fn is_truncated(&self) -> bool {
91        self.shown_line_count() < self.line_count()
92    }
93
94    /// The lines that are actually drawn.
95    fn shown_lines(&self) -> Vec<SharedString> {
96        self.text
97            .lines()
98            .take(self.shown_line_count())
99            .map(|line| SharedString::from(line.to_string()))
100            .collect()
101    }
102
103    /// The measurement a reader is shown and a node publishes: never the text.
104    pub fn shape(&self, cx: &App) -> SharedString {
105        let total = self.line_count();
106        if self.is_truncated() {
107            return cx.strings().format(
108                StringKey::AgentTruncated,
109                &[&self.shown_line_count().to_string(), &total.to_string()],
110            );
111        }
112        if total == 1 {
113            cx.strings().text(StringKey::AgentLinesOne)
114        } else {
115            cx.strings()
116                .format(StringKey::AgentLinesMany, &[&total.to_string()])
117        }
118    }
119}
120
121impl From<SharedString> for ToolBody {
122    fn from(value: SharedString) -> Self {
123        Self::new(value)
124    }
125}
126
127impl From<&'static str> for ToolBody {
128    fn from(value: &'static str) -> Self {
129        Self::new(value)
130    }
131}
132
133impl From<String> for ToolBody {
134    fn from(value: String) -> Self {
135        Self::new(value)
136    }
137}
138
139/// What a tool that ran to completion gave back.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub enum ToolOutput {
142    Body(ToolBody),
143    /// The tool ran and returned nothing. This is a fact about a completed
144    /// call, which is why it can only be reached through
145    /// [`ToolCallState::Succeeded`] and never stands in for a refusal.
146    Silent,
147}
148
149/// Where one invocation has got to.
150///
151/// The five are a closed set rather than flags, so a card cannot be running
152/// and failed at once, and cannot be refused without a reason: the host that
153/// declined has to say what it declined.
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub enum ToolCallState {
156    /// Nothing has run. Somebody has to allow it first.
157    PendingApproval,
158    Running,
159    Succeeded {
160        output: ToolOutput,
161    },
162    /// The tool ran and did not succeed, in the host's own words.
163    Failed {
164        error: SharedString,
165    },
166    /// The host declined to run it, in the host's own words. Nothing ran, so
167    /// there is no result and no error — only a decision.
168    Refused {
169        reason: SharedString,
170    },
171}
172
173impl ToolCallState {
174    pub fn succeeded(output: impl Into<ToolBody>) -> Self {
175        Self::Succeeded {
176            output: ToolOutput::Body(output.into()),
177        }
178    }
179
180    /// A call that ran, succeeded, and returned nothing.
181    pub fn succeeded_silently() -> Self {
182        Self::Succeeded {
183            output: ToolOutput::Silent,
184        }
185    }
186
187    pub fn failed(error: impl Into<SharedString>) -> Self {
188        Self::Failed {
189            error: error.into(),
190        }
191    }
192
193    pub fn refused(reason: impl Into<SharedString>) -> Self {
194        Self::Refused {
195            reason: reason.into(),
196        }
197    }
198
199    /// The name the semantic node publishes, so a test asserts the state the
200    /// card reported rather than the colour it painted.
201    pub fn as_str(&self) -> &'static str {
202        match self {
203            Self::PendingApproval => "pending-approval",
204            Self::Running => "running",
205            Self::Succeeded { .. } => "succeeded",
206            Self::Failed { .. } => "failed",
207            Self::Refused { .. } => "refused",
208        }
209    }
210
211    /// Why this call did not produce a result, when that is a thing the host
212    /// said. A failure's error and a refusal's reason are different sentences
213    /// and are never rendered in the same place.
214    pub fn reason(&self) -> Option<&SharedString> {
215        match self {
216            Self::Failed { error } => Some(error),
217            Self::Refused { reason } => Some(reason),
218            _ => None,
219        }
220    }
221
222    /// Whether anything ran, which is what makes an elapsed time meaningful.
223    fn ran(&self) -> bool {
224        matches!(
225            self,
226            Self::Running | Self::Succeeded { .. } | Self::Failed { .. }
227        )
228    }
229
230    fn tone(&self) -> Tone {
231        match self {
232            Self::PendingApproval => Tone::Info,
233            Self::Running => Tone::Accent,
234            Self::Succeeded { .. } => Tone::Success,
235            Self::Failed { .. } => Tone::Danger,
236            Self::Refused { .. } => Tone::Warning,
237        }
238    }
239
240    fn glyph(&self) -> Glyph {
241        match self {
242            Self::PendingApproval => Glyph::Key,
243            Self::Running => Glyph::Refresh,
244            Self::Succeeded { .. } => Glyph::Check,
245            Self::Failed { .. } => Glyph::Danger,
246            Self::Refused { .. } => Glyph::CloseCircle,
247        }
248    }
249
250    fn key(&self) -> StringKey {
251        match self {
252            Self::PendingApproval => StringKey::AgentPendingApproval,
253            Self::Running => StringKey::AgentRunning,
254            Self::Succeeded { .. } => StringKey::AgentSucceeded,
255            Self::Failed { .. } => StringKey::AgentFailed,
256            Self::Refused { .. } => StringKey::AgentDeclined,
257        }
258    }
259}
260
261/// How long a call took, as far as anyone has said.
262///
263/// A duration nobody stated is a state, not a zero — the rule
264/// [`TransportDuration`](crate::content::transport::TransportDuration) keeps
265/// for the same reason.
266#[derive(Debug, Clone, PartialEq, Eq, Default)]
267pub enum Elapsed {
268    /// A duration the caller has already put into words.
269    Took(SharedString),
270    #[default]
271    Unknown,
272}
273
274impl Elapsed {
275    pub fn as_str(&self) -> &'static str {
276        match self {
277            Self::Took(_) => "known",
278            Self::Unknown => "unknown",
279        }
280    }
281
282    fn shown(&self, cx: &App) -> SharedString {
283        match self {
284            Self::Took(took) => took.clone(),
285            Self::Unknown => cx.strings().text(StringKey::AgentElapsedUnknown),
286        }
287    }
288}
289
290impl From<SharedString> for Elapsed {
291    fn from(value: SharedString) -> Self {
292        Self::Took(value)
293    }
294}
295
296impl From<&'static str> for Elapsed {
297    fn from(value: &'static str) -> Self {
298        Self::Took(SharedString::new_static(value))
299    }
300}
301
302impl From<String> for Elapsed {
303    fn from(value: String) -> Self {
304        Self::Took(SharedString::from(value))
305    }
306}
307
308/// One tool invocation, in whichever of its five states holds.
309#[derive(IntoElement)]
310pub struct ToolCallCard {
311    ident: Ident,
312    tool: SharedString,
313    arguments: Option<ToolBody>,
314    state: ToolCallState,
315    elapsed: Elapsed,
316    on_retry: Option<RetryHandler>,
317}
318
319impl std::fmt::Debug for ToolCallCard {
320    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
321        formatter
322            .debug_struct("ToolCallCard")
323            .field("ident", &self.ident)
324            .field("tool", &self.tool)
325            .field("state", &self.state)
326            .field("elapsed", &self.elapsed)
327            .field("has_arguments", &self.arguments.is_some())
328            .field("has_handler", &self.on_retry.is_some())
329            .finish()
330    }
331}
332
333impl ToolCallCard {
334    /// `tool` is the name of the tool as the host knows it. This crate has no
335    /// catalogue of tools and invents no display name for one.
336    pub fn new(ident: impl Into<Ident>, tool: impl Into<SharedString>) -> Self {
337        Self {
338            ident: ident.into(),
339            tool: tool.into(),
340            arguments: None,
341            state: ToolCallState::PendingApproval,
342            elapsed: Elapsed::Unknown,
343            on_retry: None,
344        }
345    }
346
347    /// What the tool was called with.
348    pub fn arguments(mut self, arguments: impl Into<ToolBody>) -> Self {
349        self.arguments = Some(arguments.into());
350        self
351    }
352
353    pub fn state(mut self, state: ToolCallState) -> Self {
354        self.state = state;
355        self
356    }
357
358    pub fn elapsed(mut self, elapsed: impl Into<Elapsed>) -> Self {
359        self.elapsed = elapsed.into();
360        self
361    }
362
363    /// Offers one control that reports the call should be tried again.
364    ///
365    /// It exists only on a failed call, and nothing is retried here: the card
366    /// runs no tool, so a host that refuses the request simply keeps showing
367    /// the failure that still holds.
368    pub fn on_retry(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
369        self.on_retry = Some(Rc::new(handler));
370        self
371    }
372
373    fn retryable(&self) -> bool {
374        matches!(self.state, ToolCallState::Failed { .. }) && self.on_retry.is_some()
375    }
376}
377
378impl RenderOnce for ToolCallCard {
379    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
380        let theme = cx.theme().clone();
381        let ident = self.ident.clone();
382        let tone = self.state.tone();
383        let retryable = self.retryable();
384
385        let header = div()
386            .row()
387            .w_full()
388            .gap_token(&theme, Space::Sm)
389            .child({
390                let mark = IconView::new(self.state.glyph())
391                    .small()
392                    .tone(icon_tone(tone));
393                // A running call turns. The glyph is a rotation arrow, and
394                // a still one reads as a call that has jammed.
395                match self.state {
396                    ToolCallState::Running => mark.spinning(ident.child("state.mark")),
397                    _ => mark,
398                }
399            })
400            .child(
401                text(&theme, TypeScale::Code, self.tool.clone())
402                    .flex_1()
403                    .min_w_0()
404                    .font_family(theme.typography.mono.clone()),
405            )
406            .child(
407                Badge::new(cx.strings().text(self.state.key()))
408                    .tone(tone)
409                    .id(ident.child("state")),
410            )
411            .children(self.state.ran().then(|| {
412                let words = self.elapsed.shown(cx);
413                text(&theme, TypeScale::Caption, words.clone())
414                    .flex_none()
415                    .text_tone(
416                        &theme,
417                        match self.elapsed {
418                            Elapsed::Took(_) => TextTone::Muted,
419                            Elapsed::Unknown => TextTone::Faint,
420                        },
421                    )
422                    .semantic_in(
423                        cx,
424                        NodeSpec::new(ident.child("elapsed").semantic_id(), Role::Text)
425                            .parent(ident.semantic_id())
426                            .text(words)
427                            .value(match &self.elapsed {
428                                Elapsed::Took(took) => took.clone(),
429                                Elapsed::Unknown => SharedString::new_static("unknown"),
430                            }),
431                    )
432            }));
433
434        let arguments = self.arguments.map(|body| {
435            block(
436                &ident.child("arguments"),
437                &ident,
438                &theme,
439                cx.strings().text(StringKey::AgentArguments),
440                &body,
441                cx,
442            )
443        });
444
445        // Each state renders its own consequence, and only its own: a failure
446        // shows an error, a refusal shows a decision, and neither is ever
447        // drawn as the other or as an absence of output.
448        let outcome = match &self.state {
449            ToolCallState::PendingApproval | ToolCallState::Running => None,
450            ToolCallState::Succeeded { output } => Some(match output {
451                ToolOutput::Body(body) => block(
452                    &ident.child("result"),
453                    &ident,
454                    &theme,
455                    cx.strings().text(StringKey::AgentResult),
456                    body,
457                    cx,
458                ),
459                ToolOutput::Silent => {
460                    let words = cx.strings().text(StringKey::AgentNoOutput);
461                    text(&theme, TypeScale::Caption, words.clone())
462                        .text_tone(&theme, TextTone::Muted)
463                        .semantic_in(
464                            cx,
465                            NodeSpec::new(ident.child("result").semantic_id(), Role::Text)
466                                .parent(ident.semantic_id())
467                                .text(words)
468                                .value("nothing"),
469                        )
470                        .into_any_element()
471                }
472            }),
473            ToolCallState::Failed { error } => Some(
474                Callout::new(error.clone(), Tone::Danger)
475                    .id(ident.child("error"))
476                    .into_any_element(),
477            ),
478            ToolCallState::Refused { reason } => Some(
479                Callout::new(reason.clone(), Tone::Warning)
480                    .id(ident.child("refusal"))
481                    .into_any_element(),
482            ),
483        };
484
485        let retry = self.on_retry.filter(|_| retryable).map(|handler| {
486            Button::new(ident.child("retry"))
487                .label(cx.strings().text(StringKey::TryAgain))
488                .secondary()
489                .small()
490                .semantic_parent(ident.semantic_id())
491                .on_click(move |window, cx| handler(window, cx))
492        });
493
494        div()
495            .w_full()
496            .column()
497            .gap_token(&theme, Space::Sm)
498            .p_token(&theme, Space::Md)
499            .radius(&theme, Radius::Card)
500            .frame(&theme, Surface::Panel, Elevation::Raised)
501            .child(header)
502            .children(arguments)
503            .children(outcome)
504            .children(retry.map(|retry| div().row().child(retry)))
505            .semantic_in(
506                cx,
507                NodeSpec::new(ident.semantic_id(), Role::Group)
508                    .text(self.tool.clone())
509                    .value(self.state.as_str())
510                    .busy(matches!(self.state, ToolCallState::Running)),
511            )
512    }
513}
514
515/// One labelled block of caller-owned text, drawn to its limit and publishing
516/// only its measurement.
517fn block(
518    ident: &Ident,
519    card: &Ident,
520    theme: &Theme,
521    label: SharedString,
522    body: &ToolBody,
523    cx: &mut App,
524) -> gpui::AnyElement {
525    let shape = body.shape(cx);
526    div()
527        .w_full()
528        .column()
529        .gap(px(2.0))
530        .child(
531            div()
532                .row()
533                .justify_between()
534                .gap_token(theme, Space::Sm)
535                .child(
536                    text(theme, TypeScale::Caption, label.clone())
537                        .text_tone(theme, TextTone::Faint),
538                )
539                // The measurement is stated whether or not anything was cut,
540                // so "there is more" is read off the same line every time
541                // rather than appearing only when it is bad news.
542                .child(
543                    text(theme, TypeScale::Caption, shape.clone())
544                        .text_tone(theme, TextTone::Faint),
545                ),
546        )
547        .child(
548            div()
549                .w_full()
550                .px_token(theme, Space::Sm)
551                .py(px(2.0))
552                .radius(theme, Radius::Small)
553                .surface(theme, Surface::Raised)
554                .font_family(theme.typography.mono.clone())
555                // One element per line, the way a fenced block is drawn: a
556                // single element would run the whole body together.
557                .children(body.shown_lines().into_iter().map(|line| {
558                    text(theme, TypeScale::Code, line).text_tone(theme, TextTone::Muted)
559                })),
560        )
561        .semantic_in(
562            cx,
563            NodeSpec::new(ident.semantic_id(), Role::Text)
564                .parent(card.semantic_id())
565                .text(label)
566                // The shape, never the text: this is somebody's data and may
567                // be a credential.
568                .value(shape),
569        )
570        .into_any_element()
571}
572
573fn icon_tone(tone: Tone) -> IconTone {
574    match tone {
575        Tone::Neutral => IconTone::Muted,
576        Tone::Accent => IconTone::Accent,
577        Tone::Success => IconTone::Success,
578        Tone::Warning => IconTone::Warning,
579        Tone::Danger => IconTone::Danger,
580        Tone::Info => IconTone::Info,
581    }
582}
583
584#[cfg(test)]
585mod tests {
586    use super::*;
587
588    #[test]
589    fn a_body_within_its_limit_is_not_truncated() {
590        let body = ToolBody::new("one\ntwo").max_lines(4);
591        assert_eq!(body.line_count(), 2);
592        assert_eq!(body.shown_line_count(), 2);
593        assert!(!body.is_truncated());
594        assert_eq!(body.shown_lines(), vec!["one", "two"]);
595    }
596
597    #[test]
598    fn a_body_past_its_limit_keeps_the_whole_count() {
599        let body = ToolBody::new("one\ntwo\nthree").max_lines(1);
600        assert!(body.is_truncated());
601        assert_eq!(body.shown_line_count(), 1);
602        assert_eq!(body.line_count(), 3);
603        assert_eq!(body.shown_lines(), vec!["one"]);
604        assert_eq!(
605            body.text().as_ref(),
606            "one\ntwo\nthree",
607            "the caller's data comes back whole; only the drawing is cut"
608        );
609    }
610
611    #[test]
612    fn a_limit_of_zero_still_draws_a_line() {
613        let body = ToolBody::new("one\ntwo").max_lines(0);
614        assert_eq!(body.shown_line_count(), 1);
615    }
616
617    #[test]
618    fn every_state_publishes_its_own_name() {
619        let names = [
620            ToolCallState::PendingApproval.as_str(),
621            ToolCallState::Running.as_str(),
622            ToolCallState::succeeded_silently().as_str(),
623            ToolCallState::failed("boom").as_str(),
624            ToolCallState::refused("no").as_str(),
625        ];
626        let mut unique = names.to_vec();
627        unique.sort_unstable();
628        unique.dedup();
629        assert_eq!(unique.len(), names.len());
630    }
631
632    #[test]
633    fn only_a_call_that_ran_has_a_duration_to_report() {
634        assert!(!ToolCallState::PendingApproval.ran());
635        assert!(!ToolCallState::refused("declined").ran());
636        assert!(ToolCallState::Running.ran());
637        assert!(ToolCallState::succeeded_silently().ran());
638        assert!(ToolCallState::failed("boom").ran());
639    }
640}