Skip to main content

everruns_engine/
turn.rs

1// The sans-IO turn planner (EVE-840, Sans-IO Turn State epic).
2//
3// This is the authoritative turn-planning brain. Every function here is pure and
4// deterministic: it reads only its arguments and returns a `TurnPlan` (plus, for
5// terminal outcomes, a list of `TurnLifecycleEffect`s the host must perform). It
6// never touches a store, socket, process, event bus, or `Utc::now()` — the host
7// resolves those facts, passes `now` in, and performs the returned effects.
8
9use 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/// Host-owned state carried across turn phases.
24///
25/// Durable hosts can persist this between activities; in-memory hosts can hold
26/// it directly in memory. The type itself is engine-level and has no host,
27/// store, or durable-engine coupling.
28///
29/// Hosts are expected to serialize this however they want. `everruns-engine`
30/// only defines the fields required to resume the next semantic step.
31#[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/// Engine-owned act scheduling payload.
67///
68/// Hosts enqueue or execute this immediately using their own worker model.
69#[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/// Generic next-step decision for a host turn.
79///
80/// This intentionally stops at the semantic boundary:
81/// - the engine decides what should happen next
82/// - the host decides how to persist, enqueue, retry, or resume it
83#[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/// A lifecycle side effect the engine decided must be recorded, described as
97/// data rather than performed.
98///
99/// The engine never emits events or fires hooks — it *returns* these, and the
100/// host applies them (in list order) through its own lifecycle machinery. This
101/// keeps planning deterministic while preserving the exact event stream. These
102/// are NOT part of the public [`TurnPlan`]; they travel alongside it.
103#[derive(Debug, Clone)]
104pub enum TurnLifecycleEffect {
105    /// Emit `turn.completed` with the summarized turn fields.
106    TurnCompleted {
107        input_message_id: MessageId,
108        data: TurnCompletedData,
109    },
110    /// Idle the session and emit `session.idled`.
111    SessionIdled {
112        turn_id: TurnId,
113        input_message_id: MessageId,
114        iterations: Option<u32>,
115        usage: Option<TokenUsage>,
116    },
117    /// Fail the turn with the already-disclosure-filtered error, emitting
118    /// `turn.failed` + `session.idled`.
119    TurnFailedWithDisclosure {
120        turn_id: TurnId,
121        input_message_id: MessageId,
122        text: String,
123        user_error: Option<UserFacingError>,
124        disclosure: Option<ErrorDisclosure>,
125    },
126    /// Fire the advisory `turn_end` lifecycle hooks.
127    FireTurnEndHooks {
128        harness_id: HarnessId,
129        agent_id: Option<AgentId>,
130        turn_id: TurnId,
131        success: bool,
132    },
133    /// Mark the session `waiting_for_tool_results`.
134    WaitingForToolResults,
135}
136
137/// Parsed `act` activity output the planner decides over.
138#[derive(Debug, Clone, Copy, Default)]
139pub struct ActOutcome {
140    pub blocked: bool,
141    pub waiting_for_tool_results: bool,
142}
143
144/// Session facts the host pre-resolves for the reason→act scheduling case.
145///
146/// The host fetches these (from its session store) only when
147/// [`reason_schedules_act`] is true, mirroring the original conditional fetch:
148/// `blueprint_id` scopes blueprint tool resolution, and `workspace_id` points
149/// tool file I/O at the (possibly shared) workspace rather than the session's
150/// own keyspace.
151#[derive(Debug, Clone, Default)]
152pub struct ActSchedulingFacts {
153    pub blueprint_id: Option<String>,
154    pub workspace_id: Option<WorkspaceId>,
155}
156
157/// Typed, parsed activity output the engine plans the next step from.
158///
159/// The host parses the raw serialized activity output into this before calling
160/// [`plan_next_turn`]; unknown activity kinds are rejected by the host, so the
161/// engine stays total.
162pub enum ActivityOutcome {
163    ProcessInput { turn_id: Option<TurnId> },
164    // Boxed: `ReasonResult` dwarfs the other variants (clippy::large_enum_variant).
165    Reason(Box<ReasonResult>),
166    Act(ActOutcome),
167}
168
169/// Host-resolved facts the engine needs but cannot fetch itself.
170///
171/// The host populates only the field relevant to the completed activity, doing
172/// I/O in exactly the same conditions as the original planner:
173/// `act_scheduling` only when [`reason_schedules_act`] is true, and
174/// `setup_connection_hint_enabled` only when the act paused for tool results.
175#[derive(Debug, Clone, Default)]
176pub struct HostFacts {
177    pub act_scheduling: Option<ActSchedulingFacts>,
178    pub setup_connection_hint_enabled: bool,
179}
180
181fn preview_final_answer(text: &str) -> Option<String> {
182    if text.is_empty() {
183        return None;
184    }
185
186    Some(text.chars().take(2000).collect())
187}
188
189fn add_usage(current: &mut Option<TokenUsage>, next: &TokenUsage) {
190    match current {
191        Some(current) => current.add(next),
192        None => *current = Some(next.clone()),
193    }
194}
195
196impl TurnState {
197    pub(crate) fn with_reason_summary(&self, reason_result: &ReasonResult) -> Self {
198        let mut next = self.clone();
199        next.llm_call_count = next.llm_call_count.saturating_add(1);
200        next.tool_call_count = next
201            .tool_call_count
202            .saturating_add(reason_result.tool_calls.len() as u32);
203        if let Some(usage) = &reason_result.usage {
204            add_usage(&mut next.cumulative_usage, usage);
205        }
206        if next.time_to_first_token_ms.is_none() {
207            next.time_to_first_token_ms = reason_result.time_to_first_token_ms;
208        }
209        next.final_message_id = reason_result.output_message_id;
210        next.final_answer_preview = preview_final_answer(&reason_result.text);
211        next
212    }
213
214    /// Wall-clock duration since `started_at`, measured against the host-supplied
215    /// `now` so the calculation stays deterministic.
216    fn duration_ms(&self, now: DateTime<Utc>) -> Option<u64> {
217        self.started_at
218            .map(|started_at| now.signed_duration_since(started_at))
219            .and_then(|duration| u64::try_from(duration.num_milliseconds()).ok())
220    }
221}
222
223fn classify_reason_failure(reason_result: &ReasonResult) -> UserFacingError {
224    // The reason atom already classified and disclosure-filtered the failure.
225    // Reuse it so the turn.failed event matches what the session message
226    // showed; re-classifying strings here could leak past a generic mode.
227    if let Some(user_error) = &reason_result.user_facing_error {
228        return user_error.clone();
229    }
230
231    let from_text =
232        classify_runtime_error_message(&reason_result.text, &UserFacingErrorContext::default());
233
234    let Some(error) = reason_result.error.as_deref() else {
235        return from_text;
236    };
237
238    let from_error = classify_runtime_error_message(error, &UserFacingErrorContext::default());
239
240    if from_error.code == user_facing_error_codes::PROCESSING_ERROR {
241        return from_text;
242    }
243
244    if from_error.code == from_text.code
245        && from_error.fields.is_empty()
246        && !from_text.fields.is_empty()
247    {
248        return from_text;
249    }
250
251    from_error
252}
253
254/// Does this reason outcome schedule an act phase?
255///
256/// The host consults this predicate to decide whether to resolve
257/// [`ActSchedulingFacts`] (a session fetch) before calling [`plan_after_reason`]
258/// — the same condition under which the original planner fetched the session.
259/// The reason planner branches on this same function, so the rule has exactly
260/// one definition.
261pub fn reason_schedules_act(state: &TurnState, reason_result: &ReasonResult) -> bool {
262    let max_turn_requests_reached = state.iteration >= reason_result.max_iterations as u32;
263    reason_result.has_tool_calls && reason_result.success && !max_turn_requests_reached
264}
265
266/// Plan the next host step after an activity finishes.
267///
268/// The authoritative, deterministic turn-planning entry point. Given the carried
269/// [`TurnState`], the parsed [`ActivityOutcome`], the count of queued steering
270/// messages, the host-supplied `now`, and any [`HostFacts`] the host pre-resolved,
271/// it returns the [`TurnPlan`] together with the [`TurnLifecycleEffect`]s the
272/// host must perform (in order). It performs no I/O of its own.
273pub fn plan_next_turn(
274    state: &TurnState,
275    outcome: ActivityOutcome,
276    pending_user_message_count: usize,
277    now: DateTime<Utc>,
278    facts: HostFacts,
279) -> (TurnPlan, Vec<TurnLifecycleEffect>) {
280    match outcome {
281        ActivityOutcome::ProcessInput { turn_id } => {
282            (plan_after_process_input(state, turn_id, now), Vec::new())
283        }
284        ActivityOutcome::Reason(reason_result) => plan_after_reason(
285            state,
286            *reason_result,
287            pending_user_message_count,
288            now,
289            facts.act_scheduling,
290        ),
291        ActivityOutcome::Act(outcome) => {
292            plan_after_act(state, outcome, facts.setup_connection_hint_enabled)
293        }
294    }
295}
296
297/// Plan the reason step that follows a completed `process_input` activity.
298pub fn plan_after_process_input(
299    state: &TurnState,
300    turn_id: Option<TurnId>,
301    now: DateTime<Utc>,
302) -> TurnPlan {
303    let next = TurnState {
304        turn_id,
305        previous_response_id: None,
306        iteration: 1,
307        started_at: state.started_at.or(Some(now)),
308        ..state.clone()
309    };
310    debug!(session_id = %state.session_id, turn_id = ?turn_id, "planned reason step");
311    TurnPlan::ScheduleReason(next)
312}
313
314/// Plan the next step after a `reason` activity finishes.
315///
316/// When [`reason_schedules_act`] holds, `act_scheduling` supplies the session
317/// facts the host resolved for the act phase; it is ignored otherwise. A
318/// terminal reason outcome returns the lifecycle effects the host must perform;
319/// the continuing outcomes return an empty effect list.
320pub fn plan_after_reason(
321    state: &TurnState,
322    reason_result: ReasonResult,
323    pending_user_message_count: usize,
324    now: DateTime<Utc>,
325    act_scheduling: Option<ActSchedulingFacts>,
326) -> (TurnPlan, Vec<TurnLifecycleEffect>) {
327    let response_id = reason_result.response_id.clone();
328    let summarized_state = state.with_reason_summary(&reason_result);
329    let max_turn_requests_reached = state.iteration >= reason_result.max_iterations as u32;
330
331    if reason_schedules_act(state, &reason_result) {
332        let facts = act_scheduling.unwrap_or_default();
333        let plan = ActPlan {
334            input: ActInput {
335                org_id: Some(state.org_id),
336                context: ExecutionContext {
337                    session_id: state.session_id,
338                    turn_id: state.turn_id.unwrap_or_default(),
339                    input_message_id: state.input_message_id,
340                    exec_id: ExecId::new(),
341                    workspace_id: facts.workspace_id,
342                },
343                harness_id: state.harness_id,
344                agent_id: state.agent_id,
345                tool_calls: reason_result.tool_calls,
346                tool_definitions: reason_result.tool_definitions,
347                locale: reason_result.locale,
348                blueprint_id: facts.blueprint_id,
349                network_access: reason_result.network_access,
350                // Request-level parallel tool calling preference, carried
351                // from agent config through the reason path (EVE-598).
352                parallel_tool_calls: reason_result.parallel_tool_calls,
353            },
354            previous_response_id: response_id,
355            iteration: state.iteration,
356            request_id: state.request_id.clone(),
357            resume_state: Box::new(summarized_state),
358        };
359        return (TurnPlan::ScheduleAct(plan), Vec::new());
360    }
361
362    if reason_result.success && pending_user_message_count > 0 && !max_turn_requests_reached {
363        if pending_user_message_count > 1 {
364            info!(
365                session_id = %state.session_id,
366                pending_user_message_count,
367                "multiple steering messages arrived during turn"
368            );
369        }
370
371        let next = TurnState {
372            previous_response_id: response_id,
373            iteration: state.iteration.saturating_add(1),
374            ..summarized_state
375        };
376        return (TurnPlan::ScheduleReason(next), Vec::new());
377    }
378
379    let turn_id = state.turn_id.unwrap_or_default();
380    let mut effects = Vec::new();
381
382    if reason_result.success {
383        effects.push(TurnLifecycleEffect::TurnCompleted {
384            input_message_id: state.input_message_id,
385            data: TurnCompletedData {
386                turn_id,
387                iterations: state.iteration,
388                duration_ms: summarized_state.duration_ms(now),
389                usage: summarized_state.cumulative_usage.clone(),
390                input_content: None,
391                final_message_id: summarized_state.final_message_id,
392                final_answer_preview: summarized_state.final_answer_preview.clone(),
393                time_to_first_token_ms: summarized_state.time_to_first_token_ms,
394                tool_call_count: Some(summarized_state.tool_call_count),
395                llm_call_count: Some(summarized_state.llm_call_count),
396                status: Some("completed".to_string()),
397            },
398        });
399        effects.push(TurnLifecycleEffect::SessionIdled {
400            turn_id,
401            input_message_id: state.input_message_id,
402            iterations: Some(state.iteration),
403            usage: summarized_state.cumulative_usage.clone(),
404        });
405    } else {
406        let user_error = classify_reason_failure(&reason_result);
407        effects.push(TurnLifecycleEffect::TurnFailedWithDisclosure {
408            turn_id,
409            input_message_id: state.input_message_id,
410            text: reason_result.text.clone(),
411            user_error: Some(user_error),
412            disclosure: reason_result.error_disclosure,
413        });
414    }
415
416    // turn_end lifecycle hooks (advisory). Fired once the turn reaches a
417    // terminal reason outcome on the durable/strategy path.
418    effects.push(TurnLifecycleEffect::FireTurnEndHooks {
419        harness_id: state.harness_id,
420        agent_id: state.agent_id,
421        turn_id,
422        success: reason_result.success,
423    });
424
425    let stop_reason = if !reason_result.success {
426        match TurnStopReason::from_provider_finish_reason(reason_result.finish_reason.as_deref()) {
427            TurnStopReason::Refusal => TurnStopReason::Refusal,
428            _ => TurnStopReason::Error,
429        }
430    } else if max_turn_requests_reached
431        && (reason_result.has_tool_calls || pending_user_message_count > 0)
432    {
433        TurnStopReason::MaxTurnRequests
434    } else {
435        TurnStopReason::from_provider_finish_reason(reason_result.finish_reason.as_deref())
436    };
437
438    (
439        TurnPlan::Complete {
440            stop_reason,
441            error: reason_result.error,
442        },
443        effects,
444    )
445}
446
447/// Plan the next step after an `act` activity finishes.
448///
449/// `setup_connection_hint_enabled` is the resolved session hint; the host reads
450/// it only when the act reported `waiting_for_tool_results`, so passing `false`
451/// otherwise matches the original short-circuit exactly.
452pub fn plan_after_act(
453    state: &TurnState,
454    outcome: ActOutcome,
455    setup_connection_hint_enabled: bool,
456) -> (TurnPlan, Vec<TurnLifecycleEffect>) {
457    if outcome.blocked {
458        return (
459            TurnPlan::Complete {
460                stop_reason: TurnStopReason::EndTurn,
461                error: None,
462            },
463            Vec::new(),
464        );
465    }
466
467    let should_pause_for_tool_results =
468        outcome.waiting_for_tool_results && setup_connection_hint_enabled;
469
470    let next = TurnState {
471        iteration: state.iteration.saturating_add(1),
472        ..state.clone()
473    };
474
475    if should_pause_for_tool_results {
476        return (
477            TurnPlan::WaitForToolResults { resume: next },
478            vec![TurnLifecycleEffect::WaitingForToolResults],
479        );
480    }
481
482    if outcome.waiting_for_tool_results {
483        info!(
484            session_id = %state.session_id,
485            "setup_connection hint absent, continuing turn instead of pausing"
486        );
487    }
488
489    (TurnPlan::ScheduleReason(next), Vec::new())
490}