Skip to main content

soothe_client/appkit/
turn_runner.rs

1//! TurnRunner: single-flight execute over ConnectionPool (Go appkit parity).
2
3use 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/// Timeout policy for idle / query / stream-close.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub enum TimeoutPolicy {
26    /// Fail the turn.
27    #[default]
28    Fail,
29    /// Soft-complete with whatever was collected.
30    SoftComplete,
31}
32
33/// Turn runner configuration.
34#[derive(Debug, Clone)]
35pub struct TurnConfig {
36    /// Absolute query timeout.
37    pub query_timeout: Duration,
38    /// Idle silence timeout (0 = off).
39    pub idle_timeout: Duration,
40    /// Floor idle timeout when attachments are present (0 = no floor).
41    pub min_idle_timeout_with_attachments: Duration,
42    /// Idle timeout policy.
43    pub on_idle_timeout: TimeoutPolicy,
44    /// Query timeout policy.
45    pub on_query_timeout: TimeoutPolicy,
46    /// Stream close policy.
47    pub on_stream_close: TimeoutPolicy,
48    /// Compact image attachments before send.
49    pub compact_attachments_before_send: bool,
50    /// Options for attachment compaction.
51    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/// Optional input knobs for TurnRunner.
70#[derive(Debug, Clone, Default)]
71pub struct InputOpts {
72    /// Intent hint.
73    pub intent_hint: Option<String>,
74    /// Preferred subagent.
75    pub preferred_subagent: Option<String>,
76    /// Response schema.
77    pub response_schema: Option<Value>,
78    /// Schema name.
79    pub response_schema_name: Option<String>,
80    /// Strict schema.
81    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
90/// Executes a turn against a pooled connection.
91pub 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    /// Create a runner. `cfg` defaults when `None`.
106    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    /// Attach an SSE broadcaster for delta / thinking / complete / error fan-out.
128    pub fn with_broadcaster(mut self, b: Arc<SseBroadcaster>) -> Self {
129        self.broadcaster = Some(b);
130        self
131    }
132
133    /// Override the loop_input payload builder.
134    pub fn with_input_builder(mut self, f: InputBuilderFn) -> Self {
135        self.input_builder = Some(f);
136        self
137    }
138
139    /// Completion hook (`app_key`, `loop_id`, `content`, `completion_event`, `elapsed_ms`).
140    pub fn with_on_complete(mut self, f: OnCompleteFn) -> Self {
141        self.on_complete = Some(f);
142        self
143    }
144
145    /// Error hook.
146    pub fn with_on_error(mut self, f: OnErrorFn) -> Self {
147        self.on_error = Some(f);
148        self
149    }
150
151    /// Formatter for SSE `query_error` payloads.
152    pub fn with_error_data(mut self, f: ErrorDataFn) -> Self {
153        self.error_data = Some(f);
154        self
155    }
156
157    /// Execute one turn; returns concatenated assistant text on success.
158    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    /// Run a turn when the caller already reserved the gate via [`QueryGate::acquire`].
192    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        // Pre-send settle-drain of pooled leftovers (Go 0.4.8).
277        {
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                    // Custom builders may return either flat params or a full envelope.
307                    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        // Post-turn non-blocking drain.
488        {
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        // Keep pooled connection for session reuse (Go Release is explicit).
496        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
636/// Drain pooled event channel. When `settle > 0`, keep reading until quiet for `settle`.
637async 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}