1use crate::{RuntimeHostAdapter, RuntimeSessionLifecycle};
5use chrono::{DateTime, Utc};
6use everruns_core::atoms::{ActInput, AtomContext};
7use everruns_core::error::{AgentLoopError, Result};
8use everruns_core::events::TokenUsage;
9use everruns_core::turn::TurnStopReason;
10use everruns_core::typed_id::{AgentId, ExecId, HarnessId, MessageId, SessionId, TurnId};
11use everruns_core::{
12 Controls, ReasonResult, UserFacingError, UserFacingErrorContext,
13 classify_runtime_error_message, user_facing_error_codes,
14};
15use serde::{Deserialize, Serialize};
16use tracing::{debug, info};
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct RuntimeTurnState {
28 pub org_id: i64,
29 pub session_id: SessionId,
30 pub harness_id: HarnessId,
31 pub agent_id: Option<AgentId>,
32 pub input_message_id: MessageId,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 pub turn_id: Option<TurnId>,
35 #[serde(skip_serializing_if = "Option::is_none", default)]
36 pub previous_response_id: Option<String>,
37 #[serde(default = "default_iteration")]
38 pub iteration: u32,
39 #[serde(skip_serializing_if = "Option::is_none", default)]
40 pub request_id: Option<String>,
41 #[serde(skip_serializing_if = "Option::is_none", default)]
42 pub started_at: Option<DateTime<Utc>>,
43 #[serde(skip_serializing_if = "Option::is_none", default)]
44 pub cumulative_usage: Option<TokenUsage>,
45 #[serde(default)]
46 pub tool_call_count: u32,
47 #[serde(default)]
48 pub llm_call_count: u32,
49 #[serde(skip_serializing_if = "Option::is_none", default)]
50 pub time_to_first_token_ms: Option<u64>,
51 #[serde(skip_serializing_if = "Option::is_none", default)]
52 pub final_message_id: Option<MessageId>,
53 #[serde(skip_serializing_if = "Option::is_none", default)]
54 pub final_answer_preview: Option<String>,
55}
56
57fn default_iteration() -> u32 {
58 1
59}
60
61#[derive(Debug, Clone)]
65pub struct RuntimeActPlan {
66 pub input: ActInput,
67 pub previous_response_id: Option<String>,
68 pub iteration: u32,
69 pub request_id: Option<String>,
70 pub resume_state: Box<RuntimeTurnState>,
71}
72
73#[derive(Debug, Clone)]
79pub enum RuntimeTurnPlan {
80 ScheduleReason(RuntimeTurnState),
81 ScheduleAct(RuntimeActPlan),
82 Complete {
83 stop_reason: TurnStopReason,
84 error: Option<String>,
85 },
86 WaitForToolResults {
87 resume: RuntimeTurnState,
88 },
89}
90
91fn preview_final_answer(text: &str) -> Option<String> {
92 if text.is_empty() {
93 return None;
94 }
95
96 Some(text.chars().take(2000).collect())
97}
98
99fn add_usage(current: &mut Option<TokenUsage>, next: &TokenUsage) {
100 match current {
101 Some(current) => current.add(next),
102 None => *current = Some(next.clone()),
103 }
104}
105
106impl RuntimeTurnState {
107 fn with_reason_summary(&self, reason_result: &ReasonResult) -> Self {
108 let mut next = self.clone();
109 next.llm_call_count = next.llm_call_count.saturating_add(1);
110 next.tool_call_count = next
111 .tool_call_count
112 .saturating_add(reason_result.tool_calls.len() as u32);
113 if let Some(usage) = &reason_result.usage {
114 add_usage(&mut next.cumulative_usage, usage);
115 }
116 if next.time_to_first_token_ms.is_none() {
117 next.time_to_first_token_ms = reason_result.time_to_first_token_ms;
118 }
119 next.final_message_id = reason_result.output_message_id;
120 next.final_answer_preview = preview_final_answer(&reason_result.text);
121 next
122 }
123
124 fn duration_ms(&self) -> Option<u64> {
125 self.started_at
126 .map(|started_at| Utc::now().signed_duration_since(started_at))
127 .and_then(|duration| u64::try_from(duration.num_milliseconds()).ok())
128 }
129}
130
131fn classify_reason_failure(reason_result: &ReasonResult) -> UserFacingError {
132 if let Some(user_error) = &reason_result.user_facing_error {
136 return user_error.clone();
137 }
138
139 let from_text =
140 classify_runtime_error_message(&reason_result.text, &UserFacingErrorContext::default());
141
142 let Some(error) = reason_result.error.as_deref() else {
143 return from_text;
144 };
145
146 let from_error = classify_runtime_error_message(error, &UserFacingErrorContext::default());
147
148 if from_error.code == user_facing_error_codes::PROCESSING_ERROR {
149 return from_text;
150 }
151
152 if from_error.code == from_text.code
153 && from_error.fields.is_empty()
154 && !from_text.fields.is_empty()
155 {
156 return from_text;
157 }
158
159 from_error
160}
161
162pub async fn plan_next_host_turn<A: RuntimeHostAdapter>(
179 adapter: &A,
180 completed_activity: &str,
181 state: &RuntimeTurnState,
182 output: &serde_json::Value,
183 pending_user_message_count: usize,
184) -> Result<RuntimeTurnPlan> {
185 match completed_activity {
186 "process_input" => {
187 let turn_id: Option<TurnId> = output
188 .get("turn_id")
189 .and_then(|value| value.as_str())
190 .and_then(|value| value.parse().ok());
191 let next = RuntimeTurnState {
192 turn_id,
193 previous_response_id: None,
194 iteration: 1,
195 started_at: state.started_at.or_else(|| Some(Utc::now())),
196 ..state.clone()
197 };
198 debug!(session_id = %state.session_id, turn_id = ?turn_id, "planned reason step");
199 Ok(RuntimeTurnPlan::ScheduleReason(next))
200 }
201 "reason" => {
202 let reason_result: ReasonResult = serde_json::from_value(output.clone())
203 .map_err(|error| AgentLoopError::Internal(error.into()))?;
204 let response_id = reason_result.response_id.clone();
205 let summarized_state = state.with_reason_summary(&reason_result);
206 let max_turn_requests_reached = state.iteration >= reason_result.max_iterations as u32;
207
208 if reason_result.has_tool_calls && reason_result.success && !max_turn_requests_reached {
209 let session = adapter
210 .session_store(state.org_id)
211 .get_session(state.session_id)
212 .await?;
213 let session_blueprint_id = session.as_ref().and_then(|s| s.blueprint_id.clone());
214 let workspace_id = session.as_ref().map(|s| s.workspace_id);
217 let plan = RuntimeActPlan {
218 input: ActInput {
219 org_id: Some(state.org_id),
220 context: AtomContext {
221 session_id: state.session_id,
222 turn_id: state.turn_id.unwrap_or_default(),
223 input_message_id: state.input_message_id,
224 exec_id: ExecId::new(),
225 workspace_id,
226 },
227 harness_id: state.harness_id,
228 agent_id: state.agent_id,
229 tool_calls: reason_result.tool_calls,
230 tool_definitions: reason_result.tool_definitions,
231 locale: reason_result.locale,
232 blueprint_id: session_blueprint_id,
233 network_access: reason_result.network_access,
234 parallel_tool_calls: reason_result.parallel_tool_calls,
237 },
238 previous_response_id: response_id,
239 iteration: state.iteration,
240 request_id: state.request_id.clone(),
241 resume_state: Box::new(summarized_state),
242 };
243 return Ok(RuntimeTurnPlan::ScheduleAct(plan));
244 }
245
246 if reason_result.success && pending_user_message_count > 0 && !max_turn_requests_reached
247 {
248 if pending_user_message_count > 1 {
249 info!(
250 session_id = %state.session_id,
251 pending_user_message_count,
252 "multiple steering messages arrived during turn"
253 );
254 }
255
256 let next = RuntimeTurnState {
257 previous_response_id: response_id,
258 iteration: state.iteration.saturating_add(1),
259 ..summarized_state
260 };
261 return Ok(RuntimeTurnPlan::ScheduleReason(next));
262 }
263
264 let lifecycle =
265 RuntimeSessionLifecycle::new(adapter.clone(), state.org_id, state.session_id);
266 let turn_id = state.turn_id.unwrap_or_default();
267
268 if reason_result.success {
269 lifecycle
270 .emit_turn_completed(
271 state.input_message_id,
272 everruns_core::events::TurnCompletedData {
273 turn_id,
274 iterations: state.iteration,
275 duration_ms: summarized_state.duration_ms(),
276 usage: summarized_state.cumulative_usage.clone(),
277 input_content: None,
278 final_message_id: summarized_state.final_message_id,
279 final_answer_preview: summarized_state.final_answer_preview.clone(),
280 time_to_first_token_ms: summarized_state.time_to_first_token_ms,
281 tool_call_count: Some(summarized_state.tool_call_count),
282 llm_call_count: Some(summarized_state.llm_call_count),
283 status: Some("completed".to_string()),
284 },
285 )
286 .await;
287 lifecycle
288 .emit_session_idled(
289 turn_id,
290 state.input_message_id,
291 Some(state.iteration),
292 summarized_state.cumulative_usage.clone(),
293 )
294 .await;
295 } else {
296 let user_error = classify_reason_failure(&reason_result);
297 lifecycle
298 .turn_failed_with_disclosure(
299 turn_id,
300 state.input_message_id,
301 &reason_result.text,
302 Some(&user_error),
303 reason_result.error_disclosure,
304 )
305 .await;
306 }
307
308 lifecycle
311 .fire_turn_end_hooks(
312 state.harness_id,
313 state.agent_id,
314 turn_id,
315 reason_result.success,
316 )
317 .await;
318
319 let stop_reason = if !reason_result.success {
320 match TurnStopReason::from_provider_finish_reason(
321 reason_result.finish_reason.as_deref(),
322 ) {
323 TurnStopReason::Refusal => TurnStopReason::Refusal,
324 _ => TurnStopReason::Error,
325 }
326 } else if max_turn_requests_reached
327 && (reason_result.has_tool_calls || pending_user_message_count > 0)
328 {
329 TurnStopReason::MaxTurnRequests
330 } else {
331 TurnStopReason::from_provider_finish_reason(reason_result.finish_reason.as_deref())
332 };
333
334 Ok(RuntimeTurnPlan::Complete {
335 stop_reason,
336 error: reason_result.error,
337 })
338 }
339 "act" => {
340 if output
341 .get("blocked")
342 .and_then(|value| value.as_bool())
343 .unwrap_or(false)
344 {
345 return Ok(RuntimeTurnPlan::Complete {
346 stop_reason: TurnStopReason::EndTurn,
347 error: None,
348 });
349 }
350
351 let waiting_for_tool_results = output
352 .get("waiting_for_tool_results")
353 .and_then(|value| value.as_bool())
354 .unwrap_or(false);
355 let should_pause_for_tool_results = waiting_for_tool_results
356 && setup_connection_hint_enabled(adapter, state.org_id, state.session_id).await;
357
358 let next = RuntimeTurnState {
359 iteration: state.iteration.saturating_add(1),
360 ..state.clone()
361 };
362
363 if should_pause_for_tool_results {
364 let lifecycle =
365 RuntimeSessionLifecycle::new(adapter.clone(), state.org_id, state.session_id);
366 lifecycle.waiting_for_tool_results().await;
367 return Ok(RuntimeTurnPlan::WaitForToolResults { resume: next });
368 }
369
370 if waiting_for_tool_results {
371 info!(
372 session_id = %state.session_id,
373 "setup_connection hint absent, continuing turn instead of pausing"
374 );
375 }
376
377 Ok(RuntimeTurnPlan::ScheduleReason(next))
378 }
379 other => Err(AgentLoopError::config(format!(
380 "Unknown activity type completed: {other}"
381 ))),
382 }
383}
384
385async fn setup_connection_hint_enabled<A: RuntimeHostAdapter>(
386 adapter: &A,
387 org_id: i64,
388 session_id: SessionId,
389) -> bool {
390 match adapter.session_store(org_id).get_session(session_id).await {
391 Ok(Some(session)) => {
392 let hints = Controls::resolve_hints(session.hints.as_ref(), None);
393 hints
394 .get("setup_connection")
395 .and_then(|value| value.as_bool())
396 .unwrap_or(false)
397 }
398 _ => false,
399 }
400}