1use chrono::{DateTime, Utc};
11use everruns_core::atoms::{ActInput, AtomContext, ReasonResult};
12use everruns_core::events::{TokenUsage, TurnCompletedData};
13use everruns_core::turn::TurnStopReason;
14use everruns_core::typed_id::{
15 AgentId, ExecId, HarnessId, MessageId, SessionId, TurnId, WorkspaceId,
16};
17use everruns_core::{
18 ErrorDisclosure, UserFacingError, UserFacingErrorContext, classify_runtime_error_message,
19 user_facing_error_codes,
20};
21use serde::{Deserialize, Serialize};
22use tracing::{debug, info};
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct TurnState {
34 pub org_id: i64,
35 pub session_id: SessionId,
36 pub harness_id: HarnessId,
37 pub agent_id: Option<AgentId>,
38 pub input_message_id: MessageId,
39 #[serde(skip_serializing_if = "Option::is_none")]
40 pub turn_id: Option<TurnId>,
41 #[serde(skip_serializing_if = "Option::is_none", default)]
42 pub previous_response_id: Option<String>,
43 #[serde(default = "default_iteration")]
44 pub iteration: u32,
45 #[serde(skip_serializing_if = "Option::is_none", default)]
46 pub request_id: Option<String>,
47 #[serde(skip_serializing_if = "Option::is_none", default)]
48 pub started_at: Option<DateTime<Utc>>,
49 #[serde(skip_serializing_if = "Option::is_none", default)]
50 pub cumulative_usage: Option<TokenUsage>,
51 #[serde(default)]
52 pub tool_call_count: u32,
53 #[serde(default)]
54 pub llm_call_count: u32,
55 #[serde(skip_serializing_if = "Option::is_none", default)]
56 pub time_to_first_token_ms: Option<u64>,
57 #[serde(skip_serializing_if = "Option::is_none", default)]
58 pub final_message_id: Option<MessageId>,
59 #[serde(skip_serializing_if = "Option::is_none", default)]
60 pub final_answer_preview: Option<String>,
61}
62
63fn default_iteration() -> u32 {
64 1
65}
66
67#[derive(Debug, Clone)]
71pub struct ActPlan {
72 pub input: ActInput,
73 pub previous_response_id: Option<String>,
74 pub iteration: u32,
75 pub request_id: Option<String>,
76 pub resume_state: Box<TurnState>,
77}
78
79#[derive(Debug, Clone)]
85pub enum TurnPlan {
86 ScheduleReason(TurnState),
87 ScheduleAct(ActPlan),
88 Complete {
89 stop_reason: TurnStopReason,
90 error: Option<String>,
91 },
92 WaitForToolResults {
93 resume: TurnState,
94 },
95}
96
97#[derive(Debug, Clone)]
105pub enum TurnLifecycleEffect {
106 TurnCompleted {
108 input_message_id: MessageId,
109 data: TurnCompletedData,
110 },
111 SessionIdled {
113 turn_id: TurnId,
114 input_message_id: MessageId,
115 iterations: Option<u32>,
116 usage: Option<TokenUsage>,
117 },
118 TurnFailedWithDisclosure {
121 turn_id: TurnId,
122 input_message_id: MessageId,
123 text: String,
124 user_error: Option<UserFacingError>,
125 disclosure: Option<ErrorDisclosure>,
126 },
127 FireTurnEndHooks {
129 harness_id: HarnessId,
130 agent_id: Option<AgentId>,
131 turn_id: TurnId,
132 success: bool,
133 },
134 WaitingForToolResults,
136}
137
138#[derive(Debug, Clone, Copy, Default)]
140pub struct ActOutcome {
141 pub blocked: bool,
142 pub waiting_for_tool_results: bool,
143}
144
145#[derive(Debug, Clone, Default)]
153pub struct ActSchedulingFacts {
154 pub blueprint_id: Option<String>,
155 pub workspace_id: Option<WorkspaceId>,
156}
157
158pub enum ActivityOutcome {
164 ProcessInput { turn_id: Option<TurnId> },
165 Reason(Box<ReasonResult>),
167 Act(ActOutcome),
168}
169
170#[derive(Debug, Clone, Default)]
177pub struct HostFacts {
178 pub act_scheduling: Option<ActSchedulingFacts>,
179 pub setup_connection_hint_enabled: bool,
180}
181
182fn preview_final_answer(text: &str) -> Option<String> {
183 if text.is_empty() {
184 return None;
185 }
186
187 Some(text.chars().take(2000).collect())
188}
189
190fn add_usage(current: &mut Option<TokenUsage>, next: &TokenUsage) {
191 match current {
192 Some(current) => current.add(next),
193 None => *current = Some(next.clone()),
194 }
195}
196
197impl TurnState {
198 fn with_reason_summary(&self, reason_result: &ReasonResult) -> Self {
199 let mut next = self.clone();
200 next.llm_call_count = next.llm_call_count.saturating_add(1);
201 next.tool_call_count = next
202 .tool_call_count
203 .saturating_add(reason_result.tool_calls.len() as u32);
204 if let Some(usage) = &reason_result.usage {
205 add_usage(&mut next.cumulative_usage, usage);
206 }
207 if next.time_to_first_token_ms.is_none() {
208 next.time_to_first_token_ms = reason_result.time_to_first_token_ms;
209 }
210 next.final_message_id = reason_result.output_message_id;
211 next.final_answer_preview = preview_final_answer(&reason_result.text);
212 next
213 }
214
215 fn duration_ms(&self, now: DateTime<Utc>) -> Option<u64> {
218 self.started_at
219 .map(|started_at| now.signed_duration_since(started_at))
220 .and_then(|duration| u64::try_from(duration.num_milliseconds()).ok())
221 }
222}
223
224fn classify_reason_failure(reason_result: &ReasonResult) -> UserFacingError {
225 if let Some(user_error) = &reason_result.user_facing_error {
229 return user_error.clone();
230 }
231
232 let from_text =
233 classify_runtime_error_message(&reason_result.text, &UserFacingErrorContext::default());
234
235 let Some(error) = reason_result.error.as_deref() else {
236 return from_text;
237 };
238
239 let from_error = classify_runtime_error_message(error, &UserFacingErrorContext::default());
240
241 if from_error.code == user_facing_error_codes::PROCESSING_ERROR {
242 return from_text;
243 }
244
245 if from_error.code == from_text.code
246 && from_error.fields.is_empty()
247 && !from_text.fields.is_empty()
248 {
249 return from_text;
250 }
251
252 from_error
253}
254
255pub fn reason_schedules_act(state: &TurnState, reason_result: &ReasonResult) -> bool {
263 let max_turn_requests_reached = state.iteration >= reason_result.max_iterations as u32;
264 reason_result.has_tool_calls && reason_result.success && !max_turn_requests_reached
265}
266
267pub fn plan_next_turn(
275 state: &TurnState,
276 outcome: ActivityOutcome,
277 pending_user_message_count: usize,
278 now: DateTime<Utc>,
279 facts: HostFacts,
280) -> (TurnPlan, Vec<TurnLifecycleEffect>) {
281 match outcome {
282 ActivityOutcome::ProcessInput { turn_id } => {
283 (plan_after_process_input(state, turn_id, now), Vec::new())
284 }
285 ActivityOutcome::Reason(reason_result) => plan_after_reason(
286 state,
287 *reason_result,
288 pending_user_message_count,
289 now,
290 facts.act_scheduling,
291 ),
292 ActivityOutcome::Act(outcome) => {
293 plan_after_act(state, outcome, facts.setup_connection_hint_enabled)
294 }
295 }
296}
297
298pub fn plan_after_process_input(
300 state: &TurnState,
301 turn_id: Option<TurnId>,
302 now: DateTime<Utc>,
303) -> TurnPlan {
304 let next = TurnState {
305 turn_id,
306 previous_response_id: None,
307 iteration: 1,
308 started_at: state.started_at.or(Some(now)),
309 ..state.clone()
310 };
311 debug!(session_id = %state.session_id, turn_id = ?turn_id, "planned reason step");
312 TurnPlan::ScheduleReason(next)
313}
314
315pub fn plan_after_reason(
322 state: &TurnState,
323 reason_result: ReasonResult,
324 pending_user_message_count: usize,
325 now: DateTime<Utc>,
326 act_scheduling: Option<ActSchedulingFacts>,
327) -> (TurnPlan, Vec<TurnLifecycleEffect>) {
328 let response_id = reason_result.response_id.clone();
329 let summarized_state = state.with_reason_summary(&reason_result);
330 let max_turn_requests_reached = state.iteration >= reason_result.max_iterations as u32;
331
332 if reason_schedules_act(state, &reason_result) {
333 let facts = act_scheduling.unwrap_or_default();
334 let plan = ActPlan {
335 input: ActInput {
336 org_id: Some(state.org_id),
337 context: AtomContext {
338 session_id: state.session_id,
339 turn_id: state.turn_id.unwrap_or_default(),
340 input_message_id: state.input_message_id,
341 exec_id: ExecId::new(),
342 workspace_id: facts.workspace_id,
343 },
344 harness_id: state.harness_id,
345 agent_id: state.agent_id,
346 tool_calls: reason_result.tool_calls,
347 tool_definitions: reason_result.tool_definitions,
348 locale: reason_result.locale,
349 blueprint_id: facts.blueprint_id,
350 network_access: reason_result.network_access,
351 parallel_tool_calls: reason_result.parallel_tool_calls,
354 },
355 previous_response_id: response_id,
356 iteration: state.iteration,
357 request_id: state.request_id.clone(),
358 resume_state: Box::new(summarized_state),
359 };
360 return (TurnPlan::ScheduleAct(plan), Vec::new());
361 }
362
363 if reason_result.success && pending_user_message_count > 0 && !max_turn_requests_reached {
364 if pending_user_message_count > 1 {
365 info!(
366 session_id = %state.session_id,
367 pending_user_message_count,
368 "multiple steering messages arrived during turn"
369 );
370 }
371
372 let next = TurnState {
373 previous_response_id: response_id,
374 iteration: state.iteration.saturating_add(1),
375 ..summarized_state
376 };
377 return (TurnPlan::ScheduleReason(next), Vec::new());
378 }
379
380 let turn_id = state.turn_id.unwrap_or_default();
381 let mut effects = Vec::new();
382
383 if reason_result.success {
384 effects.push(TurnLifecycleEffect::TurnCompleted {
385 input_message_id: state.input_message_id,
386 data: TurnCompletedData {
387 turn_id,
388 iterations: state.iteration,
389 duration_ms: summarized_state.duration_ms(now),
390 usage: summarized_state.cumulative_usage.clone(),
391 input_content: None,
392 final_message_id: summarized_state.final_message_id,
393 final_answer_preview: summarized_state.final_answer_preview.clone(),
394 time_to_first_token_ms: summarized_state.time_to_first_token_ms,
395 tool_call_count: Some(summarized_state.tool_call_count),
396 llm_call_count: Some(summarized_state.llm_call_count),
397 status: Some("completed".to_string()),
398 },
399 });
400 effects.push(TurnLifecycleEffect::SessionIdled {
401 turn_id,
402 input_message_id: state.input_message_id,
403 iterations: Some(state.iteration),
404 usage: summarized_state.cumulative_usage.clone(),
405 });
406 } else {
407 let user_error = classify_reason_failure(&reason_result);
408 effects.push(TurnLifecycleEffect::TurnFailedWithDisclosure {
409 turn_id,
410 input_message_id: state.input_message_id,
411 text: reason_result.text.clone(),
412 user_error: Some(user_error),
413 disclosure: reason_result.error_disclosure,
414 });
415 }
416
417 effects.push(TurnLifecycleEffect::FireTurnEndHooks {
420 harness_id: state.harness_id,
421 agent_id: state.agent_id,
422 turn_id,
423 success: reason_result.success,
424 });
425
426 let stop_reason = if !reason_result.success {
427 match TurnStopReason::from_provider_finish_reason(reason_result.finish_reason.as_deref()) {
428 TurnStopReason::Refusal => TurnStopReason::Refusal,
429 _ => TurnStopReason::Error,
430 }
431 } else if max_turn_requests_reached
432 && (reason_result.has_tool_calls || pending_user_message_count > 0)
433 {
434 TurnStopReason::MaxTurnRequests
435 } else {
436 TurnStopReason::from_provider_finish_reason(reason_result.finish_reason.as_deref())
437 };
438
439 (
440 TurnPlan::Complete {
441 stop_reason,
442 error: reason_result.error,
443 },
444 effects,
445 )
446}
447
448pub fn plan_after_act(
454 state: &TurnState,
455 outcome: ActOutcome,
456 setup_connection_hint_enabled: bool,
457) -> (TurnPlan, Vec<TurnLifecycleEffect>) {
458 if outcome.blocked {
459 return (
460 TurnPlan::Complete {
461 stop_reason: TurnStopReason::EndTurn,
462 error: None,
463 },
464 Vec::new(),
465 );
466 }
467
468 let should_pause_for_tool_results =
469 outcome.waiting_for_tool_results && setup_connection_hint_enabled;
470
471 let next = TurnState {
472 iteration: state.iteration.saturating_add(1),
473 ..state.clone()
474 };
475
476 if should_pause_for_tool_results {
477 return (
478 TurnPlan::WaitForToolResults { resume: next },
479 vec![TurnLifecycleEffect::WaitingForToolResults],
480 );
481 }
482
483 if outcome.waiting_for_tool_results {
484 info!(
485 session_id = %state.session_id,
486 "setup_connection hint absent, continuing turn instead of pausing"
487 );
488 }
489
490 (TurnPlan::ScheduleReason(next), Vec::new())
491}