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 !idle_for_turn.is_zero() && last_event.elapsed() > idle_for_turn {
375 let _ = conn.client.command_cancel(&loop_id).await;
376 break self
377 .finish_timeout(
378 session_id,
379 &loop_id,
380 &collected,
381 started_at,
382 "idle_timeout",
383 self.cfg.on_idle_timeout,
384 )
385 .await;
386 }
387
388 let wait = if idle_for_turn.is_zero() {
389 Duration::from_millis(500)
390 } else {
391 idle_for_turn
392 .saturating_sub(last_event.elapsed())
393 .min(Duration::from_millis(500))
394 .max(Duration::from_millis(1))
395 };
396
397 let ev = {
398 let mut rx_guard = conn.event_rx.lock().await;
399 let Some(rx) = rx_guard.as_mut() else {
400 break Err(Error::msg("event stream missing"));
401 };
402 match tokio::time::timeout(wait, rx.recv()).await {
403 Ok(Some(v)) => Some(v),
404 Ok(None) => None,
405 Err(_) => continue,
406 }
407 };
408
409 let Some(ev) = ev else {
410 if !conn.event_stream_live().await || !conn.client.is_connection_alive() {
411 if self.cfg.on_stream_close == TimeoutPolicy::SoftComplete
412 && !collected.trim().is_empty()
413 {
414 break self
415 .complete_turn(
416 session_id,
417 &loop_id,
418 &collected,
419 started_at,
420 "stream_closed",
421 )
422 .await;
423 }
424 let err = Error::msg("event stream closed");
425 self.fail_turn(session_id, &loop_id, &err).await;
426 break Err(err);
427 }
428 continue;
429 };
430 last_event = Instant::now();
431
432 if !armed {
433 if is_stale_turn_end_event(&ev) {
434 continue;
435 }
436 if is_status_running_event(&ev) {
437 let _ = feed_boundary(&mut boundary, &ev);
438 continue;
439 }
440 armed = true;
441 }
442
443 let ended = feed_boundary(&mut boundary, &ev);
444 if ended.is_some() && !armed {
445 boundary = TurnBoundary::default();
446 continue;
447 }
448
449 let event_result = self.classifier.classify(&ev, &collected);
450 if event_result.terminal == ChatEventTerminal::FailedComplete {
451 let err = Error::msg(
452 event_result
453 .error
454 .unwrap_or_else(|| "process event failed".into()),
455 );
456 self.fail_turn(session_id, &loop_id, &err).await;
457 break Err(err);
458 }
459
460 if !event_result.thinking_step.trim().is_empty() {
461 self.broadcast_thinking_step(session_id, &event_result.thinking_step);
462 }
463
464 if !event_result.content.is_empty() {
465 let delta = if event_result.content.starts_with(&collected) {
466 let d = event_result.content[collected.len()..].to_string();
467 collected = event_result.content.clone();
468 d
469 } else {
470 collected.push_str(&event_result.content);
471 event_result.content.clone()
472 };
473 if !delta.is_empty() {
474 self.broadcast_delta(session_id, &delta);
475 }
476 }
477
478 if let Some(final_text) = self
479 .classifier
480 .resolve_deliverable_final_content(&event_result, &collected)
481 {
482 if !is_daemon_turn_end_event(&event_result.completion_event) {
483 collected = final_text;
484 let completion = event_result.completion_event;
485 break self
486 .complete_turn(session_id, &loop_id, &collected, started_at, &completion)
487 .await;
488 }
489 }
490
491 if let Some(reason) = ended {
492 if collected.trim().is_empty() {
493 let err =
494 Error::msg(format!("turn ended ({reason}) with no assistant content"));
495 self.fail_turn(session_id, &loop_id, &err).await;
496 break Err(err);
497 }
498 break self
499 .complete_turn(session_id, &loop_id, &collected, started_at, reason)
500 .await;
501 }
502 };
503
504 {
506 let mut rx_guard = conn.event_rx.lock().await;
507 if let Some(rx) = rx_guard.as_mut() {
508 drain_event_ch(rx, Duration::ZERO).await;
509 }
510 }
511
512 let _ = conn;
514 result
515 }
516
517 async fn finish_timeout(
518 &self,
519 session_id: &str,
520 loop_id: &str,
521 content: &str,
522 started_at: Instant,
523 completion_event: &str,
524 policy: TimeoutPolicy,
525 ) -> Result<String> {
526 match policy {
527 TimeoutPolicy::SoftComplete if !content.trim().is_empty() => {
528 self.complete_turn(session_id, loop_id, content, started_at, completion_event)
529 .await
530 }
531 _ => {
532 let err = Error::msg(completion_event);
533 self.fail_turn(session_id, loop_id, &err).await;
534 Err(err)
535 }
536 }
537 }
538
539 async fn complete_turn(
540 &self,
541 session_id: &str,
542 loop_id: &str,
543 content: &str,
544 started_at: Instant,
545 completion_event: &str,
546 ) -> Result<String> {
547 let elapsed_ms = started_at.elapsed().as_millis() as i64;
548 self.store
549 .append_message(
550 session_id,
551 json!({
552 "role": "assistant",
553 "content": content,
554 "status": "completed",
555 "completion_event": completion_event,
556 "deliverable": true,
557 "duration_ms": elapsed_ms,
558 }),
559 )
560 .await;
561 self.broadcast_complete(session_id, content);
562 if let Some(hook) = &self.on_complete {
563 hook(session_id, loop_id, content, completion_event, elapsed_ms);
564 }
565 Ok(content.to_string())
566 }
567
568 async fn fail_turn(&self, session_id: &str, loop_id: &str, err: &Error) {
569 self.store
570 .append_message(
571 session_id,
572 json!({
573 "role": "error",
574 "status": "failed",
575 "error_message": err.to_string(),
576 }),
577 )
578 .await;
579 self.broadcast_error(session_id, err);
580 if let Some(hook) = &self.on_error {
581 hook(session_id, loop_id, err);
582 }
583 }
584
585 fn broadcast_delta(&self, app_key: &str, delta: &str) {
586 if let Some(b) = &self.broadcaster {
587 b.broadcast(
588 app_key,
589 SseEvent {
590 event_type: "delta".into(),
591 data: json!(delta),
592 },
593 );
594 }
595 }
596
597 fn broadcast_thinking_step(&self, app_key: &str, step: &str) {
598 if let Some(b) = &self.broadcaster {
599 b.broadcast(
600 app_key,
601 SseEvent {
602 event_type: "thinking_step".into(),
603 data: json!(format!("{step}\n")),
604 },
605 );
606 }
607 }
608
609 fn broadcast_complete(&self, app_key: &str, content: &str) {
610 if let Some(b) = &self.broadcaster {
611 b.broadcast(
612 app_key,
613 SseEvent {
614 event_type: "complete".into(),
615 data: json!(content),
616 },
617 );
618 }
619 }
620
621 fn broadcast_error(&self, app_key: &str, err: &Error) {
622 if let Some(b) = &self.broadcaster {
623 let data = if let Some(fmt) = &self.error_data {
624 fmt(err)
625 } else {
626 json!(err.to_string())
627 };
628 b.broadcast(
629 app_key,
630 SseEvent {
631 event_type: "query_error".into(),
632 data,
633 },
634 );
635 }
636 }
637}
638
639fn idle_timeout_for_turn(cfg: &TurnConfig, has_attachments: bool) -> Duration {
640 let idle = cfg.idle_timeout;
641 if idle.is_zero() {
642 return Duration::ZERO;
643 }
644 if has_attachments
645 && !cfg.min_idle_timeout_with_attachments.is_zero()
646 && idle < cfg.min_idle_timeout_with_attachments
647 {
648 return cfg.min_idle_timeout_with_attachments;
649 }
650 idle
651}
652
653async fn drain_event_ch(rx: &mut mpsc::Receiver<Value>, settle: Duration) {
655 if settle.is_zero() {
656 while rx.try_recv().is_ok() {}
657 return;
658 }
659 let mut deadline = Instant::now() + settle;
660 loop {
661 let remaining = deadline.saturating_duration_since(Instant::now());
662 if remaining.is_zero() {
663 break;
664 }
665 match tokio::time::timeout(remaining, rx.recv()).await {
666 Ok(Some(_)) => {
667 deadline = Instant::now() + settle;
668 }
669 Ok(None) | Err(_) => break,
670 }
671 }
672}
673
674fn feed_boundary(boundary: &mut TurnBoundary, msg: &Value) -> Option<&'static str> {
675 let frame = if msg.get("type").and_then(|v| v.as_str()) == Some("next") {
676 unwrap_next_frame(msg)
677 } else {
678 msg.clone()
679 };
680 let event_type = frame.get("type").and_then(|v| v.as_str()).unwrap_or("");
681 if event_type == "status" {
682 let state = frame.get("state").and_then(|v| v.as_str()).unwrap_or("");
683 return boundary.feed_status(state);
684 }
685 if event_type == "event" {
686 let mode = frame.get("mode").and_then(|v| v.as_str()).unwrap_or("");
687 let data = frame.get("data").cloned().unwrap_or(Value::Null);
688 return boundary.feed_event(mode, &data);
689 }
690 None
691}
692
693fn is_status_running_event(msg: &Value) -> bool {
694 let frame = if msg.get("type").and_then(|v| v.as_str()) == Some("next") {
695 unwrap_next_frame(msg)
696 } else {
697 msg.clone()
698 };
699 frame.get("type").and_then(|v| v.as_str()) == Some("status")
700 && frame
701 .get("state")
702 .and_then(|v| v.as_str())
703 .unwrap_or("")
704 .eq_ignore_ascii_case("running")
705}
706
707fn is_stale_turn_end_event(msg: &Value) -> bool {
708 let frame = if msg.get("type").and_then(|v| v.as_str()) == Some("next") {
709 unwrap_next_frame(msg)
710 } else {
711 msg.clone()
712 };
713 let event_type = frame.get("type").and_then(|v| v.as_str()).unwrap_or("");
714 if event_type == "status" {
715 let state = frame
716 .get("state")
717 .and_then(|v| v.as_str())
718 .unwrap_or("")
719 .to_ascii_lowercase();
720 return matches!(state.as_str(), "idle" | "stopped");
721 }
722 if event_type == "event" {
723 let mode = frame.get("mode").and_then(|v| v.as_str()).unwrap_or("");
724 let data = frame.get("data").unwrap_or(&Value::Null);
725 return mode == "custom" && is_turn_end_custom_data(data);
726 }
727 false
728}