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    /// The pause is a URL mode elicitation consent card, which only a client
143    /// that declared `url_elicitation` can answer.
144    pub waiting_for_url_elicitation: bool,
145}
146
147/// Session facts the host pre-resolves for the reason→act scheduling case.
148///
149/// The host fetches these (from its session store) only when
150/// [`reason_schedules_act`] is true, mirroring the original conditional fetch:
151/// `blueprint_id` scopes blueprint tool resolution, and `workspace_id` points
152/// tool file I/O at the (possibly shared) workspace rather than the session's
153/// own keyspace.
154#[derive(Debug, Clone, Default)]
155pub struct ActSchedulingFacts {
156    pub blueprint_id: Option<String>,
157    pub workspace_id: Option<WorkspaceId>,
158}
159
160/// Typed, parsed activity output the engine plans the next step from.
161///
162/// The host parses the raw serialized activity output into this before calling
163/// [`plan_next_turn`]; unknown activity kinds are rejected by the host, so the
164/// engine stays total.
165pub enum ActivityOutcome {
166    ProcessInput { turn_id: Option<TurnId> },
167    // Boxed: `ReasonResult` dwarfs the other variants (clippy::large_enum_variant).
168    Reason(Box<ReasonResult>),
169    Act(ActOutcome),
170}
171
172/// Host-resolved facts the engine needs but cannot fetch itself.
173///
174/// The host populates only the field relevant to the completed activity, doing
175/// I/O in exactly the same conditions as the original planner:
176/// `act_scheduling` only when [`reason_schedules_act`] is true, and
177/// `setup_connection_hint_enabled` only when the act paused for tool results.
178#[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(1);
204        next.tool_call_count = next
205            .tool_call_count
206            .saturating_add(reason_result.tool_calls.len() as u32);
207        if let Some(usage) = &reason_result.usage {
208            add_usage(&mut next.cumulative_usage, usage);
209        }
210        if next.time_to_first_token_ms.is_none() {
211            next.time_to_first_token_ms = reason_result.time_to_first_token_ms;
212        }
213        next.final_message_id = reason_result.output_message_id;
214        next.final_answer_preview = preview_final_answer(&reason_result.text);
215        next
216    }
217
218    /// Wall-clock duration since `started_at`, measured against the host-supplied
219    /// `now` so the calculation stays deterministic.
220    fn duration_ms(&self, now: DateTime<Utc>) -> Option<u64> {
221        self.started_at
222            .map(|started_at| now.signed_duration_since(started_at))
223            .and_then(|duration| u64::try_from(duration.num_milliseconds()).ok())
224    }
225}
226
227fn classify_reason_failure(reason_result: &ReasonResult) -> UserFacingError {
228    // The reason atom already classified and disclosure-filtered the failure.
229    // Reuse it so the turn.failed event matches what the session message
230    // showed; re-classifying strings here could leak past a generic mode.
231    if let Some(user_error) = &reason_result.user_facing_error {
232        return user_error.clone();
233    }
234
235    let from_text =
236        classify_runtime_error_message(&reason_result.text, &UserFacingErrorContext::default());
237
238    let Some(error) = reason_result.error.as_deref() else {
239        return from_text;
240    };
241
242    let from_error = classify_runtime_error_message(error, &UserFacingErrorContext::default());
243
244    if from_error.code == user_facing_error_codes::PROCESSING_ERROR {
245        return from_text;
246    }
247
248    if from_error.code == from_text.code
249        && from_error.fields.is_empty()
250        && !from_text.fields.is_empty()
251    {
252        return from_text;
253    }
254
255    from_error
256}
257
258/// Does this reason outcome schedule an act phase?
259///
260/// The host consults this predicate to decide whether to resolve
261/// [`ActSchedulingFacts`] (a session fetch) before calling [`plan_after_reason`]
262/// — the same condition under which the original planner fetched the session.
263/// The reason planner branches on this same function, so the rule has exactly
264/// one definition.
265pub fn reason_schedules_act(state: &TurnState, reason_result: &ReasonResult) -> bool {
266    let max_turn_requests_reached = state.iteration >= reason_result.max_iterations as u32;
267    reason_result.has_tool_calls && reason_result.success && !max_turn_requests_reached
268}
269
270/// Plan the next host step after an activity finishes.
271///
272/// The authoritative, deterministic turn-planning entry point. Given the carried
273/// [`TurnState`], the parsed [`ActivityOutcome`], the count of queued steering
274/// messages, the host-supplied `now`, and any [`HostFacts`] the host pre-resolved,
275/// it returns the [`TurnPlan`] together with the [`TurnLifecycleEffect`]s the
276/// host must perform (in order). It performs no I/O of its own.
277pub fn plan_next_turn(
278    state: &TurnState,
279    outcome: ActivityOutcome,
280    pending_user_message_count: usize,
281    now: DateTime<Utc>,
282    facts: HostFacts,
283) -> (TurnPlan, Vec<TurnLifecycleEffect>) {
284    match outcome {
285        ActivityOutcome::ProcessInput { turn_id } => {
286            (plan_after_process_input(state, turn_id, now), Vec::new())
287        }
288        ActivityOutcome::Reason(reason_result) => plan_after_reason(
289            state,
290            *reason_result,
291            pending_user_message_count,
292            now,
293            facts.act_scheduling,
294        ),
295        ActivityOutcome::Act(outcome) => plan_after_act(
296            state,
297            outcome,
298            facts.setup_connection_hint_enabled,
299            facts.url_elicitation_hint_enabled,
300        ),
301    }
302}
303
304/// Plan the reason step that follows a completed `process_input` activity.
305pub fn plan_after_process_input(
306    state: &TurnState,
307    turn_id: Option<TurnId>,
308    now: DateTime<Utc>,
309) -> TurnPlan {
310    let next = TurnState {
311        turn_id,
312        previous_response_id: None,
313        iteration: 1,
314        started_at: state.started_at.or(Some(now)),
315        ..state.clone()
316    };
317    debug!(session_id = %state.session_id, turn_id = ?turn_id, "planned reason step");
318    TurnPlan::ScheduleReason(next)
319}
320
321/// Plan the next step after a `reason` activity finishes.
322///
323/// When [`reason_schedules_act`] holds, `act_scheduling` supplies the session
324/// facts the host resolved for the act phase; it is ignored otherwise. A
325/// terminal reason outcome returns the lifecycle effects the host must perform;
326/// the continuing outcomes return an empty effect list.
327pub fn plan_after_reason(
328    state: &TurnState,
329    reason_result: ReasonResult,
330    pending_user_message_count: usize,
331    now: DateTime<Utc>,
332    act_scheduling: Option<ActSchedulingFacts>,
333) -> (TurnPlan, Vec<TurnLifecycleEffect>) {
334    let response_id = reason_result.response_id.clone();
335    let summarized_state = state.with_reason_summary(&reason_result);
336    let max_turn_requests_reached = state.iteration >= reason_result.max_iterations as u32;
337
338    if reason_schedules_act(state, &reason_result) {
339        let facts = act_scheduling.unwrap_or_default();
340        let plan = ActPlan {
341            input: ActInput {
342                org_id: Some(state.org_id),
343                context: ExecutionContext {
344                    session_id: state.session_id,
345                    turn_id: state.turn_id.unwrap_or_default(),
346                    input_message_id: state.input_message_id,
347                    exec_id: ExecId::new(),
348                    workspace_id: facts.workspace_id,
349                },
350                harness_id: state.harness_id,
351                agent_id: state.agent_id,
352                tool_calls: reason_result.tool_calls,
353                tool_definitions: reason_result.tool_definitions,
354                locale: reason_result.locale,
355                blueprint_id: facts.blueprint_id,
356                network_access: reason_result.network_access,
357                // Request-level parallel tool calling preference, carried
358                // from agent config through the reason path (EVE-598).
359                parallel_tool_calls: reason_result.parallel_tool_calls,
360            },
361            previous_response_id: response_id,
362            iteration: state.iteration,
363            request_id: state.request_id.clone(),
364            resume_state: Box::new(summarized_state),
365        };
366        return (TurnPlan::ScheduleAct(plan), Vec::new());
367    }
368
369    if reason_result.success && pending_user_message_count > 0 && !max_turn_requests_reached {
370        if pending_user_message_count > 1 {
371            info!(
372                session_id = %state.session_id,
373                pending_user_message_count,
374                "multiple steering messages arrived during turn"
375            );
376        }
377
378        let next = TurnState {
379            previous_response_id: response_id,
380            iteration: state.iteration.saturating_add(1),
381            ..summarized_state
382        };
383        return (TurnPlan::ScheduleReason(next), Vec::new());
384    }
385
386    let turn_id = state.turn_id.unwrap_or_default();
387    let mut effects = Vec::new();
388
389    if reason_result.success {
390        effects.push(TurnLifecycleEffect::TurnCompleted {
391            input_message_id: state.input_message_id,
392            data: TurnCompletedData {
393                turn_id,
394                iterations: state.iteration,
395                duration_ms: summarized_state.duration_ms(now),
396                usage: summarized_state.cumulative_usage.clone(),
397                input_content: None,
398                final_message_id: summarized_state.final_message_id,
399                final_answer_preview: summarized_state.final_answer_preview.clone(),
400                time_to_first_token_ms: summarized_state.time_to_first_token_ms,
401                tool_call_count: Some(summarized_state.tool_call_count),
402                llm_call_count: Some(summarized_state.llm_call_count),
403                status: Some("completed".to_string()),
404            },
405        });
406        effects.push(TurnLifecycleEffect::SessionIdled {
407            turn_id,
408            input_message_id: state.input_message_id,
409            iterations: Some(state.iteration),
410            usage: summarized_state.cumulative_usage.clone(),
411        });
412    } else {
413        let user_error = classify_reason_failure(&reason_result);
414        effects.push(TurnLifecycleEffect::TurnFailedWithDisclosure {
415            turn_id,
416            input_message_id: state.input_message_id,
417            text: reason_result.text.clone(),
418            user_error: Some(user_error),
419            disclosure: reason_result.error_disclosure,
420        });
421    }
422
423    // turn_end lifecycle hooks (advisory). Fired once the turn reaches a
424    // terminal reason outcome on the durable/strategy path.
425    effects.push(TurnLifecycleEffect::FireTurnEndHooks {
426        harness_id: state.harness_id,
427        agent_id: state.agent_id,
428        turn_id,
429        success: reason_result.success,
430    });
431
432    let stop_reason = if !reason_result.success {
433        match TurnStopReason::from_provider_finish_reason(reason_result.finish_reason.as_deref()) {
434            TurnStopReason::Refusal => TurnStopReason::Refusal,
435            _ => TurnStopReason::Error,
436        }
437    } else if max_turn_requests_reached
438        && (reason_result.has_tool_calls || pending_user_message_count > 0)
439    {
440        TurnStopReason::MaxTurnRequests
441    } else {
442        TurnStopReason::from_provider_finish_reason(reason_result.finish_reason.as_deref())
443    };
444
445    (
446        TurnPlan::Complete {
447            stop_reason,
448            error: reason_result.error,
449        },
450        effects,
451    )
452}
453
454/// Plan the next step after an `act` activity finishes.
455///
456/// `setup_connection_hint_enabled` and `url_elicitation_hint_enabled` are the
457/// resolved session hints; the host reads them only when the act reported
458/// `waiting_for_tool_results`, so passing `false` otherwise matches the original
459/// short-circuit exactly.
460pub fn plan_after_act(
461    state: &TurnState,
462    outcome: ActOutcome,
463    setup_connection_hint_enabled: bool,
464    url_elicitation_hint_enabled: bool,
465) -> (TurnPlan, Vec<TurnLifecycleEffect>) {
466    if outcome.blocked {
467        return (
468            TurnPlan::Complete {
469                stop_reason: TurnStopReason::EndTurn,
470                error: None,
471            },
472            Vec::new(),
473        );
474    }
475
476    // A pause is only useful if the client on the other end can answer it. A
477    // URL elicitation waits on a consent card, so it needs a client that
478    // declared it renders one; everything else rides the `setup_connection`
479    // hint as before. Without the matching hint the turn continues and the
480    // elicitation reaches the user as an ordinary tool result instead.
481    let should_pause_for_tool_results = outcome.waiting_for_tool_results
482        && (setup_connection_hint_enabled
483            || (outcome.waiting_for_url_elicitation && url_elicitation_hint_enabled));
484
485    let next = TurnState {
486        iteration: state.iteration.saturating_add(1),
487        ..state.clone()
488    };
489
490    if should_pause_for_tool_results {
491        return (
492            TurnPlan::WaitForToolResults { resume: next },
493            vec![TurnLifecycleEffect::WaitingForToolResults],
494        );
495    }
496
497    if outcome.waiting_for_tool_results {
498        info!(
499            session_id = %state.session_id,
500            waiting_for_url_elicitation = outcome.waiting_for_url_elicitation,
501            "no hint declares this client can answer the pause, continuing turn instead"
502        );
503    }
504
505    (TurnPlan::ScheduleReason(next), Vec::new())
506}