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::CompactionProgress(e) => {
509 serde_json::json!({ "type": "compaction_progress", "lane": e.lane, "manual": e.manual, "active": e.active })
510 }
511 HarnessEvent::RunStart(e) => serde_json::json!({
512 "type": "run_start",
513 "lane": e.lane,
514 "runId": e.run_id,
515 }),
516 HarnessEvent::RunEnd(e) => serde_json::json!({
517 "type": "run_end",
518 "lane": e.lane,
519 "runId": e.run_id,
520 "outcome": run_end_outcome_str(e.outcome),
521 "leafId": e.leaf_id,
522 }),
523 };
524 println!("{line}");
525}
526
527fn run_end_outcome_str(o: RunEndOutcome) -> &'static str {
528 match o {
529 RunEndOutcome::Completed => "completed",
530 RunEndOutcome::Aborted => "aborted",
531 RunEndOutcome::Failed => "failed",
532 }
533}
534
535pub async fn interactive(
544 harness: &AgentHarness,
545 event_rx: Option<tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>>,
546 args: &Args,
547 model_catalog: Vec<rpi_ai::Model>,
548 initial: Option<String>,
549 extra_messages: &[String],
550 initial_images: Vec<ImageContent>,
551 theme: Option<&str>,
552 no_themes: bool,
553 reload_context: &crate::session::ReloadContext,
554) -> i32 {
555 if let Some(reason) = crate::session::dispatch_session_event_async(
562 reload_context,
563 rpi_plugin_sdk::EventTag::BeforeTuiStart,
564 )
565 .await
566 {
567 eprintln!("[rpi] startup vetoed by extension: {reason}");
568 return crate::app::EXIT_VETOED;
569 }
570
571 let force_tui = std::env::var("RPI_FORCE_TUI")
573 .map(|v| v == "1")
574 .unwrap_or(false);
575 let code = if force_tui || crate::interactive_tui::is_tui_supported() {
576 crate::interactive_tui::interactive_tui(
578 harness,
579 event_rx,
580 args,
581 model_catalog,
582 initial,
583 extra_messages,
584 initial_images,
585 theme,
586 no_themes,
587 reload_context,
588 )
589 .await
590 } else {
591 interactive_repl(harness, args, initial, extra_messages, initial_images).await
593 };
594
595 if let Some(reason) = crate::session::dispatch_session_event_async(
601 reload_context,
602 rpi_plugin_sdk::EventTag::SessionShutdown,
603 )
604 .await
605 {
606 tracing::warn!(
607 "[rpi] extension vetoed SessionShutdown (ignored, session closing): {reason}"
608 );
609 }
610
611 code
612}
613
614pub async fn interactive_repl(
616 harness: &AgentHarness,
617 #[allow(unused_variables)] args: &Args,
618 initial: Option<String>,
619 extra_messages: &[String],
620 initial_images: Vec<ImageContent>,
621) -> i32 {
622 let lane: Arc<dyn AgentLane> = harness.lane("main");
624 let stdin = std::io::stdin();
625 let is_tty = stdin.is_terminal();
626
627 if is_tty {
628 println!(
629 "rpi interactive (v1 minimal REPL). Type /exit to quit, /abort to cancel a run.\n"
630 );
631 }
632
633 let mut prompts: Vec<String> = Vec::new();
635 if let Some(init) = initial {
636 prompts.push(init);
637 }
638 for m in extra_messages {
639 prompts.push(m.clone());
640 }
641 let mut images = initial_images;
642 for prompt in prompts {
643 if let Err(code) = run_one(&lane, &prompt, std::mem::take(&mut images)).await {
644 return code;
645 }
646 }
647
648 let mut line = String::new();
650 loop {
651 if is_tty {
652 print!("> ");
653 let _ = std::io::stdout().flush();
654 }
655 line.clear();
656 match stdin.lock().read_line(&mut line) {
657 Ok(0) => break, Ok(_) => {}
659 Err(_) => break,
660 }
661 let trimmed = line.trim();
662 if trimmed.is_empty() {
663 continue;
664 }
665 if trimmed == "/exit" || trimmed == "/quit" {
666 break;
667 }
668 if trimmed == "/abort" {
669 let _ = lane.abort().await;
670 eprintln!("(aborted)");
671 continue;
672 }
673 if let Err(code) = run_one(&lane, trimmed, Vec::new()).await {
674 return code;
675 }
676 }
677 0
678}
679
680async fn run_one(
684 lane: &Arc<dyn AgentLane>,
685 prompt: &str,
686 images: Vec<ImageContent>,
687) -> Result<(), i32> {
688 match lane.prompt_text(prompt, images).await {
689 Ok(result) => {
690 match &result.outcome {
691 HarnessRunOutcome::Completed { final_message, .. }
692 | HarnessRunOutcome::Aborted { final_message, .. } => {
693 let text = assistant_text(final_message);
694 if !text.is_empty() {
695 println!("{text}");
696 }
697 }
698 HarnessRunOutcome::Failed {
699 error,
700 final_message,
701 ..
702 } => {
703 if let Some(m) = final_message {
704 if let Some(em) = &m.error_message {
705 eprintln!("error: {em}");
706 }
707 }
708 eprintln!("run failed: {error:?}");
709 }
710 HarnessRunOutcome::Suspended { .. } => {
711 eprintln!("run suspended (deferred) — resume not supported in v1");
712 }
713 }
714 Ok(())
715 }
716 Err(e) => {
717 eprintln!("prompt rejected: {e}");
718 Err(1)
719 }
720 }
721}
722
723#[cfg(test)]
724mod tests {
725 use super::*;
726 use rpi_agent::events::AgentEvent;
727 use rpi_agent::types::AgentToolResult;
728 use rpi_ai::types::{
729 AssistantMessage, Content, StopReason, TextContent, TextContentType, Usage,
730 };
731 use rpi_harness::session::types::OperationError;
732
733 fn assistant(text: &str, stop: StopReason) -> AssistantMessage {
734 AssistantMessage {
735 role: rpi_ai::types::AssistantRole,
736 content: vec![Content::Text(TextContent {
737 kind: TextContentType,
738 text: text.into(),
739 text_signature: None,
740 })],
741 api: rpi_ai::Api::AnthropicMessages,
742 provider: "anthropic".into(),
743 model: "claude-sonnet-5".into(),
744 response_model: None,
745 response_id: None,
746 usage: Usage::zero(),
747 stop_reason: stop,
748 deferred: None,
749 error_message: None,
750 raw_stop_reason: None,
751 end_turn: None,
752 timestamp: 0,
753 }
754 }
755
756 #[test]
757 fn assistant_text_concatenates_text_blocks() {
758 let m = assistant("hello", StopReason::Stop);
759 assert_eq!(assistant_text(&m), "hello");
760 }
761
762 #[test]
763 fn outcome_exit_code_maps_failed_aborted_to_1() {
764 let failed = HarnessRunOutcome::Failed {
765 leaf_id: "l".into(),
766 error: OperationError {
767 code: "boom".into(),
768 message: "boom".into(),
769 },
770 final_entry_id: None,
771 final_message: None,
772 };
773 assert_eq!(outcome_exit_code(&failed), 1);
774 let completed = HarnessRunOutcome::Completed {
775 leaf_id: "l".into(),
776 final_entry_id: "e".into(),
777 final_message: assistant("ok", StopReason::Stop),
778 };
779 assert_eq!(outcome_exit_code(&completed), 0);
780 }
781
782 #[test]
783 fn run_end_outcome_str_roundtrip() {
784 assert_eq!(run_end_outcome_str(RunEndOutcome::Completed), "completed");
785 assert_eq!(run_end_outcome_str(RunEndOutcome::Aborted), "aborted");
786 assert_eq!(run_end_outcome_str(RunEndOutcome::Failed), "failed");
787 }
788
789 #[test]
790 fn agent_event_projection_keeps_terminal_and_tool_payloads() {
791 let end = agent_event_json(&AgentEvent::AgentEnd { messages: vec![] });
792 assert_eq!(end["type"], "agent_end");
793 assert_eq!(end["messages"], serde_json::json!([]));
794
795 let tool = agent_event_json(&AgentEvent::ToolExecutionEnd {
796 tool_call_id: "call-1".into(),
797 tool_name: "read".into(),
798 result: AgentToolResult::text("hello"),
799 is_error: false,
800 });
801 assert_eq!(tool["type"], "tool_execution_end");
802 assert_eq!(tool["result"]["content"][0]["text"], "hello");
803 assert_eq!(tool["result"]["terminate"], false);
804
805 let retry = agent_event_json(&AgentEvent::RetryScheduled {
806 attempt: 3,
807 max_retries: 10,
808 delay_ms: 8_000,
809 error: "503 service unavailable".into(),
810 });
811 assert_eq!(retry["type"], "retry_scheduled");
812 assert_eq!(retry["attempt"], 3);
813 assert_eq!(retry["maxRetries"], 10);
814 assert_eq!(retry["delayMs"], 8_000);
815 assert_eq!(retry["error"], "503 service unavailable");
816 }
817}