1use std::sync::Arc;
4use std::time::Duration;
5
6use futures::StreamExt;
7use oino_ai::{AgentMessage, AssistantContent, StopReason};
8use oino_core::events::{AgentSessionEvent, ClientEvent};
9use oino_core::runtime::AgentSessionRuntime;
10use oino_core::session::{PromptDisposition, PromptOptions};
11use tokio::io::AsyncWriteExt;
12use tokio::sync::watch;
13
14use crate::cli::files::InitialMessage;
15use crate::modes::signals;
16
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub enum PrintOutput {
20 Text,
22 Json,
24}
25
26#[derive(Clone, Debug, Default, PartialEq)]
28pub struct PrintInput {
29 pub initial: InitialMessage,
31 pub messages: Vec<String>,
33}
34
35fn stop_reason_name(reason: StopReason) -> String {
36 serde_json::to_value(reason)
37 .ok()
38 .and_then(|v| v.as_str().map(str::to_owned))
39 .unwrap_or_default()
40}
41
42async fn forward(
45 mut events: oino_core::hub::EventStream,
46 output: PrintOutput,
47 settled: watch::Sender<u64>,
48) {
49 let mut stdout = tokio::io::stdout();
50 while let Some(event) = events.next().await {
51 if matches!(
52 event,
53 ClientEvent::Session(AgentSessionEvent::AgentSettled { .. })
54 ) {
55 settled.send_modify(|n| *n += 1);
56 }
57 match event {
58 ClientEvent::Session(event) if output == PrintOutput::Json => {
59 let line =
60 oino_rpc::jsonl::serialize_line(&oino_rpc::wire::session_event_json(&event));
61 if stdout.write_all(line.as_bytes()).await.is_err() {
62 return;
63 }
64 let _ = stdout.flush().await;
65 }
66 ClientEvent::ExtensionError(error) => {
67 crate::eprintln_line(&format!(
68 "Extension error ({}): {}",
69 error.error.extension_path, error.error.error
70 ));
71 }
72 _ => {}
73 }
74 }
75}
76
77const SETTLE_GRACE: Duration = Duration::from_secs(5);
80
81async fn prompts(
82 runtime: &AgentSessionRuntime,
83 input: PrintInput,
84 mut settled: watch::Receiver<u64>,
85) -> Result<(), String> {
86 let mut queue: Vec<(String, Vec<oino_ai::ImageContent>)> = Vec::new();
87 if let Some(text) = input.initial.text {
88 queue.push((text, input.initial.images));
89 }
90 queue.extend(input.messages.into_iter().map(|m| (m, Vec::new())));
91 for (text, images) in queue {
92 let session = runtime.session();
93 let before = *settled.borrow_and_update();
94 let disposition = session
95 .prompt(
96 text,
97 PromptOptions {
98 images,
99 ..PromptOptions::default()
100 },
101 )
102 .await
103 .map_err(|e| e.to_string())?;
104 session.wait_for_idle().await;
105 runtime.session().wait_for_idle().await;
106 if disposition == PromptDisposition::Started {
107 let _ = tokio::time::timeout(SETTLE_GRACE, settled.wait_for(|n| *n > before)).await;
108 }
109 }
110 Ok(())
111}
112
113fn text_result(messages: &[AgentMessage]) -> (String, Option<String>, i32) {
115 let Some(AgentMessage::Assistant(last)) = messages.last() else {
116 return (String::new(), None, 0);
117 };
118 if matches!(last.stop_reason, StopReason::Error | StopReason::Aborted) {
119 let message = last
120 .error_message
121 .clone()
122 .filter(|m| !m.is_empty())
123 .unwrap_or_else(|| format!("Request {}", stop_reason_name(last.stop_reason)));
124 return (String::new(), Some(message), 1);
125 }
126 let mut out = String::new();
127 for block in &last.content {
128 if let AssistantContent::Text(text) = block {
129 out.push_str(&text.text);
130 out.push('\n');
131 }
132 }
133 if last.stop_reason == StopReason::Length {
134 return (
135 out,
136 Some("Response was truncated: the model reached its output token limit.".to_owned()),
137 1,
138 );
139 }
140 (out, None, 0)
141}
142
143async fn shutdown_requested(runtime: &AgentSessionRuntime) {
145 let mut requested = runtime.shutdown_requested();
146 if requested.wait_for(|on| *on).await.is_err() {
147 return std::future::pending().await;
148 }
149 runtime.session().wait_for_idle().await;
150}
151
152pub async fn run(runtime: Arc<AgentSessionRuntime>, output: PrintOutput, input: PrintInput) -> i32 {
154 let mut stdout = tokio::io::stdout();
155 if output == PrintOutput::Json {
156 let header = runtime
157 .session()
158 .with_manager(|m| serde_json::to_value(m.header()).ok());
159 if let Some(header) = header {
160 let line = oino_rpc::jsonl::serialize_line(&header);
161 let _ = stdout.write_all(line.as_bytes()).await;
162 let _ = stdout.flush().await;
163 }
164 }
165 let (settled, settled_rx) = watch::channel(0);
166 let forwarder = tokio::spawn(forward(runtime.subscribe(), output, settled));
167 let outcome = tokio::select! {
168 result = prompts(&runtime, input, settled_rx) => Ok(result),
169 () = shutdown_requested(&runtime) => Ok(Ok(())),
170 signal = signals::next() => Err(signal),
171 };
172 let code = match outcome {
173 Err(signal) => signal.exit_code(),
174 Ok(Err(message)) => {
175 crate::eprintln_line(&message);
176 1
177 }
178 Ok(Ok(())) if output == PrintOutput::Text => {
179 let (text, error, code) = text_result(&runtime.session().messages());
180 let _ = stdout.write_all(text.as_bytes()).await;
181 let _ = stdout.flush().await;
182 if let Some(error) = error {
183 crate::eprintln_line(&error);
184 }
185 code
186 }
187 Ok(Ok(())) => 0,
188 };
189 runtime.dispose().await;
190 let _ = forwarder.await;
191 let _ = stdout.flush().await;
192 code
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198 use oino_ai::AssistantMessage;
199
200 fn assistant(stop: StopReason, text: &str, error: Option<&str>) -> Vec<AgentMessage> {
201 let mut message = AssistantMessage::empty("faux", "faux", "m", 1);
202 message.stop_reason = stop;
203 message.content = vec![AssistantContent::text(text)];
204 message.error_message = error.map(str::to_owned);
205 vec![AgentMessage::Assistant(message)]
206 }
207
208 #[test]
209 fn exit_rules() {
210 assert_eq!(
211 text_result(&assistant(StopReason::Stop, "hi", None)),
212 ("hi\n".into(), None, 0)
213 );
214 assert_eq!(
215 text_result(&assistant(StopReason::Error, "x", Some("boom"))),
216 (String::new(), Some("boom".into()), 1)
217 );
218 assert_eq!(
219 text_result(&assistant(StopReason::Aborted, "x", None)).1,
220 Some("Request aborted".into())
221 );
222 let (out, error, code) = text_result(&assistant(StopReason::Length, "part", None));
223 assert_eq!((out.as_str(), code), ("part\n", 1));
224 assert_eq!(
225 error.as_deref(),
226 Some("Response was truncated: the model reached its output token limit.")
227 );
228 assert_eq!(text_result(&[]), (String::new(), None, 0));
229 }
230}