use chrono::{DateTime, Utc};
use everruns_core::atoms::{ActInput, AtomContext, ReasonResult};
use everruns_core::events::{TokenUsage, TurnCompletedData};
use everruns_core::turn::TurnStopReason;
use everruns_core::typed_id::{
AgentId, ExecId, HarnessId, MessageId, SessionId, TurnId, WorkspaceId,
};
use everruns_core::{
ErrorDisclosure, UserFacingError, UserFacingErrorContext, classify_runtime_error_message,
user_facing_error_codes,
};
use serde::{Deserialize, Serialize};
use tracing::{debug, info};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TurnState {
pub org_id: i64,
pub session_id: SessionId,
pub harness_id: HarnessId,
pub agent_id: Option<AgentId>,
pub input_message_id: MessageId,
#[serde(skip_serializing_if = "Option::is_none")]
pub turn_id: Option<TurnId>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub previous_response_id: Option<String>,
#[serde(default = "default_iteration")]
pub iteration: u32,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub request_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub started_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub cumulative_usage: Option<TokenUsage>,
#[serde(default)]
pub tool_call_count: u32,
#[serde(default)]
pub llm_call_count: u32,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub time_to_first_token_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub final_message_id: Option<MessageId>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub final_answer_preview: Option<String>,
}
fn default_iteration() -> u32 {
1
}
#[derive(Debug, Clone)]
pub struct ActPlan {
pub input: ActInput,
pub previous_response_id: Option<String>,
pub iteration: u32,
pub request_id: Option<String>,
pub resume_state: Box<TurnState>,
}
#[derive(Debug, Clone)]
pub enum TurnPlan {
ScheduleReason(TurnState),
ScheduleAct(ActPlan),
Complete {
stop_reason: TurnStopReason,
error: Option<String>,
},
WaitForToolResults {
resume: TurnState,
},
}
#[derive(Debug, Clone)]
pub enum TurnLifecycleEffect {
TurnCompleted {
input_message_id: MessageId,
data: TurnCompletedData,
},
SessionIdled {
turn_id: TurnId,
input_message_id: MessageId,
iterations: Option<u32>,
usage: Option<TokenUsage>,
},
TurnFailedWithDisclosure {
turn_id: TurnId,
input_message_id: MessageId,
text: String,
user_error: Option<UserFacingError>,
disclosure: Option<ErrorDisclosure>,
},
FireTurnEndHooks {
harness_id: HarnessId,
agent_id: Option<AgentId>,
turn_id: TurnId,
success: bool,
},
WaitingForToolResults,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ActOutcome {
pub blocked: bool,
pub waiting_for_tool_results: bool,
}
#[derive(Debug, Clone, Default)]
pub struct ActSchedulingFacts {
pub blueprint_id: Option<String>,
pub workspace_id: Option<WorkspaceId>,
}
pub enum ActivityOutcome {
ProcessInput { turn_id: Option<TurnId> },
Reason(Box<ReasonResult>),
Act(ActOutcome),
}
#[derive(Debug, Clone, Default)]
pub struct HostFacts {
pub act_scheduling: Option<ActSchedulingFacts>,
pub setup_connection_hint_enabled: bool,
}
fn preview_final_answer(text: &str) -> Option<String> {
if text.is_empty() {
return None;
}
Some(text.chars().take(2000).collect())
}
fn add_usage(current: &mut Option<TokenUsage>, next: &TokenUsage) {
match current {
Some(current) => current.add(next),
None => *current = Some(next.clone()),
}
}
impl TurnState {
fn with_reason_summary(&self, reason_result: &ReasonResult) -> Self {
let mut next = self.clone();
next.llm_call_count = next.llm_call_count.saturating_add(1);
next.tool_call_count = next
.tool_call_count
.saturating_add(reason_result.tool_calls.len() as u32);
if let Some(usage) = &reason_result.usage {
add_usage(&mut next.cumulative_usage, usage);
}
if next.time_to_first_token_ms.is_none() {
next.time_to_first_token_ms = reason_result.time_to_first_token_ms;
}
next.final_message_id = reason_result.output_message_id;
next.final_answer_preview = preview_final_answer(&reason_result.text);
next
}
fn duration_ms(&self, now: DateTime<Utc>) -> Option<u64> {
self.started_at
.map(|started_at| now.signed_duration_since(started_at))
.and_then(|duration| u64::try_from(duration.num_milliseconds()).ok())
}
}
fn classify_reason_failure(reason_result: &ReasonResult) -> UserFacingError {
if let Some(user_error) = &reason_result.user_facing_error {
return user_error.clone();
}
let from_text =
classify_runtime_error_message(&reason_result.text, &UserFacingErrorContext::default());
let Some(error) = reason_result.error.as_deref() else {
return from_text;
};
let from_error = classify_runtime_error_message(error, &UserFacingErrorContext::default());
if from_error.code == user_facing_error_codes::PROCESSING_ERROR {
return from_text;
}
if from_error.code == from_text.code
&& from_error.fields.is_empty()
&& !from_text.fields.is_empty()
{
return from_text;
}
from_error
}
pub fn reason_schedules_act(state: &TurnState, reason_result: &ReasonResult) -> bool {
let max_turn_requests_reached = state.iteration >= reason_result.max_iterations as u32;
reason_result.has_tool_calls && reason_result.success && !max_turn_requests_reached
}
pub fn plan_next_turn(
state: &TurnState,
outcome: ActivityOutcome,
pending_user_message_count: usize,
now: DateTime<Utc>,
facts: HostFacts,
) -> (TurnPlan, Vec<TurnLifecycleEffect>) {
match outcome {
ActivityOutcome::ProcessInput { turn_id } => {
(plan_after_process_input(state, turn_id, now), Vec::new())
}
ActivityOutcome::Reason(reason_result) => plan_after_reason(
state,
*reason_result,
pending_user_message_count,
now,
facts.act_scheduling,
),
ActivityOutcome::Act(outcome) => {
plan_after_act(state, outcome, facts.setup_connection_hint_enabled)
}
}
}
pub fn plan_after_process_input(
state: &TurnState,
turn_id: Option<TurnId>,
now: DateTime<Utc>,
) -> TurnPlan {
let next = TurnState {
turn_id,
previous_response_id: None,
iteration: 1,
started_at: state.started_at.or(Some(now)),
..state.clone()
};
debug!(session_id = %state.session_id, turn_id = ?turn_id, "planned reason step");
TurnPlan::ScheduleReason(next)
}
pub fn plan_after_reason(
state: &TurnState,
reason_result: ReasonResult,
pending_user_message_count: usize,
now: DateTime<Utc>,
act_scheduling: Option<ActSchedulingFacts>,
) -> (TurnPlan, Vec<TurnLifecycleEffect>) {
let response_id = reason_result.response_id.clone();
let summarized_state = state.with_reason_summary(&reason_result);
let max_turn_requests_reached = state.iteration >= reason_result.max_iterations as u32;
if reason_schedules_act(state, &reason_result) {
let facts = act_scheduling.unwrap_or_default();
let plan = ActPlan {
input: ActInput {
org_id: Some(state.org_id),
context: AtomContext {
session_id: state.session_id,
turn_id: state.turn_id.unwrap_or_default(),
input_message_id: state.input_message_id,
exec_id: ExecId::new(),
workspace_id: facts.workspace_id,
},
harness_id: state.harness_id,
agent_id: state.agent_id,
tool_calls: reason_result.tool_calls,
tool_definitions: reason_result.tool_definitions,
locale: reason_result.locale,
blueprint_id: facts.blueprint_id,
network_access: reason_result.network_access,
parallel_tool_calls: reason_result.parallel_tool_calls,
},
previous_response_id: response_id,
iteration: state.iteration,
request_id: state.request_id.clone(),
resume_state: Box::new(summarized_state),
};
return (TurnPlan::ScheduleAct(plan), Vec::new());
}
if reason_result.success && pending_user_message_count > 0 && !max_turn_requests_reached {
if pending_user_message_count > 1 {
info!(
session_id = %state.session_id,
pending_user_message_count,
"multiple steering messages arrived during turn"
);
}
let next = TurnState {
previous_response_id: response_id,
iteration: state.iteration.saturating_add(1),
..summarized_state
};
return (TurnPlan::ScheduleReason(next), Vec::new());
}
let turn_id = state.turn_id.unwrap_or_default();
let mut effects = Vec::new();
if reason_result.success {
effects.push(TurnLifecycleEffect::TurnCompleted {
input_message_id: state.input_message_id,
data: TurnCompletedData {
turn_id,
iterations: state.iteration,
duration_ms: summarized_state.duration_ms(now),
usage: summarized_state.cumulative_usage.clone(),
input_content: None,
final_message_id: summarized_state.final_message_id,
final_answer_preview: summarized_state.final_answer_preview.clone(),
time_to_first_token_ms: summarized_state.time_to_first_token_ms,
tool_call_count: Some(summarized_state.tool_call_count),
llm_call_count: Some(summarized_state.llm_call_count),
status: Some("completed".to_string()),
},
});
effects.push(TurnLifecycleEffect::SessionIdled {
turn_id,
input_message_id: state.input_message_id,
iterations: Some(state.iteration),
usage: summarized_state.cumulative_usage.clone(),
});
} else {
let user_error = classify_reason_failure(&reason_result);
effects.push(TurnLifecycleEffect::TurnFailedWithDisclosure {
turn_id,
input_message_id: state.input_message_id,
text: reason_result.text.clone(),
user_error: Some(user_error),
disclosure: reason_result.error_disclosure,
});
}
effects.push(TurnLifecycleEffect::FireTurnEndHooks {
harness_id: state.harness_id,
agent_id: state.agent_id,
turn_id,
success: reason_result.success,
});
let stop_reason = if !reason_result.success {
match TurnStopReason::from_provider_finish_reason(reason_result.finish_reason.as_deref()) {
TurnStopReason::Refusal => TurnStopReason::Refusal,
_ => TurnStopReason::Error,
}
} else if max_turn_requests_reached
&& (reason_result.has_tool_calls || pending_user_message_count > 0)
{
TurnStopReason::MaxTurnRequests
} else {
TurnStopReason::from_provider_finish_reason(reason_result.finish_reason.as_deref())
};
(
TurnPlan::Complete {
stop_reason,
error: reason_result.error,
},
effects,
)
}
pub fn plan_after_act(
state: &TurnState,
outcome: ActOutcome,
setup_connection_hint_enabled: bool,
) -> (TurnPlan, Vec<TurnLifecycleEffect>) {
if outcome.blocked {
return (
TurnPlan::Complete {
stop_reason: TurnStopReason::EndTurn,
error: None,
},
Vec::new(),
);
}
let should_pause_for_tool_results =
outcome.waiting_for_tool_results && setup_connection_hint_enabled;
let next = TurnState {
iteration: state.iteration.saturating_add(1),
..state.clone()
};
if should_pause_for_tool_results {
return (
TurnPlan::WaitForToolResults { resume: next },
vec![TurnLifecycleEffect::WaitingForToolResults],
);
}
if outcome.waiting_for_tool_results {
info!(
session_id = %state.session_id,
"setup_connection hint absent, continuing turn instead of pausing"
);
}
(TurnPlan::ScheduleReason(next), Vec::new())
}