1use std::io::{BufRead, IsTerminal, Write};
19use std::sync::{Arc, Mutex};
20
21use rpi_agent::events::AgentEvent;
22use rpi_ai::types::{AssistantMessage, Content, ImageContent, StopReason};
23use rpi_harness::agent_harness::{AgentHarness, AgentLane, HarnessRunOutcome};
24use rpi_harness::events::{HarnessEvent, RunEndOutcome};
25
26use crate::args::Args;
27use crate::remote::protocol::RemoteResponse;
28
29pub fn assistant_text(msg: &AssistantMessage) -> String {
32 msg.content
33 .iter()
34 .filter_map(|c| match c {
35 Content::Text(t) => Some(t.text.clone()),
36 _ => None,
37 })
38 .collect()
39}
40
41pub fn outcome_exit_code(outcome: &HarnessRunOutcome) -> i32 {
44 match outcome {
45 HarnessRunOutcome::Failed { .. } | HarnessRunOutcome::Aborted { .. } => 1,
46 _ => 0,
47 }
48}
49
50pub async fn print(
54 harness: &AgentHarness,
55 _args: &Args,
56 initial: Option<String>,
57 extra_messages: &[String],
58 initial_images: Vec<ImageContent>,
59) -> i32 {
60 let lane: Arc<dyn AgentLane> = harness.lane("main");
61
62 let mut last_exit = 0;
63 let mut last_msg: Option<AssistantMessage> = None;
64
65 let mut prompts: Vec<String> = Vec::new();
68 if let Some(init) = initial {
69 prompts.push(init);
70 }
71 for m in extra_messages {
72 prompts.push(m.clone());
73 }
74
75 if prompts.is_empty() {
76 return 0;
78 }
79
80 let mut images = initial_images;
81 for prompt in prompts {
82 match lane.prompt_text(&prompt, std::mem::take(&mut images)).await {
83 Ok(result) => {
84 last_exit = outcome_exit_code(&result.outcome);
85 match &result.outcome {
86 HarnessRunOutcome::Completed { final_message, .. }
87 | HarnessRunOutcome::Aborted { final_message, .. } => {
88 last_msg = Some(final_message.clone());
89 }
90 HarnessRunOutcome::Failed {
91 error,
92 final_message,
93 ..
94 } => {
95 if let Some(m) = final_message {
96 if m.stop_reason == StopReason::Error {
97 if let Some(em) = &m.error_message {
98 eprintln!("{em}");
99 }
100 }
101 }
102 eprintln!("run failed: {error:?}");
103 }
104 HarnessRunOutcome::Suspended { .. } => {
105 eprintln!("run suspended (deferred) — resume is not supported in v1");
106 last_exit = 1;
107 }
108 }
109 }
110 Err(e) => {
111 eprintln!("prompt rejected: {e}");
112 return 1;
113 }
114 }
115 }
116
117 if let Some(m) = &last_msg {
119 match m.stop_reason {
120 StopReason::Error => {
121 if let Some(em) = &m.error_message {
122 eprintln!("{em}");
123 }
124 last_exit = 1;
125 }
126 StopReason::Aborted => {
127 eprintln!("request aborted");
128 last_exit = 1;
129 }
130 _ => {
131 let text = assistant_text(m);
132 let mut out = std::io::stdout();
133 let _ = out.write_all(text.as_bytes());
134 if !text.ends_with('\n') {
135 let _ = out.write_all(b"\n");
136 }
137 let _ = out.flush();
138 }
139 }
140 }
141
142 last_exit
143}
144
145pub async fn json(
149 harness: &AgentHarness,
150 _args: &Args,
151 initial: Option<String>,
152 extra_messages: &[String],
153 initial_images: Vec<ImageContent>,
154 mut agent_events: Option<tokio::sync::broadcast::Receiver<AgentEvent>>,
155) -> i32 {
156 let lane: Arc<dyn AgentLane> = harness.lane("main");
157 let collected: Arc<Mutex<Vec<HarnessEvent>>> = Arc::new(Mutex::new(Vec::new()));
158 let collected_for_watch = collected.clone();
159
160 let mut watch = harness.events().watch(|| ());
163 watch.start(Arc::new(move |event: &HarnessEvent| {
164 emit_json_event(event);
166 collected_for_watch.lock().unwrap().push(event.clone());
167 }));
168 std::mem::forget(watch);
171
172 let (agent_done_tx, mut agent_done_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
175 let agent_event_task = agent_events.take().map(|mut rx| {
176 tokio::spawn(async move {
177 let done_tx = agent_done_tx;
178 loop {
179 match rx.recv().await {
180 Ok(event) => {
181 let terminal = event.is_terminal();
182 emit_agent_event(&event);
183 if terminal {
184 let _ = done_tx.send(());
185 }
186 }
187 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
188 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
189 }
190 }
191 })
192 });
193
194 let mut prompts: Vec<String> = Vec::new();
195 if let Some(init) = initial {
196 prompts.push(init);
197 }
198 for m in extra_messages {
199 prompts.push(m.clone());
200 }
201
202 let mut last_exit = 0;
203 let mut final_outcome: Option<HarnessRunOutcome> = None;
204
205 let mut images = initial_images;
206 for prompt in prompts {
207 match lane.prompt_text(&prompt, std::mem::take(&mut images)).await {
208 Ok(result) => {
209 let _ = tokio::time::timeout(
215 std::time::Duration::from_millis(250),
216 agent_done_rx.recv(),
217 )
218 .await;
219 last_exit = outcome_exit_code(&result.outcome);
220 final_outcome = Some(result.outcome);
221 }
222 Err(e) => {
223 let line = serde_json::json!({
225 "type": "error",
226 "error": e.to_string(),
227 });
228 println!("{line}");
229 if let Some(task) = agent_event_task {
230 task.abort();
231 }
232 return 1;
233 }
234 }
235 }
236
237 let (outcome_str, final_text) = match final_outcome {
239 Some(HarnessRunOutcome::Completed { final_message, .. }) => {
240 ("completed", Some(assistant_text(&final_message)))
241 }
242 Some(HarnessRunOutcome::Aborted { final_message, .. }) => {
243 ("aborted", Some(assistant_text(&final_message)))
244 }
245 Some(HarnessRunOutcome::Failed { final_message, .. }) => {
246 let t = final_message.as_ref().map(assistant_text);
247 ("failed", t)
248 }
249 Some(HarnessRunOutcome::Suspended { .. }) => ("suspended", None),
250 None => ("idle", None),
251 };
252 let result_line = serde_json::json!({
253 "type": "result",
254 "outcome": outcome_str,
255 "finalText": final_text,
256 });
257 println!("{result_line}");
258 if let Some(task) = agent_event_task {
259 task.abort();
260 }
261 last_exit
262}
263
264fn emit_agent_event(event: &AgentEvent) {
265 println!("{}", agent_event_json(event));
266}
267
268pub async fn rpc(
280 harness: &AgentHarness,
281 mut agent_events: Option<tokio::sync::broadcast::Receiver<AgentEvent>>,
282 model_catalog: Vec<rpi_ai::Model>,
283) -> i32 {
284 use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
285 use tokio::sync::Mutex as AsyncMutex;
286
287 let lane: Arc<dyn AgentLane> = harness.lane("main");
288 let stdout = Arc::new(AsyncMutex::new(tokio::io::stdout()));
289
290 if let Some(mut rx) = agent_events.take() {
292 let out = Arc::clone(&stdout);
293 tokio::spawn(async move {
294 loop {
295 match rx.recv().await {
296 Ok(event) => write_line(&out, &agent_event_json(&event)).await,
297 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
298 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
299 }
300 }
301 });
302 }
303
304 write_line(&stdout, &serde_json::json!({"type": "ready"})).await;
306
307 let mut lines = tokio::io::BufReader::new(tokio::io::stdin()).lines();
308 loop {
309 let line = match lines.next_line().await {
310 Ok(Some(line)) => line,
311 Ok(None) => break, Err(error) => {
313 eprintln!("[rpi] rpc stdin error: {error}");
314 break;
315 }
316 };
317 let line = line.trim();
318 if line.is_empty() {
319 continue;
320 }
321 let msg: serde_json::Value = match serde_json::from_str(line) {
322 Ok(value) => value,
323 Err(error) => {
324 write_line(
325 &stdout,
326 &RemoteResponse::error(
327 serde_json::Value::Null,
328 format!("invalid json: {error}"),
329 )
330 .to_json(),
331 )
332 .await;
333 continue;
334 }
335 };
336 let id = msg.get("id").cloned().unwrap_or(serde_json::Value::Null);
337 let kind = msg
338 .get("type")
339 .and_then(|v| v.as_str())
340 .unwrap_or("")
341 .to_string();
342
343 if kind == "stop" {
344 write_line(
345 &stdout,
346 &RemoteResponse::ok(id.clone(), serde_json::json!({"stopped": true})).to_json(),
347 )
348 .await;
349 break;
350 }
351
352 let result = match kind.as_str() {
353 "prompt" => rpc_prompt(&lane, &msg).await,
354 "abort" => lane
355 .abort()
356 .await
357 .map(|_| serde_json::json!({"aborted": true}))
358 .map_err(|e| e.to_string()),
359 "get_state" => Ok(rpc_get_state(&lane, harness).await),
360 "set_model" => rpc_set_model(&lane, &model_catalog, &msg).await,
361 "set_thinking_level" => rpc_set_thinking_level(&lane, &msg).await,
362 "set_active_tools" => rpc_set_active_tools(&lane, &msg).await,
363 "ping" => Ok(serde_json::json!({"pong": true})),
364 other => Err(format!("unknown command type: {other}")),
365 };
366
367 let response = match result {
368 Ok(value) => RemoteResponse::ok(id.clone(), value),
369 Err(error) => RemoteResponse::error(id.clone(), error),
370 };
371 write_line(&stdout, &response.to_json()).await;
372 }
373
374 let _ = stdout.lock().await.flush().await;
375 0
376}
377
378async fn write_line(out: &Arc<tokio::sync::Mutex<tokio::io::Stdout>>, value: &serde_json::Value) {
379 use tokio::io::AsyncWriteExt;
380 let mut guard = out.lock().await;
381 let _ = guard.write_all(value.to_string().as_bytes()).await;
382 let _ = guard.write_all(b"\n").await;
383 let _ = guard.flush().await;
384}
385
386fn rpc_outcome_str(outcome: &HarnessRunOutcome) -> (&'static str, Option<String>) {
387 match outcome {
388 HarnessRunOutcome::Completed { final_message, .. } => {
389 ("completed", Some(assistant_text(final_message)))
390 }
391 HarnessRunOutcome::Aborted { final_message, .. } => {
392 ("aborted", Some(assistant_text(final_message)))
393 }
394 HarnessRunOutcome::Failed { final_message, .. } => {
395 ("failed", final_message.as_ref().map(assistant_text))
396 }
397 HarnessRunOutcome::Suspended { .. } => ("suspended", None),
398 }
399}
400
401async fn rpc_prompt(
402 lane: &Arc<dyn AgentLane>,
403 msg: &serde_json::Value,
404) -> Result<serde_json::Value, String> {
405 let content = msg
406 .get("content")
407 .or_else(|| msg.get("prompt"))
408 .and_then(|v| v.as_str())
409 .ok_or("prompt requires a string `content`")?
410 .to_string();
411 let result = lane
412 .prompt_text(&content, Vec::new())
413 .await
414 .map_err(|e| e.to_string())?;
415 let (outcome, final_text) = rpc_outcome_str(&result.outcome);
416 Ok(serde_json::json!({"outcome": outcome, "finalText": final_text}))
417}
418
419async fn rpc_get_state(lane: &Arc<dyn AgentLane>, harness: &AgentHarness) -> serde_json::Value {
420 let model = lane.get_model().await.ok().map(|m| m.id);
421 let thinking = lane
422 .get_thinking_level()
423 .await
424 .ok()
425 .map(|t| serde_json::to_value(t).unwrap_or(serde_json::Value::Null));
426 let active_tools = lane.get_active_tools().await.unwrap_or_default();
427 let leaf_id = lane.get_leaf_id().await.ok().flatten();
428 serde_json::json!({
429 "model": model,
430 "thinkingLevel": thinking,
431 "activeTools": active_tools,
432 "leafId": leaf_id,
433 "sessionId": harness.session().storage().metadata().id,
434 })
435}
436
437async fn rpc_set_model(
438 lane: &Arc<dyn AgentLane>,
439 catalog: &[rpi_ai::Model],
440 msg: &serde_json::Value,
441) -> Result<serde_json::Value, String> {
442 let id = msg
443 .get("model")
444 .and_then(|v| v.as_str())
445 .ok_or("set_model requires `model`")?;
446 let model = catalog
447 .iter()
448 .find(|m| m.id.eq_ignore_ascii_case(id))
449 .cloned()
450 .ok_or_else(|| format!("unknown model: {id}"))?;
451 lane.set_model(model).await.map_err(|e| e.to_string())?;
452 Ok(serde_json::json!({"model": id}))
453}
454
455async fn rpc_set_thinking_level(
456 lane: &Arc<dyn AgentLane>,
457 msg: &serde_json::Value,
458) -> Result<serde_json::Value, String> {
459 let value = msg
460 .get("level")
461 .or_else(|| msg.get("thinkingLevel"))
462 .ok_or("set_thinking_level requires `level`")?;
463 let level: rpi_ai::types::ThinkingLevel =
464 serde_json::from_value(value.clone()).map_err(|_| {
465 "invalid thinking level (off|minimal|low|medium|high|xhigh|max)".to_string()
466 })?;
467 lane.set_thinking_level(level)
468 .await
469 .map_err(|e| e.to_string())?;
470 Ok(serde_json::json!({"thinkingLevel": level}))
471}
472
473async fn rpc_set_active_tools(
474 lane: &Arc<dyn AgentLane>,
475 msg: &serde_json::Value,
476) -> Result<serde_json::Value, String> {
477 let tools = msg
478 .get("tools")
479 .and_then(|v| v.as_array())
480 .map(|arr| {
481 arr.iter()
482 .filter_map(|v| v.as_str().map(str::to_owned))
483 .collect::<Vec<_>>()
484 })
485 .ok_or("set_active_tools requires a `tools` array")?;
486 lane.set_active_tools(tools.clone())
487 .await
488 .map_err(|e| e.to_string())?;
489 Ok(serde_json::json!({"activeTools": tools}))
490}
491
492fn agent_event_json(event: &AgentEvent) -> serde_json::Value {
497 serde_json::to_value(crate::remote::protocol::RemoteEvent::from_agent_event(
498 event,
499 ))
500 .unwrap_or(serde_json::Value::Null)
501}
502
503fn emit_json_event(event: &HarnessEvent) {
507 let line = match event {
508 HarnessEvent::RunStart(e) => serde_json::json!({
509 "type": "run_start",
510 "lane": e.lane,
511 "runId": e.run_id,
512 }),
513 HarnessEvent::RunEnd(e) => serde_json::json!({
514 "type": "run_end",
515 "lane": e.lane,
516 "runId": e.run_id,
517 "outcome": run_end_outcome_str(e.outcome),
518 "leafId": e.leaf_id,
519 }),
520 };
521 println!("{line}");
522}
523
524fn run_end_outcome_str(o: RunEndOutcome) -> &'static str {
525 match o {
526 RunEndOutcome::Completed => "completed",
527 RunEndOutcome::Aborted => "aborted",
528 RunEndOutcome::Failed => "failed",
529 }
530}
531
532pub async fn interactive(
541 harness: &AgentHarness,
542 event_rx: Option<tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>>,
543 args: &Args,
544 model_catalog: Vec<rpi_ai::Model>,
545 initial: Option<String>,
546 extra_messages: &[String],
547 initial_images: Vec<ImageContent>,
548 theme: Option<&str>,
549 no_themes: bool,
550 reload_context: &crate::session::ReloadContext,
551) -> i32 {
552 if let Some(reason) = crate::session::dispatch_session_event_async(
559 reload_context,
560 rpi_plugin_sdk::EventTag::BeforeTuiStart,
561 )
562 .await
563 {
564 eprintln!("[rpi] startup vetoed by extension: {reason}");
565 return crate::app::EXIT_VETOED;
566 }
567
568 let force_tui = std::env::var("RPI_FORCE_TUI")
570 .map(|v| v == "1")
571 .unwrap_or(false);
572 let code = if force_tui || crate::interactive_tui::is_tui_supported() {
573 crate::interactive_tui::interactive_tui(
575 harness,
576 event_rx,
577 args,
578 model_catalog,
579 initial,
580 extra_messages,
581 initial_images,
582 theme,
583 no_themes,
584 reload_context,
585 )
586 .await
587 } else {
588 interactive_repl(harness, args, initial, extra_messages, initial_images).await
590 };
591
592 if let Some(reason) = crate::session::dispatch_session_event_async(
598 reload_context,
599 rpi_plugin_sdk::EventTag::SessionShutdown,
600 )
601 .await
602 {
603 tracing::warn!(
604 "[rpi] extension vetoed SessionShutdown (ignored, session closing): {reason}"
605 );
606 }
607
608 code
609}
610
611pub async fn interactive_repl(
613 harness: &AgentHarness,
614 #[allow(unused_variables)] args: &Args,
615 initial: Option<String>,
616 extra_messages: &[String],
617 initial_images: Vec<ImageContent>,
618) -> i32 {
619 let lane: Arc<dyn AgentLane> = harness.lane("main");
621 let stdin = std::io::stdin();
622 let is_tty = stdin.is_terminal();
623
624 if is_tty {
625 println!(
626 "rpi interactive (v1 minimal REPL). Type /exit to quit, /abort to cancel a run.\n"
627 );
628 }
629
630 let mut prompts: Vec<String> = Vec::new();
632 if let Some(init) = initial {
633 prompts.push(init);
634 }
635 for m in extra_messages {
636 prompts.push(m.clone());
637 }
638 let mut images = initial_images;
639 for prompt in prompts {
640 if let Err(code) = run_one(&lane, &prompt, std::mem::take(&mut images)).await {
641 return code;
642 }
643 }
644
645 let mut line = String::new();
647 loop {
648 if is_tty {
649 print!("> ");
650 let _ = std::io::stdout().flush();
651 }
652 line.clear();
653 match stdin.lock().read_line(&mut line) {
654 Ok(0) => break, Ok(_) => {}
656 Err(_) => break,
657 }
658 let trimmed = line.trim();
659 if trimmed.is_empty() {
660 continue;
661 }
662 if trimmed == "/exit" || trimmed == "/quit" {
663 break;
664 }
665 if trimmed == "/abort" {
666 let _ = lane.abort().await;
667 eprintln!("(aborted)");
668 continue;
669 }
670 if let Err(code) = run_one(&lane, trimmed, Vec::new()).await {
671 return code;
672 }
673 }
674 0
675}
676
677async fn run_one(
681 lane: &Arc<dyn AgentLane>,
682 prompt: &str,
683 images: Vec<ImageContent>,
684) -> Result<(), i32> {
685 match lane.prompt_text(prompt, images).await {
686 Ok(result) => {
687 match &result.outcome {
688 HarnessRunOutcome::Completed { final_message, .. }
689 | HarnessRunOutcome::Aborted { final_message, .. } => {
690 let text = assistant_text(final_message);
691 if !text.is_empty() {
692 println!("{text}");
693 }
694 }
695 HarnessRunOutcome::Failed {
696 error,
697 final_message,
698 ..
699 } => {
700 if let Some(m) = final_message {
701 if let Some(em) = &m.error_message {
702 eprintln!("error: {em}");
703 }
704 }
705 eprintln!("run failed: {error:?}");
706 }
707 HarnessRunOutcome::Suspended { .. } => {
708 eprintln!("run suspended (deferred) — resume not supported in v1");
709 }
710 }
711 Ok(())
712 }
713 Err(e) => {
714 eprintln!("prompt rejected: {e}");
715 Err(1)
716 }
717 }
718}
719
720#[cfg(test)]
721mod tests {
722 use super::*;
723 use rpi_agent::events::AgentEvent;
724 use rpi_agent::types::AgentToolResult;
725 use rpi_ai::types::{
726 AssistantMessage, Content, StopReason, TextContent, TextContentType, Usage,
727 };
728 use rpi_harness::session::types::OperationError;
729
730 fn assistant(text: &str, stop: StopReason) -> AssistantMessage {
731 AssistantMessage {
732 role: rpi_ai::types::AssistantRole,
733 content: vec![Content::Text(TextContent {
734 kind: TextContentType,
735 text: text.into(),
736 text_signature: None,
737 })],
738 api: rpi_ai::Api::AnthropicMessages,
739 provider: "anthropic".into(),
740 model: "claude-sonnet-5".into(),
741 response_model: None,
742 response_id: None,
743 usage: Usage::zero(),
744 stop_reason: stop,
745 deferred: None,
746 error_message: None,
747 raw_stop_reason: None,
748 end_turn: None,
749 timestamp: 0,
750 }
751 }
752
753 #[test]
754 fn assistant_text_concatenates_text_blocks() {
755 let m = assistant("hello", StopReason::Stop);
756 assert_eq!(assistant_text(&m), "hello");
757 }
758
759 #[test]
760 fn outcome_exit_code_maps_failed_aborted_to_1() {
761 let failed = HarnessRunOutcome::Failed {
762 leaf_id: "l".into(),
763 error: OperationError {
764 code: "boom".into(),
765 message: "boom".into(),
766 },
767 final_entry_id: None,
768 final_message: None,
769 };
770 assert_eq!(outcome_exit_code(&failed), 1);
771 let completed = HarnessRunOutcome::Completed {
772 leaf_id: "l".into(),
773 final_entry_id: "e".into(),
774 final_message: assistant("ok", StopReason::Stop),
775 };
776 assert_eq!(outcome_exit_code(&completed), 0);
777 }
778
779 #[test]
780 fn run_end_outcome_str_roundtrip() {
781 assert_eq!(run_end_outcome_str(RunEndOutcome::Completed), "completed");
782 assert_eq!(run_end_outcome_str(RunEndOutcome::Aborted), "aborted");
783 assert_eq!(run_end_outcome_str(RunEndOutcome::Failed), "failed");
784 }
785
786 #[test]
787 fn agent_event_projection_keeps_terminal_and_tool_payloads() {
788 let end = agent_event_json(&AgentEvent::AgentEnd { messages: vec![] });
789 assert_eq!(end["type"], "agent_end");
790 assert_eq!(end["messages"], serde_json::json!([]));
791
792 let tool = agent_event_json(&AgentEvent::ToolExecutionEnd {
793 tool_call_id: "call-1".into(),
794 tool_name: "read".into(),
795 result: AgentToolResult::text("hello"),
796 is_error: false,
797 });
798 assert_eq!(tool["type"], "tool_execution_end");
799 assert_eq!(tool["result"]["content"][0]["text"], "hello");
800 assert_eq!(tool["result"]["terminate"], false);
801
802 let retry = agent_event_json(&AgentEvent::RetryScheduled {
803 attempt: 3,
804 max_retries: 10,
805 delay_ms: 8_000,
806 error: "503 service unavailable".into(),
807 });
808 assert_eq!(retry["type"], "retry_scheduled");
809 assert_eq!(retry["attempt"], 3);
810 assert_eq!(retry["maxRetries"], 10);
811 assert_eq!(retry["delayMs"], 8_000);
812 assert_eq!(retry["error"], "503 service unavailable");
813 }
814}