Skip to main content

llimorse_chat/
tools.rs

1//! Helpers for tools, for use with llimorse-chat
2
3use crate::ChatHistory;
4use crate::history::HistoryEntryType;
5use crate::ui::{self, AgentId, SubagentId};
6use anyhow::Result;
7use llimorse::line_format::ToolCall;
8use llimorse::{Agent, StreamingChunk};
9use llimorse_tools::{GateableToolParams, ToolGate};
10use std::result;
11use std::sync::atomic::{AtomicUsize, Ordering};
12use std::sync::{Arc, Mutex};
13use tokio::sync::{mpsc, oneshot};
14
15/// A [`ToolGate`] that sends a permission request to the UI and waits for the user's decision.
16///
17/// Each call to [`ToolGate::permitted()`] sends a
18/// [`ui::Notification::RequestPermission`] across the UI-notification channel and blocks until
19/// the UI sends the user's decision back across the request's oneshot channel. If the UI is
20/// dropped before a decision is made (e.g. the application exits while a prompt is pending),
21/// the request is treated as a rejection.
22#[derive(Debug, Clone)]
23pub struct UserToolGate {
24    /// The UI-notification channel to send permission requests across.
25    notifications: mpsc::UnboundedSender<ui::Notification>,
26}
27
28impl UserToolGate {
29    /// Create a new gate over the given UI-notification channel.
30    pub fn new(notifications: &ui::NotificationChannel) -> Self {
31        Self {
32            notifications: notifications.sender(),
33        }
34    }
35}
36
37impl ToolGate for UserToolGate {
38    /// Send a permission request to the UI and await the user's decision.
39    async fn permitted(&self, params: &dyn GateableToolParams) -> result::Result<(), String> {
40        let (approval, wait) = oneshot::channel();
41        self.notifications
42            .send(ui::Notification::RequestPermission {
43                prompt: params.to_string(),
44                approval,
45            })
46            .map_err(|_| "UI notifications channel is closed".to_string())?;
47
48        wait.await
49            .map_err(|_| "permission request cancelled (UI closed)".to_string())?
50    }
51}
52
53/// Connects the subagent tool to a llimorse-chat app
54///
55/// New subagents registered with this will send the appropriate notifications to the UI and thus
56/// register themselves with it.
57#[derive(Debug)]
58pub struct SubagentNotifier {
59    /// A notification channel to inform the llimorse-chat app of notifications
60    notifications: mpsc::UnboundedSender<ui::Notification>,
61
62    /// A counter to auto-generate distinct subagent IDs
63    subagent_id_counter: AtomicUsize,
64}
65
66/// Connects a subagent to a llimorse-chat app
67#[derive(Debug)]
68pub struct SubagentConnector {
69    /// The auto-generated subagent ID
70    id: SubagentId,
71
72    /// The subagents chat history
73    chat_history: Arc<Mutex<ChatHistory>>,
74
75    /// A notification channel to inform the llimorse-chat app of notifications
76    notifications: mpsc::UnboundedSender<ui::Notification>,
77}
78
79impl SubagentNotifier {
80    /// Create a new subagent notifier object for a llimorse-chat app.
81    ///
82    /// New subagents registered with this will send the appropriate notifications over
83    /// `notifications` and thus register themselves with the UI.
84    pub fn new(notifications: &ui::NotificationChannel) -> Self {
85        SubagentNotifier {
86            notifications: notifications.sender(),
87            subagent_id_counter: 0.into(),
88        }
89    }
90}
91
92impl llimorse_tools::subagent::SubagentNotifier for SubagentNotifier {
93    type Error = anyhow::Error;
94    type Subagent = SubagentConnector;
95
96    async fn created(&self, agent: &Agent, prompt: &str) -> Result<SubagentConnector> {
97        let subagent = SubagentConnector::new(
98            SubagentId::new(self.subagent_id_counter.fetch_add(1, Ordering::Relaxed)),
99            self.notifications.clone(),
100        );
101
102        subagent
103            .chat_history
104            .lock()
105            .unwrap()
106            .push_lines(prompt, HistoryEntryType::User, true);
107
108        let _ = self.notifications.send(ui::Notification::SubagentCreated {
109            subagent_id: subagent.id,
110            prompt: prompt.to_string(),
111            client_info: agent.client_info().clone(),
112            client_state: agent.client_state_arc(),
113            chat_history: Arc::clone(&subagent.chat_history),
114        });
115
116        Ok(subagent)
117    }
118}
119
120impl SubagentConnector {
121    /// Create a new subagent connector for a llimorse-chat app.
122    ///
123    /// `id` is the generated subagent ID, `notifications` is a channel to inform the UI of
124    /// changes.
125    fn new(id: SubagentId, notifications: mpsc::UnboundedSender<ui::Notification>) -> Self {
126        SubagentConnector {
127            id,
128            chat_history: Default::default(),
129            notifications,
130        }
131    }
132
133    /// Notify the UI that this subagent’s history has been updated
134    fn notify_update(&self) {
135        let _ = self.notifications.send(ui::Notification::UpdateAgent {
136            agent_id: AgentId::Subagent(self.id),
137        });
138    }
139}
140
141impl Drop for SubagentConnector {
142    fn drop(&mut self) {
143        let _ = self.notifications.send(ui::Notification::SubagentDropped {
144            subagent_id: self.id,
145        });
146    }
147}
148
149impl llimorse_tools::subagent::SubagentConnector for SubagentConnector {
150    type Error = anyhow::Error;
151
152    async fn push_chunk(&self, chunk: StreamingChunk) {
153        let (string, kind) = match chunk {
154            StreamingChunk::Content(c) => (c, HistoryEntryType::Content),
155            StreamingChunk::Reasoning(r) => (r, HistoryEntryType::Reasoning),
156        };
157        self.chat_history
158            .lock()
159            .unwrap()
160            .push_lines(&string, kind, false);
161        self.notify_update();
162    }
163
164    fn tool_call(&self, agent: &Agent, call: &ToolCall) -> Result<()> {
165        self.chat_history.lock().unwrap().push_lines(
166            &format!("[{}] {}\n", call.id, agent.display_call(&call.call)),
167            HistoryEntryType::ToolCall,
168            true,
169        );
170        self.notify_update();
171        Ok(())
172    }
173
174    fn tool_result(&self, agent: &Agent, call: &ToolCall, result: &Result<String>) -> Result<()> {
175        let name = call.call.name();
176        let id = &call.id;
177        match result {
178            Ok(result) => self.chat_history.lock().unwrap().push_lines(
179                &format!(
180                    "=[{name}/{id}]=> {}\n",
181                    agent.display_call_result(&call.call, result)
182                ),
183                HistoryEntryType::ToolResultOk,
184                true,
185            ),
186            Err(err) => self.chat_history.lock().unwrap().push_lines(
187                &format!("=[{name}/{id}]=> {err}\n"),
188                HistoryEntryType::ToolResultErr,
189                true,
190            ),
191        }
192
193        self.notify_update();
194        Ok(())
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use crate::ui;
202
203    /// Test parameters displaying as a fixed command line.
204    #[derive(Debug)]
205    struct TestParams;
206
207    impl std::fmt::Display for TestParams {
208        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209            write!(f, "echo hello")
210        }
211    }
212
213    #[tokio::test]
214    async fn approval_round_trip() {
215        let notifications = ui::NotificationChannel::new();
216        let gate = UserToolGate::new(&notifications);
217        let mut receiver = notifications.into_receiver();
218        let params = TestParams;
219
220        let (prompt, decision) = futures::join!(
221            async {
222                let ui::Notification::RequestPermission { prompt, approval } =
223                    receiver.recv().await.expect("channel closed")
224                else {
225                    panic!("not a permission request");
226                };
227                approval.send(Ok(())).unwrap();
228                prompt
229            },
230            async { gate.permitted(&params).await },
231        );
232
233        assert_eq!(prompt, "echo hello");
234        assert!(decision.is_ok());
235    }
236
237    #[tokio::test]
238    async fn dropped_ui_cancels_the_request() {
239        let notifications = ui::NotificationChannel::new();
240        let gate = UserToolGate::new(&notifications);
241        let params = TestParams;
242
243        // Simulate the UI being dropped without a decision
244        drop(notifications.into_receiver());
245
246        let decision = gate.permitted(&params).await;
247        assert!(decision.is_err());
248    }
249}