1use std::sync::atomic::{AtomicU64, Ordering};
18use std::sync::{Arc, Mutex as StdMutex};
19
20use async_trait::async_trait;
21use base64::Engine;
22use serde_json::{json, Value};
23use tokio::sync::{broadcast, mpsc};
24
25use rpi_agent::{AgentEvent, AgentMessage};
26use rpi_ai::types::{AssistantMessage, Content, ThinkingLevel};
27use rpi_harness::agent_harness::AgentLane;
28use rpi_tui::AssistantBlock;
29
30use crate::remote::client::RemoteClient;
31use crate::remote::protocol::{RemoteCommand, RemoteEvent};
32use crate::remote::session::RemoteSession;
33use crate::transcript_view::{UiEvent, UiToolCall};
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum DriverCommand {
38 Prompt(String),
40 Abort,
42 RefreshState,
44 SetModel(String),
46 SetThinkingLevel(String),
48 SetActiveTools(Vec<String>),
50}
51
52#[derive(Debug, Clone, Default, PartialEq)]
54pub struct DriverStatus {
55 pub ready: bool,
56 pub streaming: bool,
57 pub model: Option<String>,
58 pub thinking: Option<String>,
59 pub active_tools: Vec<String>,
60 pub retry: Option<(u32, u32, u64)>,
62}
63
64#[derive(Debug, Clone)]
66pub enum DriverEvent {
67 Ui(UiEvent),
69 Status(DriverStatus),
71 Notice(String),
73 Ended,
75}
76
77#[async_trait]
81pub trait SessionDriver: Send + Sync {
82 fn events(&self) -> Option<mpsc::UnboundedReceiver<DriverEvent>>;
85
86 async fn send(&self, command: DriverCommand) -> Result<(), String>;
88}
89
90pub fn map_agent_event(event: &AgentEvent) -> Option<UiEvent> {
97 match event {
98 AgentEvent::AgentStart => Some(UiEvent::AgentStart),
99 AgentEvent::AgentEnd { .. } => Some(UiEvent::AgentEnd),
100 AgentEvent::RetryScheduled {
101 attempt,
102 max_retries,
103 delay_ms,
104 error,
105 } => Some(UiEvent::Retry {
106 attempt: *attempt,
107 max_retries: *max_retries,
108 delay_ms: *delay_ms,
109 error: error.clone(),
110 }),
111 AgentEvent::TurnStart | AgentEvent::TurnEnd { .. } => None,
112 AgentEvent::MessageStart { message } => match message {
113 AgentMessage::Assistant(a) => Some(UiEvent::AssistantStart {
114 blocks: assistant_blocks(a),
115 }),
116 _ => None,
117 },
118 AgentEvent::MessageUpdate { message, .. } => Some(UiEvent::AssistantUpdate {
119 blocks: assistant_blocks(message),
120 tool_calls: assistant_tool_calls(message),
121 }),
122 AgentEvent::MessageEnd { message } => match message {
123 AgentMessage::Assistant(a) => Some(UiEvent::AssistantEnd {
124 blocks: assistant_blocks(a),
125 }),
126 _ => None,
127 },
128 AgentEvent::ToolExecutionStart {
129 tool_call_id,
130 tool_name,
131 args,
132 } => Some(UiEvent::ToolStart {
133 id: tool_call_id.clone(),
134 name: tool_name.clone(),
135 args: args.clone(),
136 }),
137 AgentEvent::ToolExecutionUpdate {
138 tool_call_id,
139 tool_name,
140 args,
141 partial_result,
142 } => Some(UiEvent::ToolUpdate {
143 id: tool_call_id.clone(),
144 name: tool_name.clone(),
145 args: args.clone(),
146 result: crate::remote::protocol::tool_result_json(partial_result),
147 }),
148 AgentEvent::ToolExecutionEnd {
149 tool_call_id,
150 tool_name,
151 result,
152 is_error,
153 } => Some(UiEvent::ToolEnd {
154 id: tool_call_id.clone(),
155 name: tool_name.clone(),
156 result: crate::remote::protocol::tool_result_json(result),
157 is_error: *is_error,
158 }),
159 }
160}
161
162pub fn assistant_blocks(msg: &AssistantMessage) -> Vec<AssistantBlock> {
165 msg.content
166 .iter()
167 .filter_map(|c| match c {
168 Content::Text(t) => Some(AssistantBlock::Text(t.text.clone())),
169 Content::Thinking(t) => Some(AssistantBlock::Thinking(t.thinking.clone())),
170 Content::Image(image) => base64::engine::general_purpose::STANDARD
171 .decode(&image.data)
172 .ok()
173 .filter(|data| !data.is_empty())
174 .map(AssistantBlock::Image),
175 _ => None,
176 })
177 .collect()
178}
179
180pub fn assistant_tool_calls(msg: &AssistantMessage) -> Vec<UiToolCall> {
183 msg.content
184 .iter()
185 .filter_map(|c| match c {
186 Content::ToolCall(tc) if !tc.name.trim().is_empty() => Some(UiToolCall {
187 id: tc.id.clone(),
188 name: tc.name.clone(),
189 args: tc.arguments.clone(),
190 }),
191 _ => None,
192 })
193 .collect()
194}
195
196pub struct RemoteDriver {
202 client: tokio::sync::Mutex<RemoteClient>,
203 session_id: String,
204 next_id: AtomicU64,
205 events: StdMutex<Option<mpsc::UnboundedReceiver<DriverEvent>>>,
206}
207
208impl RemoteDriver {
209 pub async fn connect(addr: &str, token: Option<&str>) -> Result<Self, String> {
212 let mut client = RemoteClient::connect(addr).await?;
213 client
214 .authenticate(token.unwrap_or(""))
215 .await
216 .map_err(auth_hint)?;
217
218 let started = client
219 .call("start_session", json!({}))
220 .await
221 .map_err(auth_hint)?;
222 let session_id = started
223 .get("sessionId")
224 .and_then(Value::as_str)
225 .ok_or("server did not return a sessionId")?
226 .to_string();
227
228 let subscribed = client
229 .call("subscribe", json!({"sessionId": session_id}))
230 .await?;
231 let subscription_id = subscribed
232 .get("subscriptionId")
233 .and_then(Value::as_u64)
234 .ok_or("server did not return a subscriptionId")?;
235
236 let mut lines = client.start_event_pump(subscription_id);
237 let (tx, rx) = mpsc::unbounded_channel();
238 tokio::spawn(async move {
239 let mut session = RemoteSession::new();
240 let mut last_error: Option<String> = None;
241 while let Some(line) = lines.recv().await {
242 session.apply_line(&line);
243 let _ = tx.send(DriverEvent::Status(status_of(&session)));
244 if session.last_error != last_error {
245 last_error = session.last_error.clone();
246 if let Some(error) = &last_error {
247 let _ = tx.send(DriverEvent::Notice(format!("error: {error}")));
248 }
249 }
250 if let Ok(event) = serde_json::from_value::<RemoteEvent>(line) {
251 if let Some(ui) = map_remote_event(&event) {
252 let _ = tx.send(DriverEvent::Ui(ui));
253 }
254 }
255 }
256 let _ = tx.send(DriverEvent::Ended);
257 });
258
259 let driver = Self {
260 client: tokio::sync::Mutex::new(client),
261 session_id,
262 next_id: AtomicU64::new(1),
263 events: StdMutex::new(Some(rx)),
264 };
265 let _ = driver.send(DriverCommand::RefreshState).await;
267 Ok(driver)
268 }
269
270 pub async fn shutdown(&self) {
272 let mut client = self.client.lock().await;
273 client.stop_session(&self.session_id).await;
274 }
275}
276
277#[async_trait]
278impl SessionDriver for RemoteDriver {
279 fn events(&self) -> Option<mpsc::UnboundedReceiver<DriverEvent>> {
280 self.events.lock().unwrap().take()
281 }
282
283 async fn send(&self, command: DriverCommand) -> Result<(), String> {
284 let remote = match command {
285 DriverCommand::Prompt(content) => RemoteCommand::Prompt { content },
286 DriverCommand::Abort => RemoteCommand::Abort,
287 DriverCommand::RefreshState => RemoteCommand::GetState,
288 DriverCommand::SetModel(model) => RemoteCommand::SetModel { model },
289 DriverCommand::SetThinkingLevel(level) => RemoteCommand::SetThinkingLevel { level },
290 DriverCommand::SetActiveTools(tools) => RemoteCommand::SetActiveTools { tools },
291 };
292 let id = self.next_id.fetch_add(1, Ordering::Relaxed);
293 let mut client = self.client.lock().await;
294 client
295 .send_command(&self.session_id, remote.with_id(id))
296 .await
297 }
298}
299
300fn status_of(session: &RemoteSession) -> DriverStatus {
302 DriverStatus {
303 ready: session.ready,
304 streaming: session.streaming,
305 model: session.state.model.clone(),
306 thinking: session
307 .state
308 .thinking_level
309 .as_ref()
310 .and_then(Value::as_str)
311 .map(str::to_string),
312 active_tools: session.state.active_tools.clone(),
313 retry: session
314 .retry_attempt
315 .map(|(attempt, max)| (attempt, max, session.retry_delay_ms.unwrap_or(1000))),
316 }
317}
318
319pub fn map_remote_event(event: &RemoteEvent) -> Option<UiEvent> {
321 use crate::remote::session::message_role;
322 use crate::transcript_view::{assistant_blocks_from_value, tool_calls_from_value};
323
324 match event {
325 RemoteEvent::AgentStart => Some(UiEvent::AgentStart),
326 RemoteEvent::AgentEnd { .. } => Some(UiEvent::AgentEnd),
327 RemoteEvent::RetryScheduled {
328 attempt,
329 max_retries,
330 delay_ms,
331 error,
332 } => Some(UiEvent::Retry {
333 attempt: *attempt,
334 max_retries: *max_retries,
335 delay_ms: *delay_ms,
336 error: error.clone(),
337 }),
338 RemoteEvent::MessageStart { message } => {
339 if message_role(message) != "assistant" {
340 return None;
341 }
342 Some(UiEvent::AssistantStart {
343 blocks: assistant_blocks_from_value(message),
344 })
345 }
346 RemoteEvent::MessageUpdate { message, .. } => {
347 if !message.is_object() {
348 return None;
349 }
350 Some(UiEvent::AssistantUpdate {
351 blocks: assistant_blocks_from_value(message),
352 tool_calls: tool_calls_from_value(message),
353 })
354 }
355 RemoteEvent::MessageEnd { message } => {
356 if message_role(message) != "assistant" {
357 return None;
358 }
359 Some(UiEvent::AssistantEnd {
360 blocks: assistant_blocks_from_value(message),
361 })
362 }
363 RemoteEvent::ToolExecutionStart {
364 tool_call_id,
365 tool_name,
366 args,
367 } => Some(UiEvent::ToolStart {
368 id: tool_call_id.clone(),
369 name: tool_name.clone(),
370 args: args.clone(),
371 }),
372 RemoteEvent::ToolExecutionUpdate {
373 tool_call_id,
374 tool_name,
375 args,
376 partial_result,
377 } => Some(UiEvent::ToolUpdate {
378 id: tool_call_id.clone(),
379 name: tool_name.clone(),
380 args: args.clone(),
381 result: partial_result.clone(),
382 }),
383 RemoteEvent::ToolExecutionEnd {
384 tool_call_id,
385 tool_name,
386 result,
387 is_error,
388 } => Some(UiEvent::ToolEnd {
389 id: tool_call_id.clone(),
390 name: tool_name.clone(),
391 result: result.clone(),
392 is_error: *is_error,
393 }),
394 RemoteEvent::TurnStart | RemoteEvent::TurnEnd { .. } => None,
395 }
396}
397
398fn auth_hint(error: String) -> String {
400 let lower = error.to_lowercase();
401 if lower.contains("authentication") || lower.contains("token") {
402 format!("{error} (retrieve the token printed by `rpi --server` and pass `--token <token>`)")
403 } else {
404 error
405 }
406}
407
408pub struct LocalDriver {
422 lane: Arc<dyn AgentLane>,
423 status_tx: mpsc::UnboundedSender<DriverEvent>,
424 events: StdMutex<Option<mpsc::UnboundedReceiver<DriverEvent>>>,
425}
426
427impl LocalDriver {
428 pub fn new(lane: Arc<dyn AgentLane>, events: broadcast::Receiver<AgentEvent>) -> Self {
430 let (tx, rx) = mpsc::unbounded_channel();
431 let status_tx = tx.clone();
432 tokio::spawn(pump_agent_events(lane.clone(), events, tx));
433 Self {
434 lane,
435 status_tx,
436 events: StdMutex::new(Some(rx)),
437 }
438 }
439}
440
441#[async_trait]
442impl SessionDriver for LocalDriver {
443 fn events(&self) -> Option<mpsc::UnboundedReceiver<DriverEvent>> {
444 self.events.lock().unwrap().take()
445 }
446
447 async fn send(&self, command: DriverCommand) -> Result<(), String> {
448 match command {
449 DriverCommand::Prompt(text) => {
450 let lane = self.lane.clone();
454 let tx = self.status_tx.clone();
455 tokio::spawn(async move {
456 if let Err(error) = lane.prompt_text(&text, Vec::new()).await {
457 let _ = tx.send(DriverEvent::Notice(format!("error: {error}")));
458 }
459 });
460 Ok(())
461 }
462 DriverCommand::Abort => self
463 .lane
464 .abort()
465 .await
466 .map(|_| ())
467 .map_err(|e| e.to_string()),
468 DriverCommand::RefreshState => {
469 let status = query_status(&self.lane, false).await;
470 let _ = self.status_tx.send(DriverEvent::Status(status));
471 Ok(())
472 }
473 DriverCommand::SetThinkingLevel(level) => {
474 let parsed = parse_thinking_level_name(&level)
475 .ok_or_else(|| format!("unknown thinking level: {level}"))?;
476 self.lane
477 .set_thinking_level(parsed)
478 .await
479 .map_err(|e| e.to_string())
480 }
481 DriverCommand::SetActiveTools(tools) => self
482 .lane
483 .set_active_tools(tools)
484 .await
485 .map_err(|e| e.to_string()),
486 DriverCommand::SetModel(_) => Err(
487 "model switching is not available through the local driver (use /model)"
488 .to_string(),
489 ),
490 }
491 }
492}
493
494async fn pump_agent_events(
497 lane: Arc<dyn AgentLane>,
498 mut rx: broadcast::Receiver<AgentEvent>,
499 tx: mpsc::UnboundedSender<DriverEvent>,
500) {
501 let mut streaming = false;
502 loop {
503 match rx.recv().await {
504 Ok(event) => {
505 match &event {
506 AgentEvent::AgentStart => streaming = true,
507 AgentEvent::AgentEnd { .. } => streaming = false,
508 _ => {}
509 }
510 if let Some(ui) = map_agent_event(&event) {
511 if tx.send(DriverEvent::Ui(ui)).is_err() {
512 return;
513 }
514 }
515 if matches!(
516 &event,
517 AgentEvent::AgentStart
518 | AgentEvent::AgentEnd { .. }
519 | AgentEvent::RetryScheduled { .. }
520 ) {
521 let status = query_status(&lane, streaming).await;
522 if tx.send(DriverEvent::Status(status)).is_err() {
523 return;
524 }
525 }
526 }
527 Err(broadcast::error::RecvError::Lagged(_)) => continue,
528 Err(broadcast::error::RecvError::Closed) => break,
529 }
530 }
531 let _ = tx.send(DriverEvent::Ended);
532}
533
534async fn query_status(lane: &Arc<dyn AgentLane>, streaming: bool) -> DriverStatus {
536 DriverStatus {
537 ready: true,
538 streaming,
539 model: lane.get_model().await.ok().map(|model| model.id),
540 thinking: lane
541 .get_thinking_level()
542 .await
543 .ok()
544 .map(thinking_level_name)
545 .map(str::to_string),
546 active_tools: lane.get_active_tools().await.unwrap_or_default(),
547 retry: None,
548 }
549}
550
551fn thinking_level_name(level: ThinkingLevel) -> &'static str {
553 use ThinkingLevel::*;
554 match level {
555 Off => "off",
556 Minimal => "minimal",
557 Low => "low",
558 Medium => "medium",
559 High => "high",
560 Xhigh => "xhigh",
561 Max => "max",
562 }
563}
564
565fn parse_thinking_level_name(name: &str) -> Option<ThinkingLevel> {
567 match name.trim().to_ascii_lowercase().as_str() {
568 "off" => Some(ThinkingLevel::Off),
569 "minimal" => Some(ThinkingLevel::Minimal),
570 "low" => Some(ThinkingLevel::Low),
571 "medium" => Some(ThinkingLevel::Medium),
572 "high" => Some(ThinkingLevel::High),
573 "xhigh" => Some(ThinkingLevel::Xhigh),
574 "max" => Some(ThinkingLevel::Max),
575 _ => None,
576 }
577}
578
579#[cfg(test)]
580mod tests {
581 use super::*;
582 use serde_json::json;
583
584 #[test]
585 fn map_remote_event_ignores_non_assistant_messages() {
586 let event = serde_json::from_value::<RemoteEvent>(json!({
587 "type": "message_start",
588 "message": {"kind": "user", "content": []}
589 }))
590 .unwrap();
591 assert!(map_remote_event(&event).is_none());
592 }
593
594 #[test]
595 fn map_remote_event_projects_assistant_update() {
596 let event = serde_json::from_value::<RemoteEvent>(json!({
597 "type": "message_update",
598 "message": {"kind": "assistant", "content": [{"type": "text", "text": "hi"}]}
599 }))
600 .unwrap();
601 match map_remote_event(&event) {
602 Some(UiEvent::AssistantUpdate { blocks, tool_calls }) => {
603 assert_eq!(blocks, vec![AssistantBlock::Text("hi".into())]);
604 assert!(tool_calls.is_empty());
605 }
606 other => panic!("expected AssistantUpdate, got {other:?}"),
607 }
608 }
609
610 #[test]
611 fn map_remote_event_projects_tool_end() {
612 let event = serde_json::from_value::<RemoteEvent>(json!({
613 "type": "tool_execution_end",
614 "toolCallId": "c1",
615 "toolName": "read",
616 "isError": true,
617 "result": {"content": [{"type": "text", "text": "nope"}]}
618 }))
619 .unwrap();
620 match map_remote_event(&event) {
621 Some(UiEvent::ToolEnd {
622 id, name, is_error, ..
623 }) => {
624 assert_eq!(id, "c1");
625 assert_eq!(name, "read");
626 assert!(is_error);
627 }
628 other => panic!("expected ToolEnd, got {other:?}"),
629 }
630 }
631
632 #[test]
633 fn auth_hint_mentions_the_token_flag() {
634 assert!(auth_hint("authentication required".to_string()).contains("--token"));
635 assert_eq!(auth_hint("boom".to_string()), "boom");
636 }
637
638 #[test]
639 fn thinking_level_name_round_trips() {
640 for name in ["off", "minimal", "low", "medium", "high", "xhigh", "max"] {
641 let level = parse_thinking_level_name(name).unwrap();
642 assert_eq!(thinking_level_name(level), name);
643 }
644 assert_eq!(parse_thinking_level_name("HIGH"), Some(ThinkingLevel::High));
645 assert_eq!(parse_thinking_level_name("bogus"), None);
646 }
647}