1use std::sync::atomic::{AtomicBool, Ordering};
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6
7use serde_json::{json, Map, Value};
8use tokio::sync::mpsc;
9
10use crate::client::{unwrap_next_frame, SendInputOptions};
11use crate::errors::{Error, Result};
12use crate::intent_hints::validate_loop_input_intent_hint;
13use crate::stream_terminal::is_turn_end_custom_data;
14
15use super::attachments::{compact_attachments, CompactImageOptions};
16use super::broadcaster::{SseBroadcaster, SseEvent};
17use super::classifier::{ChatEventTerminal, EventClassifier};
18use super::pool::ConnectionPool;
19use super::query_gate::{CancelFn, QueryGate, SendCancelFn};
20use super::session_store::SessionStore;
21use super::turn_boundary::{is_daemon_turn_end_event, TurnBoundary};
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub enum TimeoutPolicy {
26 #[default]
28 Fail,
29 SoftComplete,
31}
32
33#[derive(Debug, Clone)]
35pub struct TurnConfig {
36 pub query_timeout: Duration,
38 pub idle_timeout: Duration,
40 pub min_idle_timeout_with_attachments: Duration,
42 pub on_idle_timeout: TimeoutPolicy,
44 pub on_query_timeout: TimeoutPolicy,
46 pub on_stream_close: TimeoutPolicy,
48 pub compact_attachments_before_send: bool,
50 pub compact_image_opts: Option<CompactImageOptions>,
52}
53
54impl Default for TurnConfig {
55 fn default() -> Self {
56 Self {
57 query_timeout: Duration::from_secs(30 * 60),
58 idle_timeout: Duration::ZERO,
59 min_idle_timeout_with_attachments: Duration::ZERO,
60 on_idle_timeout: TimeoutPolicy::Fail,
61 on_query_timeout: TimeoutPolicy::Fail,
62 on_stream_close: TimeoutPolicy::Fail,
63 compact_attachments_before_send: false,
64 compact_image_opts: None,
65 }
66 }
67}
68
69#[derive(Debug, Clone, Default)]
71pub struct InputOpts {
72 pub intent_hint: Option<String>,
74 pub preferred_subagent: Option<String>,
76 pub response_schema: Option<Value>,
78 pub response_schema_name: Option<String>,
80 pub response_schema_strict: Option<bool>,
82}
83
84type OnCompleteFn = Arc<dyn Fn(&str, &str, &str, &str, i64) + Send + Sync>;
85type OnErrorFn = Arc<dyn Fn(&str, &str, &Error) + Send + Sync>;
86type ErrorDataFn = Arc<dyn Fn(&Error) -> Value + Send + Sync>;
87type InputBuilderFn =
88 Arc<dyn Fn(&str, &str, Option<&Value>, Option<&InputOpts>) -> Map<String, Value> + Send + Sync>;
89
90pub struct TurnRunner<S: SessionStore> {
92 pool: Arc<ConnectionPool<S>>,
93 gate: Arc<QueryGate>,
94 classifier: EventClassifier,
95 store: Arc<S>,
96 broadcaster: Option<Arc<SseBroadcaster>>,
97 cfg: TurnConfig,
98 on_complete: Option<OnCompleteFn>,
99 on_error: Option<OnErrorFn>,
100 error_data: Option<ErrorDataFn>,
101 input_builder: Option<InputBuilderFn>,
102}
103
104impl<S: SessionStore + 'static> TurnRunner<S> {
105 pub fn new(
110 pool: Arc<ConnectionPool<S>>,
111 gate: Arc<QueryGate>,
112 classifier: EventClassifier,
113 store: Arc<S>,
114 cfg: Option<TurnConfig>,
115 ) -> Self {
116 Self {
117 pool,
118 gate,
119 classifier,
120 store,
121 broadcaster: None,
122 cfg: cfg.unwrap_or_default(),
123 on_complete: None,
124 on_error: None,
125 error_data: None,
126 input_builder: None,
127 }
128 }
129
130 pub fn gate(&self) -> &Arc<QueryGate> {
132 &self.gate
133 }
134
135 pub fn with_broadcaster(mut self, b: Arc<SseBroadcaster>) -> Self {
137 self.broadcaster = Some(b);
138 self
139 }
140
141 pub fn with_input_builder(mut self, f: InputBuilderFn) -> Self {
143 self.input_builder = Some(f);
144 self
145 }
146
147 pub fn with_on_complete(mut self, f: OnCompleteFn) -> Self {
149 self.on_complete = Some(f);
150 self
151 }
152
153 pub fn with_on_error(mut self, f: OnErrorFn) -> Self {
155 self.on_error = Some(f);
156 self
157 }
158
159 pub fn with_error_data(mut self, f: ErrorDataFn) -> Self {
161 self.error_data = Some(f);
162 self
163 }
164
165 pub async fn execute(
167 &self,
168 session_id: &str,
169 message: &str,
170 user_id: &str,
171 workspace_id: &str,
172 attachments: Option<Value>,
173 opts: Option<InputOpts>,
174 ) -> Result<String> {
175 self.validate_opts(opts.as_ref())?;
176 let cancelled = Arc::new(AtomicBool::new(false));
177 let cancel_flag = cancelled.clone();
178 let cancel_fn: CancelFn = Arc::new(move || {
179 cancel_flag.store(true, Ordering::SeqCst);
180 });
181 self.gate
182 .acquire(session_id, cancel_fn, None)
183 .map_err(|_| Error::msg("query busy"))?;
184 let result = self
185 .run_turn(
186 session_id,
187 message,
188 user_id,
189 workspace_id,
190 attachments,
191 opts,
192 cancelled,
193 )
194 .await;
195 self.gate.release(session_id);
196 result
197 }
198
199 pub async fn execute_reserved(
204 &self,
205 session_id: &str,
206 message: &str,
207 user_id: &str,
208 workspace_id: &str,
209 attachments: Option<Value>,
210 opts: Option<InputOpts>,
211 ) -> Result<String> {
212 if !self.gate.is_active(session_id) {
213 return Err(Error::msg(format!(
214 "appkit: ExecuteReserved requires an active QueryGate reservation for {session_id}"
215 )));
216 }
217 if let Err(e) = self.validate_opts(opts.as_ref()) {
218 self.gate.release(session_id);
219 return Err(e);
220 }
221 let cancelled = Arc::new(AtomicBool::new(false));
222 let cancel_flag = cancelled.clone();
223 let cancel_fn: CancelFn = Arc::new(move || {
224 cancel_flag.store(true, Ordering::SeqCst);
225 });
226 self.gate.replace_cancel(session_id, cancel_fn);
227 let result = self
228 .run_turn(
229 session_id,
230 message,
231 user_id,
232 workspace_id,
233 attachments,
234 opts,
235 cancelled,
236 )
237 .await;
238 self.gate.release(session_id);
239 result
240 }
241
242 fn validate_opts(&self, opts: Option<&InputOpts>) -> Result<()> {
243 if let Some(hint) = opts.and_then(|o| o.intent_hint.as_deref()) {
244 if let Some(msg) = validate_loop_input_intent_hint(hint) {
245 return Err(Error::msg(msg));
246 }
247 }
248 Ok(())
249 }
250
251 #[allow(clippy::too_many_arguments)]
252 async fn run_turn(
253 &self,
254 session_id: &str,
255 message: &str,
256 user_id: &str,
257 workspace_id: &str,
258 attachments: Option<Value>,
259 opts: Option<InputOpts>,
260 cancelled: Arc<AtomicBool>,
261 ) -> Result<String> {
262 let conn = self.pool.acquire(session_id, workspace_id, user_id).await?;
263 let loop_id = conn.get_loop_id().await;
264
265 let loop_id_for_cancel = loop_id.clone();
266 let client_for_cancel = conn.client.clone();
267 let send_cancel: SendCancelFn = Arc::new(move || {
268 let client = client_for_cancel.clone();
269 let loop_id = loop_id_for_cancel.clone();
270 Box::pin(async move { client.command_cancel(&loop_id).await.map(|_| ()) })
271 });
272 self.gate.set_send_cancel(session_id, send_cancel);
273
274 let has_attachments = attachments
275 .as_ref()
276 .map(|v| v.as_array().map(|a| !a.is_empty()).unwrap_or(true))
277 .unwrap_or(false);
278
279 let mut atts = attachments;
280 if self.cfg.compact_attachments_before_send {
281 if let Some(Value::Array(arr)) = atts.take() {
282 let maps: Vec<Map<String, Value>> = arr
283 .into_iter()
284 .filter_map(|v| v.as_object().cloned())
285 .collect();
286 let compacted = compact_attachments(&maps, self.cfg.compact_image_opts.as_ref());
287 atts = Some(Value::Array(
288 compacted.into_iter().map(Value::Object).collect(),
289 ));
290 }
291 }
292
293 {
295 let mut rx_guard = conn.event_rx.lock().await;
296 if let Some(rx) = rx_guard.as_mut() {
297 drain_event_ch(rx, Duration::from_millis(5)).await;
298 } else {
299 let err = Error::msg(format!(
300 "missing event stream for session {session_id} (loop {loop_id})"
301 ));
302 self.fail_turn(session_id, &loop_id, &err).await;
303 return Err(err);
304 }
305 }
306
307 let input_opts = SendInputOptions {
308 loop_id: Some(loop_id.clone()),
309 intent_hint: opts.as_ref().and_then(|o| o.intent_hint.clone()),
310 preferred_subagent: opts.as_ref().and_then(|o| o.preferred_subagent.clone()),
311 response_schema: opts.as_ref().and_then(|o| o.response_schema.clone()),
312 response_schema_name: opts.as_ref().and_then(|o| o.response_schema_name.clone()),
313 response_schema_strict: opts.as_ref().and_then(|o| o.response_schema_strict),
314 attachments: atts.clone(),
315 ..Default::default()
316 };
317
318 if let Some(builder) = &self.input_builder {
319 let flat = builder(message, &loop_id, atts.as_ref(), opts.as_ref());
320 let mut params = Map::new();
321 for (k, v) in flat {
322 if k != "type" && k != "proto" && k != "method" {
323 if k == "params" {
325 if let Value::Object(inner) = v {
326 params.extend(inner);
327 continue;
328 }
329 }
330 params.insert(k, v);
331 }
332 }
333 if let Err(e) = conn.client.notify("loop_input", params).await {
334 let err = Error::msg(format!("send message: {e}"));
335 self.fail_turn(session_id, &loop_id, &err).await;
336 return Err(err);
337 }
338 } else if let Err(e) = conn.client.send_input(message, input_opts).await {
339 self.fail_turn(session_id, &loop_id, &e).await;
340 return Err(e);
341 }
342
343 self.store
344 .append_message(session_id, json!({"role":"user","content": message}))
345 .await;
346
347 let started_at = Instant::now();
348 let deadline = started_at + self.cfg.query_timeout;
349 let idle_for_turn = idle_timeout_for_turn(&self.cfg, has_attachments);
350 let mut last_event = Instant::now();
351 let mut collected = String::new();
352 let mut boundary = TurnBoundary::default();
353 let mut armed = false;
354
355 let result = loop {
356 if cancelled.load(Ordering::SeqCst) {
357 let err = Error::msg("query cancelled");
358 self.fail_turn(session_id, &loop_id, &err).await;
359 break Err(err);
360 }
361 if Instant::now() > deadline {
362 let _ = conn.client.command_cancel(&loop_id).await;
363 break self
364 .finish_timeout(
365 session_id,
366 &loop_id,
367 &collected,
368 started_at,
369 "query_timeout",
370 self.cfg.on_query_timeout,
371 )
372 .await;
373 }
374 if armed && !idle_for_turn.is_zero() && last_event.elapsed() > idle_for_turn {
378 let _ = conn.client.command_cancel(&loop_id).await;
379 break self
380 .finish_timeout(
381 session_id,
382 &loop_id,
383 &collected,
384 started_at,
385 "idle_timeout",
386 self.cfg.on_idle_timeout,
387 )
388 .await;
389 }
390
391 let wait = if !armed || idle_for_turn.is_zero() {
392 Duration::from_millis(500)
393 } else {
394 idle_for_turn
395 .saturating_sub(last_event.elapsed())
396 .min(Duration::from_millis(500))
397 .max(Duration::from_millis(1))
398 };
399
400 let ev = {
401 let mut rx_guard = conn.event_rx.lock().await;
402 let Some(rx) = rx_guard.as_mut() else {
403 break Err(Error::msg("event stream missing"));
404 };
405 match tokio::time::timeout(wait, rx.recv()).await {
406 Ok(Some(v)) => Some(v),
407 Ok(None) => None,
408 Err(_) => continue,
409 }
410 };
411
412 let Some(ev) = ev else {
413 if !conn.event_stream_live().await || !conn.client.is_connection_alive() {
414 if self.cfg.on_stream_close == TimeoutPolicy::SoftComplete
415 && !collected.trim().is_empty()
416 {
417 break self
418 .complete_turn(
419 session_id,
420 &loop_id,
421 &collected,
422 started_at,
423 "stream_closed",
424 )
425 .await;
426 }
427 let err = Error::msg("event stream closed");
428 self.fail_turn(session_id, &loop_id, &err).await;
429 break Err(err);
430 }
431 continue;
432 };
433
434 if !armed {
435 if is_stale_turn_end_event(&ev) {
436 continue;
438 }
439 if is_status_running_event(&ev) {
440 let _ = feed_boundary(&mut boundary, &ev);
441 armed = true;
445 last_event = Instant::now();
446 continue;
447 }
448 armed = true;
449 last_event = Instant::now();
450 }
451
452 let ended = feed_boundary(&mut boundary, &ev);
453 if ended.is_some() && !armed {
454 boundary = TurnBoundary::default();
455 continue;
456 }
457
458 let event_result = self.classifier.classify(&ev, &collected);
459 if advances_idle_clock(&event_result) {
462 last_event = Instant::now();
463 }
464
465 if event_result.terminal == ChatEventTerminal::FailedComplete {
466 let err = Error::msg(
467 event_result
468 .error
469 .unwrap_or_else(|| "process event failed".into()),
470 );
471 self.fail_turn(session_id, &loop_id, &err).await;
472 break Err(err);
473 }
474
475 if !event_result.thinking_step.trim().is_empty() {
476 self.broadcast_thinking_step(session_id, &event_result.thinking_step);
477 }
478
479 if !event_result.content.is_empty() {
480 let delta = if event_result.content.starts_with(&collected) {
481 let d = event_result.content[collected.len()..].to_string();
482 collected = event_result.content.clone();
483 d
484 } else {
485 collected.push_str(&event_result.content);
486 event_result.content.clone()
487 };
488 if !delta.is_empty() {
489 self.broadcast_delta(session_id, &delta);
490 }
491 }
492
493 if let Some(final_text) = self
494 .classifier
495 .resolve_deliverable_final_content(&event_result, &collected)
496 {
497 if !is_daemon_turn_end_event(&event_result.completion_event) {
498 collected = final_text;
499 let completion = event_result.completion_event;
500 break self
501 .complete_turn(session_id, &loop_id, &collected, started_at, &completion)
502 .await;
503 }
504 }
505
506 if let Some(reason) = ended {
507 if collected.trim().is_empty() {
508 let err =
509 Error::msg(format!("turn ended ({reason}) with no assistant content"));
510 self.fail_turn(session_id, &loop_id, &err).await;
511 break Err(err);
512 }
513 break self
514 .complete_turn(session_id, &loop_id, &collected, started_at, reason)
515 .await;
516 }
517 };
518
519 {
521 let mut rx_guard = conn.event_rx.lock().await;
522 if let Some(rx) = rx_guard.as_mut() {
523 drain_event_ch(rx, Duration::ZERO).await;
524 }
525 }
526
527 let _ = conn;
529 result
530 }
531
532 async fn finish_timeout(
533 &self,
534 session_id: &str,
535 loop_id: &str,
536 content: &str,
537 started_at: Instant,
538 completion_event: &str,
539 policy: TimeoutPolicy,
540 ) -> Result<String> {
541 match policy {
542 TimeoutPolicy::SoftComplete => {
545 self.complete_turn(session_id, loop_id, content, started_at, completion_event)
546 .await
547 }
548 _ => {
549 let err = Error::msg(completion_event);
550 self.fail_turn(session_id, loop_id, &err).await;
551 Err(err)
552 }
553 }
554 }
555
556 async fn complete_turn(
557 &self,
558 session_id: &str,
559 loop_id: &str,
560 content: &str,
561 started_at: Instant,
562 completion_event: &str,
563 ) -> Result<String> {
564 let elapsed_ms = started_at.elapsed().as_millis() as i64;
565 self.store
566 .append_message(
567 session_id,
568 json!({
569 "role": "assistant",
570 "content": content,
571 "status": "completed",
572 "completion_event": completion_event,
573 "deliverable": true,
574 "duration_ms": elapsed_ms,
575 }),
576 )
577 .await;
578 self.broadcast_complete(session_id, content);
579 if let Some(hook) = &self.on_complete {
580 hook(session_id, loop_id, content, completion_event, elapsed_ms);
581 }
582 Ok(content.to_string())
583 }
584
585 async fn fail_turn(&self, session_id: &str, loop_id: &str, err: &Error) {
586 self.store
587 .append_message(
588 session_id,
589 json!({
590 "role": "error",
591 "status": "failed",
592 "error_message": err.to_string(),
593 }),
594 )
595 .await;
596 self.broadcast_error(session_id, err);
597 if let Some(hook) = &self.on_error {
598 hook(session_id, loop_id, err);
599 }
600 }
601
602 fn broadcast_delta(&self, app_key: &str, delta: &str) {
603 if let Some(b) = &self.broadcaster {
604 b.broadcast(
605 app_key,
606 SseEvent {
607 event_type: "delta".into(),
608 data: json!(delta),
609 },
610 );
611 }
612 }
613
614 fn broadcast_thinking_step(&self, app_key: &str, step: &str) {
615 if let Some(b) = &self.broadcaster {
616 b.broadcast(
617 app_key,
618 SseEvent {
619 event_type: "thinking_step".into(),
620 data: json!(format!("{step}\n")),
621 },
622 );
623 }
624 }
625
626 fn broadcast_complete(&self, app_key: &str, content: &str) {
627 if let Some(b) = &self.broadcaster {
628 b.broadcast(
629 app_key,
630 SseEvent {
631 event_type: "complete".into(),
632 data: json!(content),
633 },
634 );
635 }
636 }
637
638 fn broadcast_error(&self, app_key: &str, err: &Error) {
639 if let Some(b) = &self.broadcaster {
640 let data = if let Some(fmt) = &self.error_data {
641 fmt(err)
642 } else {
643 json!(err.to_string())
644 };
645 b.broadcast(
646 app_key,
647 SseEvent {
648 event_type: "query_error".into(),
649 data,
650 },
651 );
652 }
653 }
654}
655
656fn idle_timeout_for_turn(cfg: &TurnConfig, has_attachments: bool) -> Duration {
657 let idle = cfg.idle_timeout;
658 if idle.is_zero() {
659 return Duration::ZERO;
660 }
661 if has_attachments
662 && !cfg.min_idle_timeout_with_attachments.is_zero()
663 && idle < cfg.min_idle_timeout_with_attachments
664 {
665 return cfg.min_idle_timeout_with_attachments;
666 }
667 idle
668}
669
670fn advances_idle_clock(result: &super::classifier::ChatEventResult) -> bool {
672 use super::classifier::ChatEventTerminal;
673 if !result.content.trim().is_empty() {
674 return true;
675 }
676 if !result.thinking_step.trim().is_empty() {
677 return true;
678 }
679 matches!(
680 result.terminal,
681 ChatEventTerminal::DeliverableComplete | ChatEventTerminal::FailedComplete
682 )
683}
684
685async fn drain_event_ch(rx: &mut mpsc::Receiver<Value>, settle: Duration) {
687 if settle.is_zero() {
688 while rx.try_recv().is_ok() {}
689 return;
690 }
691 let mut deadline = Instant::now() + settle;
692 loop {
693 let remaining = deadline.saturating_duration_since(Instant::now());
694 if remaining.is_zero() {
695 break;
696 }
697 match tokio::time::timeout(remaining, rx.recv()).await {
698 Ok(Some(_)) => {
699 deadline = Instant::now() + settle;
700 }
701 Ok(None) | Err(_) => break,
702 }
703 }
704}
705
706fn feed_boundary(boundary: &mut TurnBoundary, msg: &Value) -> Option<&'static str> {
707 let frame = if msg.get("type").and_then(|v| v.as_str()) == Some("next") {
708 unwrap_next_frame(msg)
709 } else {
710 msg.clone()
711 };
712 let event_type = frame.get("type").and_then(|v| v.as_str()).unwrap_or("");
713 if event_type == "status" {
714 let state = frame.get("state").and_then(|v| v.as_str()).unwrap_or("");
715 return boundary.feed_status(state);
716 }
717 if event_type == "event" {
718 let mode = frame.get("mode").and_then(|v| v.as_str()).unwrap_or("");
719 let data = frame.get("data").cloned().unwrap_or(Value::Null);
720 return boundary.feed_event(mode, &data);
721 }
722 None
723}
724
725fn is_status_running_event(msg: &Value) -> bool {
726 let frame = if msg.get("type").and_then(|v| v.as_str()) == Some("next") {
727 unwrap_next_frame(msg)
728 } else {
729 msg.clone()
730 };
731 frame.get("type").and_then(|v| v.as_str()) == Some("status")
732 && frame
733 .get("state")
734 .and_then(|v| v.as_str())
735 .unwrap_or("")
736 .eq_ignore_ascii_case("running")
737}
738
739fn is_stale_turn_end_event(msg: &Value) -> bool {
740 let frame = if msg.get("type").and_then(|v| v.as_str()) == Some("next") {
741 unwrap_next_frame(msg)
742 } else {
743 msg.clone()
744 };
745 let event_type = frame.get("type").and_then(|v| v.as_str()).unwrap_or("");
746 if event_type == "status" {
747 let state = frame
748 .get("state")
749 .and_then(|v| v.as_str())
750 .unwrap_or("")
751 .to_ascii_lowercase();
752 return matches!(state.as_str(), "idle" | "stopped");
753 }
754 if event_type == "event" {
755 let mode = frame.get("mode").and_then(|v| v.as_str()).unwrap_or("");
756 let data = frame.get("data").unwrap_or(&Value::Null);
757 return mode == "custom" && is_turn_end_custom_data(data);
758 }
759 false
760}