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