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: 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    /// Create a runner. `cfg` defaults when `None`.
106    ///
107    /// `gate` is shared (`Arc`) so callers can [`QueryGate::acquire`] before
108    /// spawning work and then call [`Self::execute_reserved`] (Go appkit parity).
109    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    /// Shared query gate (same instance used by [`Self::execute`] / [`Self::execute_reserved`]).
131    pub fn gate(&self) -> &Arc<QueryGate> {
132        &self.gate
133    }
134
135    /// Attach an SSE broadcaster for delta / thinking / complete / error fan-out.
136    pub fn with_broadcaster(mut self, b: Arc<SseBroadcaster>) -> Self {
137        self.broadcaster = Some(b);
138        self
139    }
140
141    /// Override the loop_input payload builder.
142    pub fn with_input_builder(mut self, f: InputBuilderFn) -> Self {
143        self.input_builder = Some(f);
144        self
145    }
146
147    /// Completion hook (`app_key`, `loop_id`, `content`, `completion_event`, `elapsed_ms`).
148    pub fn with_on_complete(mut self, f: OnCompleteFn) -> Self {
149        self.on_complete = Some(f);
150        self
151    }
152
153    /// Error hook.
154    pub fn with_on_error(mut self, f: OnErrorFn) -> Self {
155        self.on_error = Some(f);
156        self
157    }
158
159    /// Formatter for SSE `query_error` payloads.
160    pub fn with_error_data(mut self, f: ErrorDataFn) -> Self {
161        self.error_data = Some(f);
162        self
163    }
164
165    /// Execute one turn; returns concatenated assistant text on success.
166    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    /// Run a turn when the caller already reserved the gate via [`QueryGate::acquire`].
200    ///
201    /// Releases the gate on all exit paths (including `validate_opts` failure), matching
202    /// Go `ExecuteReserved`.
203    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        // Pre-send settle-drain of pooled leftovers (Go 0.4.8).
294        {
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                    // Custom builders may return either flat params or a full envelope.
324                    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        // Post-turn non-blocking drain.
505        {
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        // Keep pooled connection for session reuse (Go Release is explicit).
513        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
653/// Drain pooled event channel. When `settle > 0`, keep reading until quiet for `settle`.
654async 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}