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