1use crate::{ActInput, ExecutionContext, ReasonResult};
10use chrono::{DateTime, Utc};
11use everruns_core::events::{TokenUsage, TurnCompletedData};
12use everruns_core::turn::TurnStopReason;
13use everruns_provider::typed_id::{
14 AgentId, ExecId, HarnessId, MessageId, SessionId, TurnId, WorkspaceId,
15};
16use everruns_provider::user_facing_error::codes as user_facing_error_codes;
17use everruns_provider::user_facing_error::{
18 ErrorDisclosure, UserFacingError, UserFacingErrorContext, classify_runtime_error_message,
19};
20use serde::{Deserialize, Serialize};
21use tracing::{debug, info};
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct TurnState {
33 pub org_id: i64,
34 pub session_id: SessionId,
35 pub harness_id: HarnessId,
36 pub agent_id: Option<AgentId>,
37 pub input_message_id: MessageId,
38 #[serde(skip_serializing_if = "Option::is_none")]
39 pub turn_id: Option<TurnId>,
40 #[serde(skip_serializing_if = "Option::is_none", default)]
41 pub previous_response_id: Option<String>,
42 #[serde(default = "default_iteration")]
43 pub iteration: u32,
44 #[serde(skip_serializing_if = "Option::is_none", default)]
45 pub request_id: Option<String>,
46 #[serde(skip_serializing_if = "Option::is_none", default)]
47 pub started_at: Option<DateTime<Utc>>,
48 #[serde(skip_serializing_if = "Option::is_none", default)]
49 pub cumulative_usage: Option<TokenUsage>,
50 #[serde(default)]
51 pub tool_call_count: u32,
52 #[serde(default)]
53 pub llm_call_count: u32,
54 #[serde(skip_serializing_if = "Option::is_none", default)]
55 pub time_to_first_token_ms: Option<u64>,
56 #[serde(skip_serializing_if = "Option::is_none", default)]
57 pub final_message_id: Option<MessageId>,
58 #[serde(skip_serializing_if = "Option::is_none", default)]
59 pub final_answer_preview: Option<String>,
60}
61
62fn default_iteration() -> u32 {
63 1
64}
65
66#[derive(Debug, Clone)]
70pub struct ActPlan {
71 pub input: ActInput,
72 pub previous_response_id: Option<String>,
73 pub iteration: u32,
74 pub request_id: Option<String>,
75 pub resume_state: Box<TurnState>,
76}
77
78#[derive(Debug, Clone)]
84pub enum TurnPlan {
85 ScheduleReason(TurnState),
86 ScheduleAct(ActPlan),
87 Complete {
88 stop_reason: TurnStopReason,
89 error: Option<String>,
90 },
91 WaitForToolResults {
92 resume: TurnState,
93 },
94}
95
96#[derive(Debug, Clone)]
104pub enum TurnLifecycleEffect {
105 TurnCompleted {
107 input_message_id: MessageId,
108 data: TurnCompletedData,
109 },
110 SessionIdled {
112 turn_id: TurnId,
113 input_message_id: MessageId,
114 iterations: Option<u32>,
115 usage: Option<TokenUsage>,
116 },
117 TurnFailedWithDisclosure {
120 turn_id: TurnId,
121 input_message_id: MessageId,
122 text: String,
123 user_error: Option<UserFacingError>,
124 disclosure: Option<ErrorDisclosure>,
125 },
126 FireTurnEndHooks {
128 harness_id: HarnessId,
129 agent_id: Option<AgentId>,
130 turn_id: TurnId,
131 success: bool,
132 },
133 WaitingForToolResults,
135}
136
137#[derive(Debug, Clone, Copy, Default)]
139pub struct ActOutcome {
140 pub blocked: bool,
141 pub waiting_for_tool_results: bool,
142 pub waiting_for_url_elicitation: bool,
145}
146
147#[derive(Debug, Clone, Default)]
155pub struct ActSchedulingFacts {
156 pub blueprint_id: Option<String>,
157 pub workspace_id: Option<WorkspaceId>,
158}
159
160pub enum ActivityOutcome {
166 ProcessInput { turn_id: Option<TurnId> },
167 Reason(Box<ReasonResult>),
169 Act(ActOutcome),
170}
171
172#[derive(Debug, Clone, Default)]
179pub struct HostFacts {
180 pub act_scheduling: Option<ActSchedulingFacts>,
181 pub setup_connection_hint_enabled: bool,
182 pub url_elicitation_hint_enabled: bool,
183}
184
185fn preview_final_answer(text: &str) -> Option<String> {
186 if text.is_empty() {
187 return None;
188 }
189
190 Some(text.chars().take(2000).collect())
191}
192
193fn add_usage(current: &mut Option<TokenUsage>, next: &TokenUsage) {
194 match current {
195 Some(current) => current.add(next),
196 None => *current = Some(next.clone()),
197 }
198}
199
200impl TurnState {
201 pub(crate) fn with_reason_summary(&self, reason_result: &ReasonResult) -> Self {
202 let mut next = self.clone();
203 next.llm_call_count = next.llm_call_count.saturating_add(
204 reason_result
205 .native_counts
206 .as_ref()
207 .map_or(1, |counts| counts.llm_calls),
208 );
209 next.tool_call_count = next.tool_call_count.saturating_add(
210 reason_result
211 .native_counts
212 .as_ref()
213 .map_or(reason_result.tool_calls.len() as u32, |counts| {
214 counts.tool_calls
215 }),
216 );
217 if let Some(usage) = &reason_result.usage {
218 add_usage(&mut next.cumulative_usage, usage);
219 }
220 if next.time_to_first_token_ms.is_none() {
221 next.time_to_first_token_ms = reason_result.time_to_first_token_ms;
222 }
223 next.final_message_id = reason_result.output_message_id;
224 next.final_answer_preview = preview_final_answer(&reason_result.text);
225 next
226 }
227
228 fn duration_ms(&self, now: DateTime<Utc>) -> Option<u64> {
231 self.started_at
232 .map(|started_at| now.signed_duration_since(started_at))
233 .and_then(|duration| u64::try_from(duration.num_milliseconds()).ok())
234 }
235}
236
237fn classify_reason_failure(reason_result: &ReasonResult) -> UserFacingError {
238 if let Some(user_error) = &reason_result.user_facing_error {
242 return user_error.clone();
243 }
244
245 let from_text =
246 classify_runtime_error_message(&reason_result.text, &UserFacingErrorContext::default());
247
248 let Some(error) = reason_result.error.as_deref() else {
249 return from_text;
250 };
251
252 let from_error = classify_runtime_error_message(error, &UserFacingErrorContext::default());
253
254 if from_error.code == user_facing_error_codes::PROCESSING_ERROR {
255 return from_text;
256 }
257
258 if from_error.code == from_text.code
259 && from_error.fields.is_empty()
260 && !from_text.fields.is_empty()
261 {
262 return from_text;
263 }
264
265 from_error
266}
267
268pub fn reason_schedules_act(state: &TurnState, reason_result: &ReasonResult) -> bool {
276 let max_turn_requests_reached = state.iteration >= reason_result.max_iterations as u32;
277 reason_result.has_tool_calls && reason_result.success && !max_turn_requests_reached
278}
279
280pub fn plan_next_turn(
288 state: &TurnState,
289 outcome: ActivityOutcome,
290 pending_user_message_count: usize,
291 now: DateTime<Utc>,
292 facts: HostFacts,
293) -> (TurnPlan, Vec<TurnLifecycleEffect>) {
294 match outcome {
295 ActivityOutcome::ProcessInput { turn_id } => {
296 (plan_after_process_input(state, turn_id, now), Vec::new())
297 }
298 ActivityOutcome::Reason(reason_result) => plan_after_reason(
299 state,
300 *reason_result,
301 pending_user_message_count,
302 now,
303 facts.act_scheduling,
304 ),
305 ActivityOutcome::Act(outcome) => plan_after_act(
306 state,
307 outcome,
308 facts.setup_connection_hint_enabled,
309 facts.url_elicitation_hint_enabled,
310 ),
311 }
312}
313
314pub fn plan_after_process_input(
316 state: &TurnState,
317 turn_id: Option<TurnId>,
318 now: DateTime<Utc>,
319) -> TurnPlan {
320 let next = TurnState {
321 turn_id,
322 previous_response_id: None,
323 iteration: 1,
324 started_at: state.started_at.or(Some(now)),
325 ..state.clone()
326 };
327 debug!(session_id = %state.session_id, turn_id = ?turn_id, "planned reason step");
328 TurnPlan::ScheduleReason(next)
329}
330
331pub fn plan_after_reason(
338 state: &TurnState,
339 reason_result: ReasonResult,
340 pending_user_message_count: usize,
341 now: DateTime<Utc>,
342 act_scheduling: Option<ActSchedulingFacts>,
343) -> (TurnPlan, Vec<TurnLifecycleEffect>) {
344 let response_id = reason_result.response_id.clone();
345 let summarized_state = state.with_reason_summary(&reason_result);
346 let max_turn_requests_reached = state.iteration >= reason_result.max_iterations as u32;
347
348 if reason_schedules_act(state, &reason_result) {
349 let facts = act_scheduling.unwrap_or_default();
350 let plan = ActPlan {
351 input: ActInput {
352 org_id: Some(state.org_id),
353 context: ExecutionContext {
354 session_id: state.session_id,
355 turn_id: state.turn_id.unwrap_or_default(),
356 input_message_id: state.input_message_id,
357 exec_id: ExecId::new(),
358 workspace_id: facts.workspace_id,
359 },
360 harness_id: state.harness_id,
361 agent_id: state.agent_id,
362 tool_calls: reason_result.tool_calls,
363 tool_definitions: reason_result.tool_definitions,
364 locale: reason_result.locale,
365 blueprint_id: facts.blueprint_id,
366 network_access: reason_result.network_access,
367 parallel_tool_calls: reason_result.parallel_tool_calls,
370 },
371 previous_response_id: response_id,
372 iteration: state.iteration,
373 request_id: state.request_id.clone(),
374 resume_state: Box::new(summarized_state),
375 };
376 return (TurnPlan::ScheduleAct(plan), Vec::new());
377 }
378
379 if reason_result.success && pending_user_message_count > 0 && !max_turn_requests_reached {
380 if pending_user_message_count > 1 {
381 info!(
382 session_id = %state.session_id,
383 pending_user_message_count,
384 "multiple steering messages arrived during turn"
385 );
386 }
387
388 let next = TurnState {
389 previous_response_id: response_id,
390 iteration: state.iteration.saturating_add(1),
391 ..summarized_state
392 };
393 return (TurnPlan::ScheduleReason(next), Vec::new());
394 }
395
396 let turn_id = state.turn_id.unwrap_or_default();
397 let mut effects = Vec::new();
398
399 if reason_result.success {
400 effects.push(TurnLifecycleEffect::TurnCompleted {
401 input_message_id: state.input_message_id,
402 data: TurnCompletedData {
403 turn_id,
404 iterations: state.iteration,
405 duration_ms: summarized_state.duration_ms(now),
406 usage: summarized_state.cumulative_usage.clone(),
407 input_content: None,
408 final_message_id: summarized_state.final_message_id,
409 final_answer_preview: summarized_state.final_answer_preview.clone(),
410 time_to_first_token_ms: summarized_state.time_to_first_token_ms,
411 tool_call_count: Some(summarized_state.tool_call_count),
412 llm_call_count: Some(summarized_state.llm_call_count),
413 status: Some("completed".to_string()),
414 },
415 });
416 effects.push(TurnLifecycleEffect::SessionIdled {
417 turn_id,
418 input_message_id: state.input_message_id,
419 iterations: Some(state.iteration),
420 usage: summarized_state.cumulative_usage.clone(),
421 });
422 } else {
423 let user_error = classify_reason_failure(&reason_result);
424 effects.push(TurnLifecycleEffect::TurnFailedWithDisclosure {
425 turn_id,
426 input_message_id: state.input_message_id,
427 text: reason_result.text.clone(),
428 user_error: Some(user_error),
429 disclosure: reason_result.error_disclosure,
430 });
431 }
432
433 effects.push(TurnLifecycleEffect::FireTurnEndHooks {
436 harness_id: state.harness_id,
437 agent_id: state.agent_id,
438 turn_id,
439 success: reason_result.success,
440 });
441
442 let stop_reason = if !reason_result.success {
443 match TurnStopReason::from_provider_finish_reason(reason_result.finish_reason.as_deref()) {
444 TurnStopReason::Refusal => TurnStopReason::Refusal,
445 _ => TurnStopReason::Error,
446 }
447 } else if max_turn_requests_reached
448 && (reason_result.has_tool_calls || pending_user_message_count > 0)
449 {
450 TurnStopReason::MaxTurnRequests
451 } else {
452 TurnStopReason::from_provider_finish_reason(reason_result.finish_reason.as_deref())
453 };
454
455 (
456 TurnPlan::Complete {
457 stop_reason,
458 error: reason_result.error,
459 },
460 effects,
461 )
462}
463
464pub fn plan_after_act(
471 state: &TurnState,
472 outcome: ActOutcome,
473 setup_connection_hint_enabled: bool,
474 url_elicitation_hint_enabled: bool,
475) -> (TurnPlan, Vec<TurnLifecycleEffect>) {
476 if outcome.blocked {
477 return (
478 TurnPlan::Complete {
479 stop_reason: TurnStopReason::EndTurn,
480 error: None,
481 },
482 Vec::new(),
483 );
484 }
485
486 let should_pause_for_tool_results = outcome.waiting_for_tool_results
492 && (setup_connection_hint_enabled
493 || (outcome.waiting_for_url_elicitation && url_elicitation_hint_enabled));
494
495 let next = TurnState {
496 iteration: state.iteration.saturating_add(1),
497 ..state.clone()
498 };
499
500 if should_pause_for_tool_results {
501 return (
502 TurnPlan::WaitForToolResults { resume: next },
503 vec![TurnLifecycleEffect::WaitingForToolResults],
504 );
505 }
506
507 if outcome.waiting_for_tool_results {
508 info!(
509 session_id = %state.session_id,
510 waiting_for_url_elicitation = outcome.waiting_for_url_elicitation,
511 "no hint declares this client can answer the pause, continuing turn instead"
512 );
513 }
514
515 (TurnPlan::ScheduleReason(next), Vec::new())
516}