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 = serde_json::from_value(value.clone()).map_err(|_| {
464 "invalid thinking level (off|minimal|low|medium|high|xhigh|max)".to_string()
465 })?;
466 lane.set_thinking_level(level)
467 .await
468 .map_err(|e| e.to_string())?;
469 Ok(serde_json::json!({"thinkingLevel": level}))
470}
471
472async fn rpc_set_active_tools(
473 lane: &Arc<dyn AgentLane>,
474 msg: &serde_json::Value,
475) -> Result<serde_json::Value, String> {
476 let tools = msg
477 .get("tools")
478 .and_then(|v| v.as_array())
479 .map(|arr| {
480 arr.iter()
481 .filter_map(|v| v.as_str().map(str::to_owned))
482 .collect::<Vec<_>>()
483 })
484 .ok_or("set_active_tools requires a `tools` array")?;
485 lane.set_active_tools(tools.clone())
486 .await
487 .map_err(|e| e.to_string())?;
488 Ok(serde_json::json!({"activeTools": tools}))
489}
490
491fn agent_event_json(event: &AgentEvent) -> serde_json::Value {
496 serde_json::to_value(crate::remote::protocol::RemoteEvent::from_agent_event(event))
497 .unwrap_or(serde_json::Value::Null)
498}
499
500fn emit_json_event(event: &HarnessEvent) {
504 let line = match event {
505 HarnessEvent::RunStart(e) => serde_json::json!({
506 "type": "run_start",
507 "lane": e.lane,
508 "runId": e.run_id,
509 }),
510 HarnessEvent::RunEnd(e) => serde_json::json!({
511 "type": "run_end",
512 "lane": e.lane,
513 "runId": e.run_id,
514 "outcome": run_end_outcome_str(e.outcome),
515 "leafId": e.leaf_id,
516 }),
517 };
518 println!("{line}");
519}
520
521fn run_end_outcome_str(o: RunEndOutcome) -> &'static str {
522 match o {
523 RunEndOutcome::Completed => "completed",
524 RunEndOutcome::Aborted => "aborted",
525 RunEndOutcome::Failed => "failed",
526 }
527}
528
529pub async fn interactive(
538 harness: &AgentHarness,
539 event_rx: Option<tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>>,
540 args: &Args,
541 model_catalog: Vec<rpi_ai::Model>,
542 initial: Option<String>,
543 extra_messages: &[String],
544 initial_images: Vec<ImageContent>,
545 theme: Option<&str>,
546 no_themes: bool,
547 reload_context: &crate::session::ReloadContext,
548) -> i32 {
549 if let Some(reason) = crate::session::dispatch_session_event_async(
556 reload_context,
557 rpi_plugin_sdk::EventTag::BeforeTuiStart,
558 )
559 .await
560 {
561 eprintln!("[rpi] startup vetoed by extension: {reason}");
562 return crate::app::EXIT_VETOED;
563 }
564
565 let force_tui = std::env::var("RPI_FORCE_TUI")
567 .map(|v| v == "1")
568 .unwrap_or(false);
569 let code = if force_tui || crate::interactive_tui::is_tui_supported() {
570 crate::interactive_tui::interactive_tui(
572 harness,
573 event_rx,
574 args,
575 model_catalog,
576 initial,
577 extra_messages,
578 initial_images,
579 theme,
580 no_themes,
581 reload_context,
582 )
583 .await
584 } else {
585 interactive_repl(harness, args, initial, extra_messages, initial_images).await
587 };
588
589 if let Some(reason) = crate::session::dispatch_session_event_async(
595 reload_context,
596 rpi_plugin_sdk::EventTag::SessionShutdown,
597 )
598 .await
599 {
600 tracing::warn!("[rpi] extension vetoed SessionShutdown (ignored, session closing): {reason}");
601 }
602
603 code
604}
605
606pub async fn interactive_repl(
608 harness: &AgentHarness,
609 #[allow(unused_variables)] args: &Args,
610 initial: Option<String>,
611 extra_messages: &[String],
612 initial_images: Vec<ImageContent>,
613) -> i32 {
614 let lane: Arc<dyn AgentLane> = harness.lane("main");
616 let stdin = std::io::stdin();
617 let is_tty = stdin.is_terminal();
618
619 if is_tty {
620 println!(
621 "rpi interactive (v1 minimal REPL). Type /exit to quit, /abort to cancel a run.\n"
622 );
623 }
624
625 let mut prompts: Vec<String> = Vec::new();
627 if let Some(init) = initial {
628 prompts.push(init);
629 }
630 for m in extra_messages {
631 prompts.push(m.clone());
632 }
633 let mut images = initial_images;
634 for prompt in prompts {
635 if let Err(code) = run_one(&lane, &prompt, std::mem::take(&mut images)).await {
636 return code;
637 }
638 }
639
640 let mut line = String::new();
642 loop {
643 if is_tty {
644 print!("> ");
645 let _ = std::io::stdout().flush();
646 }
647 line.clear();
648 match stdin.lock().read_line(&mut line) {
649 Ok(0) => break, Ok(_) => {}
651 Err(_) => break,
652 }
653 let trimmed = line.trim();
654 if trimmed.is_empty() {
655 continue;
656 }
657 if trimmed == "/exit" || trimmed == "/quit" {
658 break;
659 }
660 if trimmed == "/abort" {
661 let _ = lane.abort().await;
662 eprintln!("(aborted)");
663 continue;
664 }
665 if let Err(code) = run_one(&lane, trimmed, Vec::new()).await {
666 return code;
667 }
668 }
669 0
670}
671
672async fn run_one(
676 lane: &Arc<dyn AgentLane>,
677 prompt: &str,
678 images: Vec<ImageContent>,
679) -> Result<(), i32> {
680 match lane.prompt_text(prompt, images).await {
681 Ok(result) => {
682 match &result.outcome {
683 HarnessRunOutcome::Completed { final_message, .. }
684 | HarnessRunOutcome::Aborted { final_message, .. } => {
685 let text = assistant_text(final_message);
686 if !text.is_empty() {
687 println!("{text}");
688 }
689 }
690 HarnessRunOutcome::Failed {
691 error,
692 final_message,
693 ..
694 } => {
695 if let Some(m) = final_message {
696 if let Some(em) = &m.error_message {
697 eprintln!("error: {em}");
698 }
699 }
700 eprintln!("run failed: {error:?}");
701 }
702 HarnessRunOutcome::Suspended { .. } => {
703 eprintln!("run suspended (deferred) — resume not supported in v1");
704 }
705 }
706 Ok(())
707 }
708 Err(e) => {
709 eprintln!("prompt rejected: {e}");
710 Err(1)
711 }
712 }
713}
714
715#[cfg(test)]
716mod tests {
717 use super::*;
718 use rpi_agent::events::AgentEvent;
719 use rpi_agent::types::AgentToolResult;
720 use rpi_ai::types::{
721 AssistantMessage, Content, StopReason, TextContent, TextContentType, Usage,
722 };
723 use rpi_harness::session::types::OperationError;
724
725 fn assistant(text: &str, stop: StopReason) -> AssistantMessage {
726 AssistantMessage {
727 role: rpi_ai::types::AssistantRole,
728 content: vec![Content::Text(TextContent {
729 kind: TextContentType,
730 text: text.into(),
731 text_signature: None,
732 })],
733 api: rpi_ai::Api::AnthropicMessages,
734 provider: "anthropic".into(),
735 model: "claude-sonnet-5".into(),
736 response_model: None,
737 response_id: None,
738 usage: Usage::zero(),
739 stop_reason: stop,
740 deferred: None,
741 error_message: None,
742 raw_stop_reason: None,
743 end_turn: None,
744 timestamp: 0,
745 }
746 }
747
748 #[test]
749 fn assistant_text_concatenates_text_blocks() {
750 let m = assistant("hello", StopReason::Stop);
751 assert_eq!(assistant_text(&m), "hello");
752 }
753
754 #[test]
755 fn outcome_exit_code_maps_failed_aborted_to_1() {
756 let failed = HarnessRunOutcome::Failed {
757 leaf_id: "l".into(),
758 error: OperationError {
759 code: "boom".into(),
760 message: "boom".into(),
761 },
762 final_entry_id: None,
763 final_message: None,
764 };
765 assert_eq!(outcome_exit_code(&failed), 1);
766 let completed = HarnessRunOutcome::Completed {
767 leaf_id: "l".into(),
768 final_entry_id: "e".into(),
769 final_message: assistant("ok", StopReason::Stop),
770 };
771 assert_eq!(outcome_exit_code(&completed), 0);
772 }
773
774 #[test]
775 fn run_end_outcome_str_roundtrip() {
776 assert_eq!(run_end_outcome_str(RunEndOutcome::Completed), "completed");
777 assert_eq!(run_end_outcome_str(RunEndOutcome::Aborted), "aborted");
778 assert_eq!(run_end_outcome_str(RunEndOutcome::Failed), "failed");
779 }
780
781 #[test]
782 fn agent_event_projection_keeps_terminal_and_tool_payloads() {
783 let end = agent_event_json(&AgentEvent::AgentEnd { messages: vec![] });
784 assert_eq!(end["type"], "agent_end");
785 assert_eq!(end["messages"], serde_json::json!([]));
786
787 let tool = agent_event_json(&AgentEvent::ToolExecutionEnd {
788 tool_call_id: "call-1".into(),
789 tool_name: "read".into(),
790 result: AgentToolResult::text("hello"),
791 is_error: false,
792 });
793 assert_eq!(tool["type"], "tool_execution_end");
794 assert_eq!(tool["result"]["content"][0]["text"], "hello");
795 assert_eq!(tool["result"]["terminate"], false);
796
797 let retry = agent_event_json(&AgentEvent::RetryScheduled {
798 attempt: 3,
799 max_retries: 10,
800 delay_ms: 8_000,
801 error: "503 service unavailable".into(),
802 });
803 assert_eq!(retry["type"], "retry_scheduled");
804 assert_eq!(retry["attempt"], 3);
805 assert_eq!(retry["maxRetries"], 10);
806 assert_eq!(retry["delayMs"], 8_000);
807 assert_eq!(retry["error"], "503 service unavailable");
808 }
809}