1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
19pub enum TimeoutPolicy {
20 #[default]
22 Fail,
23 SoftComplete,
25}
26
27#[derive(Debug, Clone)]
29pub struct TurnConfig {
30 pub query_timeout: Duration,
32 pub idle_timeout: Duration,
34 pub on_idle_timeout: TimeoutPolicy,
36 pub on_query_timeout: TimeoutPolicy,
38 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#[derive(Debug, Clone, Default)]
56pub struct InputOpts {
57 pub intent_hint: Option<String>,
59 pub preferred_subagent: Option<String>,
61 pub response_schema: Option<Value>,
63 pub response_schema_name: Option<String>,
65 pub response_schema_strict: Option<bool>,
67}
68
69pub 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 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 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 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 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}