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, extracted verbatim from
4// `everruns-runtime`'s `plan_next_host_turn`. Every function here is pure and
5// deterministic: it reads only its arguments and returns a `TurnPlan` (plus, for
6// terminal outcomes, a list of `TurnLifecycleEffect`s the host must perform). It
7// never touches a store, socket, process, event bus, or `Utc::now()` — the host
8// resolves those facts, passes `now` in, and performs the returned effects.
9
10use 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/// Host-owned state carried across turn phases.
25///
26/// Durable hosts can persist this between activities; in-memory hosts can hold
27/// it directly in memory. The type itself is engine-level and has no host,
28/// store, or durable-engine coupling.
29///
30/// Hosts are expected to serialize this however they want. `everruns-engine`
31/// only defines the fields required to resume the next semantic step.
32#[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/// Engine-owned act scheduling payload.
68///
69/// Hosts enqueue or execute this immediately using their own worker model.
70#[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/// Generic next-step decision for a host turn.
80///
81/// This intentionally stops at the semantic boundary:
82/// - the engine decides what should happen next
83/// - the host decides how to persist, enqueue, retry, or resume it
84#[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/// A lifecycle side effect the engine decided must be recorded, described as
98/// data rather than performed.
99///
100/// The engine never emits events or fires hooks — it *returns* these, and the
101/// host applies them (in list order) through its own lifecycle machinery. This
102/// keeps planning deterministic while preserving the exact event stream. These
103/// are NOT part of the public [`TurnPlan`]; they travel alongside it.
104#[derive(Debug, Clone)]
105pub enum TurnLifecycleEffect {
106    /// Emit `turn.completed` with the summarized turn fields.
107    TurnCompleted {
108        input_message_id: MessageId,
109        data: TurnCompletedData,
110    },
111    /// Idle the session and emit `session.idled`.
112    SessionIdled {
113        turn_id: TurnId,
114        input_message_id: MessageId,
115        iterations: Option<u32>,
116        usage: Option<TokenUsage>,
117    },
118    /// Fail the turn with the already-disclosure-filtered error, emitting
119    /// `turn.failed` + `session.idled`.
120    TurnFailedWithDisclosure {
121        turn_id: TurnId,
122        input_message_id: MessageId,
123        text: String,
124        user_error: Option<UserFacingError>,
125        disclosure: Option<ErrorDisclosure>,
126    },
127    /// Fire the advisory `turn_end` lifecycle hooks.
128    FireTurnEndHooks {
129        harness_id: HarnessId,
130        agent_id: Option<AgentId>,
131        turn_id: TurnId,
132        success: bool,
133    },
134    /// Mark the session `waiting_for_tool_results`.
135    WaitingForToolResults,
136}
137
138/// Parsed `act` activity output the planner decides over.
139#[derive(Debug, Clone, Copy, Default)]
140pub struct ActOutcome {
141    pub blocked: bool,
142    pub waiting_for_tool_results: bool,
143}
144
145/// Session facts the host pre-resolves for the reason→act scheduling case.
146///
147/// The host fetches these (from its session store) only when
148/// [`reason_schedules_act`] is true, mirroring the original conditional fetch:
149/// `blueprint_id` scopes blueprint tool resolution, and `workspace_id` points
150/// tool file I/O at the (possibly shared) workspace rather than the session's
151/// own keyspace.
152#[derive(Debug, Clone, Default)]
153pub struct ActSchedulingFacts {
154    pub blueprint_id: Option<String>,
155    pub workspace_id: Option<WorkspaceId>,
156}
157
158/// Typed, parsed activity output the engine plans the next step from.
159///
160/// The host parses the raw serialized activity output into this before calling
161/// [`plan_next_turn`]; unknown activity kinds are rejected by the host, so the
162/// engine stays total.
163pub enum ActivityOutcome {
164    ProcessInput { turn_id: Option<TurnId> },
165    // Boxed: `ReasonResult` dwarfs the other variants (clippy::large_enum_variant).
166    Reason(Box<ReasonResult>),
167    Act(ActOutcome),
168}
169
170/// Host-resolved facts the engine needs but cannot fetch itself.
171///
172/// The host populates only the field relevant to the completed activity, doing
173/// I/O in exactly the same conditions as the original planner:
174/// `act_scheduling` only when [`reason_schedules_act`] is true, and
175/// `setup_connection_hint_enabled` only when the act paused for tool results.
176#[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    /// Wall-clock duration since `started_at`, measured against the host-supplied
216    /// `now` so the calculation stays deterministic.
217    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    // The reason atom already classified and disclosure-filtered the failure.
226    // Reuse it so the turn.failed event matches what the session message
227    // showed; re-classifying strings here could leak past a generic mode.
228    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
255/// Does this reason outcome schedule an act phase?
256///
257/// The host consults this predicate to decide whether to resolve
258/// [`ActSchedulingFacts`] (a session fetch) before calling [`plan_after_reason`]
259/// — the same condition under which the original planner fetched the session.
260/// The reason planner branches on this same function, so the rule has exactly
261/// one definition.
262pub 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
267/// Plan the next host step after an activity finishes.
268///
269/// The authoritative, deterministic turn-planning entry point. Given the carried
270/// [`TurnState`], the parsed [`ActivityOutcome`], the count of queued steering
271/// messages, the host-supplied `now`, and any [`HostFacts`] the host pre-resolved,
272/// it returns the [`TurnPlan`] together with the [`TurnLifecycleEffect`]s the
273/// host must perform (in order). It performs no I/O of its own.
274pub 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
298/// Plan the reason step that follows a completed `process_input` activity.
299pub 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
315/// Plan the next step after a `reason` activity finishes.
316///
317/// When [`reason_schedules_act`] holds, `act_scheduling` supplies the session
318/// facts the host resolved for the act phase; it is ignored otherwise. A
319/// terminal reason outcome returns the lifecycle effects the host must perform;
320/// the continuing outcomes return an empty effect list.
321pub 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                // Request-level parallel tool calling preference, carried
352                // from agent config through the reason path (EVE-598).
353                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    // turn_end lifecycle hooks (advisory). Fired once the turn reaches a
418    // terminal reason outcome on the durable/strategy path.
419    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
448/// Plan the next step after an `act` activity finishes.
449///
450/// `setup_connection_hint_enabled` is the resolved session hint; the host reads
451/// it only when the act reported `waiting_for_tool_results`, so passing `false`
452/// otherwise matches the original short-circuit exactly.
453pub 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}