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