Skip to main content

soothe_client/appkit/
turn_runner.rs

1//! TurnRunner: single-flight execute over ConnectionPool.
2
3use std::sync::Arc;
4use std::time::Duration;
5
6use serde_json::{json, Map, Value};
7
8use crate::client::SendInputOptions;
9use crate::errors::{Error, Result};
10use crate::stream_terminal::is_turn_end_custom_data;
11
12use super::classifier::EventClassifier;
13use super::pool::{input_message_for_loop, ConnectionPool};
14use super::query_gate::QueryGate;
15use super::session_store::SessionStore;
16
17/// Timeout policy for idle / query / stream-close.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
19pub enum TimeoutPolicy {
20    /// Fail the turn.
21    #[default]
22    Fail,
23    /// Soft-complete with whatever was collected.
24    SoftComplete,
25}
26
27/// Turn runner configuration.
28#[derive(Debug, Clone)]
29pub struct TurnConfig {
30    /// Absolute query timeout.
31    pub query_timeout: Duration,
32    /// Idle silence timeout (0 = off).
33    pub idle_timeout: Duration,
34    /// Idle/query/stream policies.
35    pub on_idle_timeout: TimeoutPolicy,
36    /// Query timeout policy.
37    pub on_query_timeout: TimeoutPolicy,
38    /// Stream close policy.
39    pub on_stream_close: TimeoutPolicy,
40}
41
42impl Default for TurnConfig {
43    fn default() -> Self {
44        Self {
45            query_timeout: Duration::from_secs(30 * 60),
46            idle_timeout: Duration::ZERO,
47            on_idle_timeout: TimeoutPolicy::Fail,
48            on_query_timeout: TimeoutPolicy::Fail,
49            on_stream_close: TimeoutPolicy::Fail,
50        }
51    }
52}
53
54/// Optional input knobs for TurnRunner.
55#[derive(Debug, Clone, Default)]
56pub struct InputOpts {
57    /// Intent hint.
58    pub intent_hint: Option<String>,
59    /// Preferred subagent.
60    pub preferred_subagent: Option<String>,
61    /// Response schema.
62    pub response_schema: Option<Value>,
63    /// Schema name.
64    pub response_schema_name: Option<String>,
65    /// Strict schema.
66    pub response_schema_strict: Option<bool>,
67}
68
69/// Executes a turn against a pooled connection.
70pub struct TurnRunner<S: SessionStore> {
71    pool: Arc<ConnectionPool<S>>,
72    gate: QueryGate,
73    classifier: EventClassifier,
74    store: Arc<S>,
75    cfg: TurnConfig,
76}
77
78impl<S: SessionStore + 'static> TurnRunner<S> {
79    /// Create a runner.
80    pub fn new(
81        pool: Arc<ConnectionPool<S>>,
82        gate: QueryGate,
83        classifier: EventClassifier,
84        store: Arc<S>,
85        cfg: Option<TurnConfig>,
86    ) -> Self {
87        Self {
88            pool,
89            gate,
90            classifier,
91            store,
92            cfg: cfg.unwrap_or_default(),
93        }
94    }
95
96    /// Execute one turn for a session; returns concatenated assistant text when found.
97    pub async fn execute(
98        &self,
99        session_id: &str,
100        message: &str,
101        user_id: &str,
102        workspace_id: &str,
103        attachments: Option<Value>,
104        opts: Option<InputOpts>,
105    ) -> Result<String> {
106        self.gate
107            .acquire(session_id)
108            .await
109            .map_err(|e| Error::msg(e.to_string()))?;
110        let result = self
111            .execute_inner(
112                session_id,
113                message,
114                user_id,
115                workspace_id,
116                attachments,
117                opts,
118            )
119            .await;
120        self.gate.release(session_id).await;
121        result
122    }
123
124    async fn execute_inner(
125        &self,
126        session_id: &str,
127        message: &str,
128        user_id: &str,
129        workspace_id: &str,
130        attachments: Option<Value>,
131        opts: Option<InputOpts>,
132    ) -> Result<String> {
133        let conn = self.pool.acquire(session_id, workspace_id, user_id).await?;
134        let loop_id = conn.loop_id.clone();
135        let flat = input_message_for_loop(message, &loop_id, attachments.clone(), opts.as_ref());
136        // Coerce flat → notification params.
137        let mut params = Map::new();
138        for (k, v) in flat {
139            if k == "type" {
140                continue;
141            }
142            params.insert(k, v);
143        }
144        let input_opts = SendInputOptions {
145            loop_id: Some(loop_id.clone()),
146            intent_hint: opts.as_ref().and_then(|o| o.intent_hint.clone()),
147            preferred_subagent: opts.as_ref().and_then(|o| o.preferred_subagent.clone()),
148            response_schema: opts.as_ref().and_then(|o| o.response_schema.clone()),
149            response_schema_name: opts.as_ref().and_then(|o| o.response_schema_name.clone()),
150            response_schema_strict: opts.as_ref().and_then(|o| o.response_schema_strict),
151            attachments,
152            ..Default::default()
153        };
154        // Prefer typed send_input.
155        let _ = params;
156        conn.client.send_input(message, input_opts).await?;
157
158        self.store
159            .append_message(session_id, json!({"role":"user","content": message}))
160            .await;
161
162        let deadline = tokio::time::Instant::now() + self.cfg.query_timeout;
163        let mut collected = String::new();
164        let mut last_event = tokio::time::Instant::now();
165        let mut query_started = false;
166        let mut payload_seen = false;
167
168        loop {
169            if tokio::time::Instant::now() > deadline {
170                self.pool.release(session_id).await;
171                return match self.cfg.on_query_timeout {
172                    TimeoutPolicy::SoftComplete => Ok(collected),
173                    TimeoutPolicy::Fail => Err(Error::msg("query timeout")),
174                };
175            }
176            if !self.cfg.idle_timeout.is_zero() && last_event.elapsed() > self.cfg.idle_timeout {
177                self.pool.release(session_id).await;
178                return match self.cfg.on_idle_timeout {
179                    TimeoutPolicy::SoftComplete => Ok(collected),
180                    TimeoutPolicy::Fail => Err(Error::msg("idle timeout")),
181                };
182            }
183
184            let wait = if self.cfg.idle_timeout.is_zero() {
185                Duration::from_millis(500)
186            } else {
187                self.cfg.idle_timeout.min(Duration::from_millis(500))
188            };
189            let ev = conn.client.read_event_with_timeout(wait).await?;
190            let Some(ev) = ev else {
191                if !conn.client.is_connection_alive() {
192                    self.pool.release(session_id).await;
193                    return match self.cfg.on_stream_close {
194                        TimeoutPolicy::SoftComplete => Ok(collected),
195                        TimeoutPolicy::Fail => Err(Error::msg("stream closed")),
196                    };
197                }
198                continue;
199            };
200            last_event = tokio::time::Instant::now();
201
202            let frame = if ev.get("type").and_then(|v| v.as_str()) == Some("next") {
203                crate::client::unwrap_next_frame(&ev)
204            } else {
205                ev
206            };
207            let event_type = frame.get("type").and_then(|v| v.as_str()).unwrap_or("");
208            if event_type == "status" {
209                let state = frame.get("state").and_then(|v| v.as_str()).unwrap_or("");
210                match state {
211                    "running" => query_started = true,
212                    "stopped" if query_started => break,
213                    "idle" if query_started && (payload_seen || !collected.is_empty()) => break,
214                    "idle" if query_started && self.classifier.treat_status_idle_as_complete => {
215                        break;
216                    }
217                    _ => {}
218                }
219                continue;
220            }
221            if event_type != "event" {
222                continue;
223            }
224            let mode = frame.get("mode").and_then(|v| v.as_str()).unwrap_or("");
225            let data = frame.get("data").cloned().unwrap_or(Value::Null);
226            payload_seen = true;
227            if let Some(text) = self.classifier.extract_text(mode, &data) {
228                collected.push_str(&text);
229            }
230            if mode == "custom" && is_turn_end_custom_data(&data) {
231                break;
232            }
233        }
234
235        if !collected.is_empty() {
236            self.store
237                .append_message(session_id, json!({"role":"assistant","content": collected}))
238                .await;
239        }
240        self.pool.release(session_id).await;
241        Ok(collected)
242    }
243}