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