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 ResolveAskUserUnattended {
118 turn_id: Option<TurnId>,
121 input_message_id: MessageId,
122 calls: Vec<(String, serde_json::Value)>,
124 },
125 SessionIdled {
127 turn_id: TurnId,
128 input_message_id: MessageId,
129 iterations: Option<u32>,
130 usage: Option<TokenUsage>,
131 },
132 TurnFailedWithDisclosure {
135 turn_id: TurnId,
136 input_message_id: MessageId,
137 text: String,
138 user_error: Option<UserFacingError>,
139 disclosure: Option<ErrorDisclosure>,
140 },
141 FireTurnEndHooks {
143 harness_id: HarnessId,
144 agent_id: Option<AgentId>,
145 turn_id: TurnId,
146 success: bool,
147 },
148 WaitingForToolResults,
150}
151
152#[derive(Debug, Clone, Copy, Default)]
154pub struct ActOutcome {
155 pub blocked: bool,
156 pub waiting_for_tool_results: bool,
157 pub waiting_for_url_elicitation: bool,
160 pub waiting_for_ask_user: bool,
163}
164
165#[derive(Debug, Clone, Default)]
173pub struct ActSchedulingFacts {
174 pub blueprint_id: Option<String>,
175 pub workspace_id: Option<WorkspaceId>,
176}
177
178pub enum ActivityOutcome {
184 ProcessInput { turn_id: Option<TurnId> },
185 Reason(Box<ReasonResult>),
187 Act(ActOutcome),
188}
189
190#[derive(Debug, Clone, Default)]
197pub struct HostFacts {
198 pub act_scheduling: Option<ActSchedulingFacts>,
199 pub setup_connection_hint_enabled: bool,
200 pub url_elicitation_hint_enabled: bool,
201 pub ask_user_hint_enabled: bool,
202 pub ask_user_calls: Vec<(String, serde_json::Value)>,
206}
207
208fn preview_final_answer(text: &str) -> Option<String> {
209 if text.is_empty() {
210 return None;
211 }
212
213 Some(text.chars().take(2000).collect())
214}
215
216fn add_usage(current: &mut Option<TokenUsage>, next: &TokenUsage) {
217 match current {
218 Some(current) => current.add(next),
219 None => *current = Some(next.clone()),
220 }
221}
222
223impl TurnState {
224 pub(crate) fn with_reason_summary(&self, reason_result: &ReasonResult) -> Self {
225 let mut next = self.clone();
226 next.llm_call_count = next.llm_call_count.saturating_add(
227 reason_result
228 .native_counts
229 .as_ref()
230 .map_or(1, |counts| counts.llm_calls),
231 );
232 next.tool_call_count = next.tool_call_count.saturating_add(
233 reason_result
234 .native_counts
235 .as_ref()
236 .map_or(reason_result.tool_calls.len() as u32, |counts| {
237 counts.tool_calls
238 }),
239 );
240 if let Some(usage) = &reason_result.usage {
241 add_usage(&mut next.cumulative_usage, usage);
242 }
243 if next.time_to_first_token_ms.is_none() {
244 next.time_to_first_token_ms = reason_result.time_to_first_token_ms;
245 }
246 next.final_message_id = reason_result.output_message_id;
247 next.final_answer_preview = preview_final_answer(&reason_result.text);
248 next
249 }
250
251 fn duration_ms(&self, now: DateTime<Utc>) -> Option<u64> {
254 self.started_at
255 .map(|started_at| now.signed_duration_since(started_at))
256 .and_then(|duration| u64::try_from(duration.num_milliseconds()).ok())
257 }
258}
259
260fn classify_reason_failure(reason_result: &ReasonResult) -> UserFacingError {
261 if let Some(user_error) = &reason_result.user_facing_error {
265 return user_error.clone();
266 }
267
268 let from_text =
269 classify_runtime_error_message(&reason_result.text, &UserFacingErrorContext::default());
270
271 let Some(error) = reason_result.error.as_deref() else {
272 return from_text;
273 };
274
275 let from_error = classify_runtime_error_message(error, &UserFacingErrorContext::default());
276
277 if from_error.code == user_facing_error_codes::PROCESSING_ERROR {
278 return from_text;
279 }
280
281 if from_error.code == from_text.code
282 && from_error.fields.is_empty()
283 && !from_text.fields.is_empty()
284 {
285 return from_text;
286 }
287
288 from_error
289}
290
291pub fn reason_schedules_act(state: &TurnState, reason_result: &ReasonResult) -> bool {
299 let max_turn_requests_reached = state.iteration >= reason_result.max_iterations as u32;
300 reason_result.has_tool_calls && reason_result.success && !max_turn_requests_reached
301}
302
303pub fn plan_next_turn(
311 state: &TurnState,
312 outcome: ActivityOutcome,
313 pending_user_message_count: usize,
314 now: DateTime<Utc>,
315 facts: HostFacts,
316) -> (TurnPlan, Vec<TurnLifecycleEffect>) {
317 match outcome {
318 ActivityOutcome::ProcessInput { turn_id } => {
319 (plan_after_process_input(state, turn_id, now), Vec::new())
320 }
321 ActivityOutcome::Reason(reason_result) => plan_after_reason(
322 state,
323 *reason_result,
324 pending_user_message_count,
325 now,
326 facts.act_scheduling,
327 ),
328 ActivityOutcome::Act(outcome) => plan_after_act(
329 state,
330 outcome,
331 facts.setup_connection_hint_enabled,
332 facts.url_elicitation_hint_enabled,
333 facts.ask_user_hint_enabled,
334 facts.ask_user_calls.clone(),
335 ),
336 }
337}
338
339pub fn plan_after_process_input(
341 state: &TurnState,
342 turn_id: Option<TurnId>,
343 now: DateTime<Utc>,
344) -> TurnPlan {
345 let next = TurnState {
346 turn_id,
347 previous_response_id: None,
348 iteration: 1,
349 started_at: state.started_at.or(Some(now)),
350 ..state.clone()
351 };
352 debug!(session_id = %state.session_id, turn_id = ?turn_id, "planned reason step");
353 TurnPlan::ScheduleReason(next)
354}
355
356pub fn plan_after_reason(
363 state: &TurnState,
364 reason_result: ReasonResult,
365 pending_user_message_count: usize,
366 now: DateTime<Utc>,
367 act_scheduling: Option<ActSchedulingFacts>,
368) -> (TurnPlan, Vec<TurnLifecycleEffect>) {
369 let response_id = reason_result.response_id.clone();
370 let summarized_state = state.with_reason_summary(&reason_result);
371 let max_turn_requests_reached = state.iteration >= reason_result.max_iterations as u32;
372
373 if reason_schedules_act(state, &reason_result) {
374 let facts = act_scheduling.unwrap_or_default();
375 let plan = ActPlan {
376 input: ActInput {
377 org_id: Some(state.org_id),
378 context: ExecutionContext {
379 session_id: state.session_id,
380 turn_id: state.turn_id.unwrap_or_default(),
381 input_message_id: state.input_message_id,
382 exec_id: ExecId::new(),
383 workspace_id: facts.workspace_id,
384 },
385 harness_id: state.harness_id,
386 agent_id: state.agent_id,
387 tool_calls: reason_result.tool_calls,
388 tool_definitions: reason_result.tool_definitions,
389 locale: reason_result.locale,
390 blueprint_id: facts.blueprint_id,
391 network_access: reason_result.network_access,
392 parallel_tool_calls: reason_result.parallel_tool_calls,
395 },
396 previous_response_id: response_id,
397 iteration: state.iteration,
398 request_id: state.request_id.clone(),
399 resume_state: Box::new(summarized_state),
400 };
401 return (TurnPlan::ScheduleAct(plan), Vec::new());
402 }
403
404 if reason_result.success && pending_user_message_count > 0 && !max_turn_requests_reached {
405 if pending_user_message_count > 1 {
406 info!(
407 session_id = %state.session_id,
408 pending_user_message_count,
409 "multiple steering messages arrived during turn"
410 );
411 }
412
413 let next = TurnState {
414 previous_response_id: response_id,
415 iteration: state.iteration.saturating_add(1),
416 ..summarized_state
417 };
418 return (TurnPlan::ScheduleReason(next), Vec::new());
419 }
420
421 let turn_id = state.turn_id.unwrap_or_default();
422 let mut effects = Vec::new();
423
424 if reason_result.success {
425 effects.push(TurnLifecycleEffect::TurnCompleted {
426 input_message_id: state.input_message_id,
427 data: TurnCompletedData {
428 turn_id,
429 iterations: state.iteration,
430 duration_ms: summarized_state.duration_ms(now),
431 usage: summarized_state.cumulative_usage.clone(),
432 input_content: None,
433 final_message_id: summarized_state.final_message_id,
434 final_answer_preview: summarized_state.final_answer_preview.clone(),
435 time_to_first_token_ms: summarized_state.time_to_first_token_ms,
436 tool_call_count: Some(summarized_state.tool_call_count),
437 llm_call_count: Some(summarized_state.llm_call_count),
438 status: Some("completed".to_string()),
439 },
440 });
441 effects.push(TurnLifecycleEffect::SessionIdled {
442 turn_id,
443 input_message_id: state.input_message_id,
444 iterations: Some(state.iteration),
445 usage: summarized_state.cumulative_usage.clone(),
446 });
447 } else {
448 let user_error = classify_reason_failure(&reason_result);
449 effects.push(TurnLifecycleEffect::TurnFailedWithDisclosure {
450 turn_id,
451 input_message_id: state.input_message_id,
452 text: reason_result.text.clone(),
453 user_error: Some(user_error),
454 disclosure: reason_result.error_disclosure,
455 });
456 }
457
458 effects.push(TurnLifecycleEffect::FireTurnEndHooks {
461 harness_id: state.harness_id,
462 agent_id: state.agent_id,
463 turn_id,
464 success: reason_result.success,
465 });
466
467 let stop_reason = if !reason_result.success {
468 match TurnStopReason::from_provider_finish_reason(reason_result.finish_reason.as_deref()) {
469 TurnStopReason::Refusal => TurnStopReason::Refusal,
470 _ => TurnStopReason::Error,
471 }
472 } else if max_turn_requests_reached
473 && (reason_result.has_tool_calls || pending_user_message_count > 0)
474 {
475 TurnStopReason::MaxTurnRequests
476 } else {
477 TurnStopReason::from_provider_finish_reason(reason_result.finish_reason.as_deref())
478 };
479
480 (
481 TurnPlan::Complete {
482 stop_reason,
483 error: reason_result.error,
484 },
485 effects,
486 )
487}
488
489pub fn plan_after_act(
496 state: &TurnState,
497 outcome: ActOutcome,
498 setup_connection_hint_enabled: bool,
499 url_elicitation_hint_enabled: bool,
500 ask_user_hint_enabled: bool,
501 ask_user_calls: Vec<(String, serde_json::Value)>,
502) -> (TurnPlan, Vec<TurnLifecycleEffect>) {
503 if outcome.blocked {
504 return (
505 TurnPlan::Complete {
506 stop_reason: TurnStopReason::EndTurn,
507 error: None,
508 },
509 Vec::new(),
510 );
511 }
512
513 let should_pause_for_tool_results = outcome.waiting_for_tool_results
523 && if outcome.waiting_for_ask_user {
524 ask_user_hint_enabled
525 } else {
526 setup_connection_hint_enabled
527 || (outcome.waiting_for_url_elicitation && url_elicitation_hint_enabled)
528 };
529
530 let next = TurnState {
531 iteration: state.iteration.saturating_add(1),
532 ..state.clone()
533 };
534
535 if should_pause_for_tool_results {
536 return (
537 TurnPlan::WaitForToolResults { resume: next },
538 vec![TurnLifecycleEffect::WaitingForToolResults],
539 );
540 }
541
542 if outcome.waiting_for_tool_results {
543 info!(
544 session_id = %state.session_id,
545 waiting_for_url_elicitation = outcome.waiting_for_url_elicitation,
546 waiting_for_ask_user = outcome.waiting_for_ask_user,
547 "no hint declares this client can answer the pause, continuing turn instead"
548 );
549 }
550
551 let effects = if outcome.waiting_for_ask_user && !ask_user_calls.is_empty() {
556 vec![TurnLifecycleEffect::ResolveAskUserUnattended {
557 turn_id: state.turn_id,
558 input_message_id: state.input_message_id,
559 calls: ask_user_calls,
560 }]
561 } else {
562 Vec::new()
563 };
564
565 (TurnPlan::ScheduleReason(next), effects)
566}