1use futures::future::BoxFuture;
5use oino_ai::{
6 AgentMessage, CustomMessage, ImageContent, JsonValue, UserContent, UserContentField,
7 UserMessage,
8};
9use serde::{Deserialize, Serialize};
10use serde_json::json;
11use tokio_util::sync::CancellationToken;
12
13use super::{AgentSession, IsolatedTask, SessionOpError, lock};
14use crate::events::AgentSessionEvent;
15use crate::extensions::api::CallKind;
16use crate::extensions::host::{CustomMessageDraft, Delivery};
17use crate::extensions::runner::InputOutcome;
18
19#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "lowercase")]
22pub enum PromptDisposition {
23 Handled,
25 Queued,
27 Started,
29}
30
31#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "camelCase")]
34pub enum StreamingBehavior {
35 Steer,
37 FollowUp,
39}
40
41impl StreamingBehavior {
42 const fn as_str(self) -> &'static str {
43 match self {
44 Self::Steer => "steer",
45 Self::FollowUp => "followUp",
46 }
47 }
48}
49
50#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "lowercase")]
53pub enum InputSource {
54 #[default]
56 Interactive,
57 Rpc,
59 Extension,
61}
62
63impl InputSource {
64 const fn as_str(self) -> &'static str {
65 match self {
66 Self::Interactive => "interactive",
67 Self::Rpc => "rpc",
68 Self::Extension => "extension",
69 }
70 }
71}
72
73#[derive(Clone, Debug, PartialEq)]
75pub struct PromptOptions {
76 pub expand_prompt_templates: bool,
78 pub images: Vec<ImageContent>,
80 pub streaming_behavior: Option<StreamingBehavior>,
82 pub source: InputSource,
84}
85
86impl Default for PromptOptions {
87 fn default() -> Self {
88 Self {
89 expand_prompt_templates: true,
90 images: Vec::new(),
91 streaming_behavior: None,
92 source: InputSource::Interactive,
93 }
94 }
95}
96
97#[derive(Clone, Debug)]
99pub(crate) enum Trigger {
100 User {
101 prompt: String,
102 images: Vec<ImageContent>,
103 },
104 Custom(CustomMessage),
105}
106
107fn user_message(text: String, images: Vec<ImageContent>) -> AgentMessage {
108 let mut content = vec![UserContent::text(text)];
109 content.extend(images.into_iter().map(UserContent::Image));
110 AgentMessage::User(UserMessage {
111 content: UserContentField::Blocks(content),
112 timestamp: oino_agent::now_ms(),
113 })
114}
115
116fn images_json(images: &[ImageContent]) -> JsonValue {
117 JsonValue::Array(
118 images
119 .iter()
120 .map(|i| json!({"type": "image", "data": i.data, "mimeType": i.mime_type}))
121 .collect(),
122 )
123}
124
125type Deferred = BoxFuture<'static, ()>;
126
127impl AgentSession {
128 pub fn prompt(
137 &self,
138 text: String,
139 options: PromptOptions,
140 ) -> BoxFuture<'_, Result<PromptDisposition, SessionOpError>> {
141 Box::pin(self.prompt_inner(text, options))
142 }
143
144 async fn prompt_inner(
145 &self,
146 text: String,
147 options: PromptOptions,
148 ) -> Result<PromptDisposition, SessionOpError> {
149 if lock(&self.inner.book).emitting_settled {
150 let session = self.clone();
151 self.defer(Box::pin(async move {
152 if let Err(error) = session.prompt(text, options).await {
153 session
154 .runner()
155 .emit_error("<runtime>", "prompt", error.to_string());
156 }
157 }));
158 return Ok(PromptDisposition::Queued);
159 }
160 if options.expand_prompt_templates
161 && text.starts_with('/')
162 && self.try_execute_extension_command(&text).await
163 {
164 return Ok(PromptDisposition::Handled);
165 }
166 if lock(&self.inner.cancels).compaction.is_some() {
167 return Err(SessionOpError::CompactionInProgress);
168 }
169 let streaming = self.is_streaming();
170 let Some((text, images)) = self
171 .run_input_handlers(
172 text,
173 options.images,
174 options.source,
175 options.streaming_behavior.filter(|_| streaming),
176 )
177 .await
178 else {
179 return Ok(PromptDisposition::Handled);
180 };
181 let expanded = if options.expand_prompt_templates {
182 self.expand_templates(&text)
183 } else {
184 text
185 };
186 if self.is_streaming() {
187 let behavior = options
188 .streaming_behavior
189 .ok_or(SessionOpError::StreamingWithoutBehavior)?;
190 self.queue_input(behavior, expanded, images);
191 return Ok(PromptDisposition::Queued);
192 }
193 self.validate_model_and_auth()?;
194 self.start_run(Trigger::User {
195 prompt: expanded,
196 images,
197 })
198 .await?;
199 Ok(PromptDisposition::Started)
200 }
201
202 pub async fn steer(
208 &self,
209 text: String,
210 images: Vec<ImageContent>,
211 source: InputSource,
212 ) -> Result<PromptDisposition, SessionOpError> {
213 self.queue_user_input(text, images, StreamingBehavior::Steer, source)
214 .await
215 }
216
217 pub async fn follow_up(
223 &self,
224 text: String,
225 images: Vec<ImageContent>,
226 source: InputSource,
227 ) -> Result<PromptDisposition, SessionOpError> {
228 self.queue_user_input(text, images, StreamingBehavior::FollowUp, source)
229 .await
230 }
231
232 async fn queue_user_input(
233 &self,
234 text: String,
235 images: Vec<ImageContent>,
236 behavior: StreamingBehavior,
237 source: InputSource,
238 ) -> Result<PromptDisposition, SessionOpError> {
239 if text.starts_with('/') {
240 let name = command_name(&text);
241 if self.runner().command(name).is_some() {
242 return Err(SessionOpError::CommandNotAllowed(name.to_owned()));
243 }
244 }
245 let streaming = self.is_streaming();
246 let Some((text, images)) = self
247 .run_input_handlers(text, images, source, streaming.then_some(behavior))
248 .await
249 else {
250 return Ok(PromptDisposition::Handled);
251 };
252 let expanded = self.expand_templates(&text);
253 self.queue_input(behavior, expanded, images);
254 Ok(PromptDisposition::Queued)
255 }
256
257 fn queue_input(&self, behavior: StreamingBehavior, text: String, images: Vec<ImageContent>) {
258 {
259 let mut book = lock(&self.inner.book);
260 match behavior {
261 StreamingBehavior::Steer => book.steering.push(text.clone()),
262 StreamingBehavior::FollowUp => book.follow_up.push(text.clone()),
263 }
264 }
265 self.emit_queue_update();
266 let message = user_message(text, images);
267 match behavior {
268 StreamingBehavior::Steer => self.inner.agent.steer(message),
269 StreamingBehavior::FollowUp => self.inner.agent.follow_up(message),
270 }
271 }
272
273 fn expand_templates(&self, text: &str) -> String {
274 let expanded = self.expand_skill_command(text);
275 let resources = self
276 .inner
277 .resources
278 .read()
279 .unwrap_or_else(std::sync::PoisonError::into_inner);
280 oino_resources::templates::expand_prompt_template(&expanded, resources.prompts())
281 .unwrap_or(expanded)
282 }
283
284 fn expand_skill_command(&self, text: &str) -> String {
286 let resources = self
287 .inner
288 .resources
289 .read()
290 .unwrap_or_else(std::sync::PoisonError::into_inner);
291 let path = oino_resources::skills::parse_skill_command(text).and_then(|command| {
292 resources
293 .skills()
294 .iter()
295 .find(|skill| skill.name == command.name)
296 .map(|skill| skill.file_path.display().to_string())
297 });
298 match oino_resources::skills::expand_skill_command(text, resources.skills()) {
299 Ok(Some(block)) => block,
300 Ok(None) => text.to_owned(),
301 Err(error) => {
302 drop(resources);
303 self.runner().emit_error(
304 path.as_deref().unwrap_or("<skill>"),
305 "skill_expansion",
306 error.to_string(),
307 );
308 text.to_owned()
309 }
310 }
311 }
312
313 async fn run_input_handlers(
314 &self,
315 text: String,
316 images: Vec<ImageContent>,
317 source: InputSource,
318 streaming: Option<StreamingBehavior>,
319 ) -> Option<(String, Vec<ImageContent>)> {
320 let runner = self.runner();
321 if !runner.has_handlers("input") {
322 return Some((text, images));
323 }
324 let images_opt = (!images.is_empty()).then_some(images.as_slice());
325 match runner
326 .emit_input(
327 &text,
328 images_opt,
329 source.as_str(),
330 streaming.map(StreamingBehavior::as_str),
331 )
332 .await
333 {
334 InputOutcome::Handled => None,
335 InputOutcome::Transform { text, images: next } => Some((text, next.unwrap_or(images))),
336 InputOutcome::Continue => Some((text, images)),
337 }
338 }
339
340 fn validate_model_and_auth(&self) -> Result<(), SessionOpError> {
341 let docs = self.inner.docs_root.join("docs");
342 let Some(model) = self.model() else {
343 return Err(SessionOpError::NoModel(
344 oino_models::auth::guidance::no_model_selected_message(&docs),
345 ));
346 };
347 let models = &self.inner.models;
348 if models.has_configured_auth(&model.provider)
349 || models.check_auth(&model.provider).is_some()
350 {
351 return Ok(());
352 }
353 let message = if models.is_using_oauth(&model.provider) {
354 super::hooks::oauth_failed_message(&model.provider)
355 } else {
356 oino_models::auth::guidance::no_api_key_found_message(&model.provider, &docs)
357 };
358 Err(SessionOpError::NoApiKey {
359 provider: model.provider,
360 message,
361 })
362 }
363
364 async fn try_execute_extension_command(&self, text: &str) -> bool {
366 let name = command_name(text);
367 let args = text.split_once(' ').map_or("", |(_, rest)| rest);
368 let runner = self.runner();
369 let Some(command) = runner.command(name) else {
370 return false;
371 };
372 self.record_command_echo(&command.spec.name, args);
373 let cx = runner.call_context().with_kind(CallKind::Command);
374 let result = command
375 .extension
376 .instance
377 .run_command(&command.spec.name, args, CancellationToken::new(), cx)
378 .await;
379 if let Err(error) = result {
380 runner.emit_error(&format!("command:{name}"), "command", error.message);
381 }
382 true
383 }
384
385 fn record_command_echo(&self, name: &str, args: &str) {
387 let appended = lock(&self.inner.manager).append_custom_entry(
388 "oino.command-echo",
389 Some(json!({"name": name, "args": args})),
390 );
391 if let Ok(id) = appended {
392 self.publish_entry(&id);
393 }
394 }
395
396 pub fn send_custom_message(
402 &self,
403 draft: CustomMessageDraft,
404 trigger_turn: bool,
405 deliver_as: Option<Delivery>,
406 ) -> BoxFuture<'_, Result<(), SessionOpError>> {
407 Box::pin(self.send_custom_message_inner(draft, trigger_turn, deliver_as))
408 }
409
410 async fn send_custom_message_inner(
411 &self,
412 draft: CustomMessageDraft,
413 trigger_turn: bool,
414 deliver_as: Option<Delivery>,
415 ) -> Result<(), SessionOpError> {
416 let message = CustomMessage {
417 custom_type: draft.custom_type,
418 content: draft.content,
419 display: draft.display,
420 details: draft.details,
421 timestamp: oino_agent::now_ms(),
422 };
423 let streaming = self.is_streaming();
424 if deliver_as == Some(Delivery::NextTurn) {
425 lock(&self.inner.book).pending_next_turn.push(message);
426 } else if streaming && trigger_turn {
427 if deliver_as == Some(Delivery::FollowUp) {
428 self.inner.agent.follow_up(AgentMessage::Custom(message));
429 } else {
430 self.inner.agent.steer(AgentMessage::Custom(message));
431 }
432 } else if trigger_turn {
433 if lock(&self.inner.book).emitting_settled {
434 let session = self.clone();
435 let draft = CustomMessageDraft {
436 custom_type: message.custom_type,
437 content: message.content,
438 display: message.display,
439 details: message.details,
440 };
441 self.defer(Box::pin(async move {
442 if let Err(error) = session
443 .send_custom_message(draft, trigger_turn, deliver_as)
444 .await
445 {
446 session
447 .runner()
448 .emit_error("<runtime>", "send_message", error.to_string());
449 }
450 }));
451 return Ok(());
452 }
453 self.start_run(Trigger::Custom(message)).await?;
454 } else if streaming {
455 lock(&self.inner.book).pending_custom.push(message);
456 } else {
457 self.append_custom_message(message);
458 }
459 Ok(())
460 }
461
462 pub async fn send_user_message(
468 &self,
469 content: UserContentField,
470 deliver_as: Option<Delivery>,
471 expand_prompt_templates: bool,
472 ) -> Result<PromptDisposition, SessionOpError> {
473 let (text, images) = match content {
474 UserContentField::Text(text) => (text, Vec::new()),
475 UserContentField::Blocks(blocks) => {
476 let mut texts = Vec::new();
477 let mut images = Vec::new();
478 for block in blocks {
479 match block {
480 UserContent::Text(text) => texts.push(text.text),
481 UserContent::Image(image) => images.push(image),
482 }
483 }
484 (texts.join("\n"), images)
485 }
486 };
487 let streaming_behavior = match deliver_as {
488 Some(Delivery::Steer) => Some(StreamingBehavior::Steer),
489 Some(Delivery::FollowUp) => Some(StreamingBehavior::FollowUp),
490 _ => None,
491 };
492 self.prompt(
493 text,
494 PromptOptions {
495 expand_prompt_templates,
496 images,
497 streaming_behavior,
498 source: InputSource::Extension,
499 },
500 )
501 .await
502 }
503
504 pub(crate) fn append_custom_message(&self, message: CustomMessage) {
505 let appended = lock(&self.inner.manager).append_custom_message_entry(
506 &message.custom_type,
507 message.content.clone(),
508 message.display,
509 message.details.clone(),
510 );
511 if let Err(error) = appended {
512 tracing::error!("persisting custom message failed: {error}");
513 }
514 self.refresh_context();
515 let message = AgentMessage::Custom(message);
516 self.emit(AgentSessionEvent::MessageStart {
517 message: message.clone(),
518 });
519 self.emit(AgentSessionEvent::MessageEnd { message });
520 }
521
522 pub(crate) fn flush_pending_custom_messages(&self) {
523 let pending = std::mem::take(&mut lock(&self.inner.book).pending_custom);
524 if pending.is_empty() {
525 return;
526 }
527 for message in pending {
528 self.append_custom_message(message);
529 }
530 self.boundary_sync("flush pending custom messages");
532 }
533
534 fn defer(&self, action: Deferred) {
535 let session = self.clone();
536 self.spawn_isolated(IsolatedTask::Drive, async move {
537 while lock(&session.inner.book).emitting_settled {
539 tokio::task::yield_now().await;
540 }
541 action.await;
542 });
543 }
544
545 pub(crate) async fn start_run(&self, trigger: Trigger) -> Result<(), SessionOpError> {
547 {
548 let mut book = lock(&self.inner.book);
549 book.run_active = true;
550 book.abort_requested = false;
551 }
552 self.clear_waiting(crate::events::RunState::Running);
553 self.mark_busy();
554 let messages = match self.prepare_run(trigger).await {
555 Ok(messages) => messages,
556 Err(error) => {
557 lock(&self.inner.book).run_active = false;
558 self.flush_pending_bash_messages();
559 self.flush_pending_custom_messages();
560 self.set_idle_if_idle();
561 return Err(error);
562 }
563 };
564 self.flush_pending_bash_messages();
565 self.flush_pending_custom_messages();
566 lock(&self.inner.book).failed_response = None;
567 self.record_selection();
568 self.clear_pending_tools();
569 let session = self.clone();
570 self.spawn_isolated(IsolatedTask::Drive, async move {
571 session.drive_run(messages).await;
572 });
573 Ok(())
574 }
575
576 async fn drive_run(self, messages: Vec<AgentMessage>) {
577 if let Err(error) = self.inner.agent.prompt(messages).await {
578 tracing::error!("agent prompt failed: {error}");
579 }
580 loop {
581 if self.abort_requested() {
582 break;
583 }
584 if self.handle_post_agent_run().await {
585 if self.abort_requested() {
586 break;
587 }
588 if self.inner.agent.continue_run().await.is_err() {
589 break;
590 }
591 continue;
592 }
593 if self.abort_requested() || !self.run_before_settle_boundary().await {
594 break;
595 }
596 if self.abort_requested() || self.inner.agent.continue_run().await.is_err() {
597 break;
598 }
599 }
600 if self.abort_requested() {
601 self.finish_cancelled_retry();
602 }
603 {
604 let mut book = lock(&self.inner.book);
605 book.failed_response = None;
606 book.run_prompt_options = None;
607 }
608 self.flush_pending_bash_messages();
609 self.flush_pending_custom_messages();
610 self.emit_agent_settled().await;
611 }
612
613 pub(crate) fn abort_requested(&self) -> bool {
614 lock(&self.inner.book).abort_requested
615 }
616
617 async fn prepare_run(&self, trigger: Trigger) -> Result<Vec<AgentMessage>, SessionOpError> {
619 if let Some(last) = self.last_assistant_message() {
620 self.check_compaction(&last, false).await;
621 }
622 let mut base = self.system_prompt_options();
623 self.apply_skill_opt_in(&mut base.skills);
624 let runner = self.runner();
625 let mut trigger_json = serde_json::Map::new();
626 match &trigger {
627 Trigger::User { prompt, images } => {
628 trigger_json.insert("messageType".into(), json!("user"));
629 trigger_json.insert("prompt".into(), json!(prompt));
630 if !images.is_empty() {
631 trigger_json.insert("images".into(), images_json(images));
632 }
633 }
634 Trigger::Custom(message) => {
635 trigger_json.insert("messageType".into(), json!("custom"));
636 trigger_json.insert(
637 "message".into(),
638 json!(AgentMessage::Custom(message.clone())),
639 );
640 }
641 }
642 let mut options = base.clone();
643 let mut injected = Vec::new();
644 if runner.has_handlers("before_agent_start") {
645 let docs_root = self.inner.docs_root.clone();
646 let render = move |options: &JsonValue| -> String {
647 serde_json::from_value::<oino_resources::BuildSystemPromptOptions>(options.clone())
648 .ok()
649 .and_then(|o| {
650 oino_resources::system_prompt::build_system_prompt(&o, &docs_root).ok()
651 })
652 .unwrap_or_default()
653 };
654 let outcome = runner
655 .emit_before_agent_start(trigger_json, json!(base), &render)
656 .await;
657 if let Ok(next) = serde_json::from_value(outcome.system_prompt_options) {
658 options = next;
659 }
660 if outcome.system_prompt.is_some() {
661 options.force_system_prompt = outcome.system_prompt;
662 }
663 injected = outcome.messages;
664 }
665 if options.selected_tools == base.selected_tools {
666 options.selected_tools = self.active_tool_names();
667 }
668 let mut messages = Vec::new();
669 match trigger {
670 Trigger::Custom(message) => messages.push(AgentMessage::Custom(message)),
671 Trigger::User { prompt, images } => messages.push(user_message(prompt, images)),
672 }
673 let next_turn = std::mem::take(&mut lock(&self.inner.book).pending_next_turn);
674 messages.extend(next_turn.into_iter().map(AgentMessage::Custom));
675 for draft in injected {
676 let content = draft
677 .get("content")
678 .filter(|c| !c.is_null())
679 .and_then(|c| serde_json::from_value(c.clone()).ok())
680 .unwrap_or(UserContentField::Blocks(Vec::new()));
681 messages.push(AgentMessage::Custom(CustomMessage {
682 custom_type: draft
683 .get("customType")
684 .and_then(JsonValue::as_str)
685 .unwrap_or_default()
686 .to_owned(),
687 content,
688 display: draft
689 .get("display")
690 .and_then(JsonValue::as_bool)
691 .unwrap_or(false),
692 details: draft.get("details").cloned(),
693 timestamp: oino_agent::now_ms(),
694 }));
695 }
696 let current = self.messages();
697 let update = self.prepare_prompt_and_tool_loadout(&mut options, ¤t);
698 lock(&self.inner.book).run_prompt_options = Some(options);
699 if let Some(update) = update {
700 messages.insert(0, AgentMessage::System(update));
701 }
702 Ok(messages)
703 }
704
705 #[must_use]
707 pub fn last_assistant_message(&self) -> Option<oino_ai::AssistantMessage> {
708 self.messages().into_iter().rev().find_map(|m| match m {
709 AgentMessage::Assistant(a) => Some(a),
710 _ => None,
711 })
712 }
713
714 async fn handle_post_agent_run(&self) -> bool {
716 let message = lock(&self.inner.book).last_assistant.take();
717 if self.abort_requested() {
718 self.finish_cancelled_retry();
719 return false;
720 }
721 let Some(message) = message else {
722 return self.inner.agent.has_queued_messages();
723 };
724 if self.is_retryable_error(&message) && self.prepare_retry(&message).await {
725 if self.abort_requested() {
726 self.finish_cancelled_retry();
727 }
728 lock(&self.inner.book).failed_response = Some(message);
729 return !self.abort_requested();
730 }
731 if self.abort_requested() {
732 self.finish_cancelled_retry();
733 return false;
734 }
735 let final_failure = {
736 let mut book = lock(&self.inner.book);
737 if message.stop_reason == oino_ai::StopReason::Error && book.retry_attempt > 0 {
738 let attempt = book.retry_attempt;
739 book.retry_attempt = 0;
740 Some(attempt)
741 } else {
742 None
743 }
744 };
745 if let Some(attempt) = final_failure {
746 self.emit(AgentSessionEvent::AutoRetryEnd {
747 success: false,
748 attempt,
749 final_error: message.error_message.clone(),
750 });
751 }
752 if self.check_compaction(&message, true).await {
753 return !self.abort_requested();
754 }
755 !self.abort_requested() && self.inner.agent.has_queued_messages()
756 }
757
758 async fn emit_agent_settled(&self) {
760 let aborted = {
761 let mut book = lock(&self.inner.book);
762 book.run_active = false;
763 book.emitting_settled = true;
764 book.abort_requested
765 };
766 let runner = self.runner();
767 if runner.has_handlers("agent_settled") {
768 runner
769 .emit(json!({"type": "agent_settled", "aborted": aborted}))
770 .await;
771 }
772 self.emit(AgentSessionEvent::AgentSettled { aborted });
773 lock(&self.inner.book).emitting_settled = false;
774 self.set_idle_if_idle();
775 self.refresh_git_topic_if_followed();
776 }
777}
778
779pub(crate) fn command_name(text: &str) -> &str {
781 let without_slash = text.strip_prefix('/').unwrap_or(text);
782 without_slash
783 .split_once(' ')
784 .map_or(without_slash, |(name, _)| name)
785}