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    /// Answer an `ask_user` call the client structurally cannot be asked.
111    ///
112    /// Emitted instead of parking when the session never declared the
113    /// `ask_user` hint — a scheduled run, a trigger, an SDK caller. The host
114    /// completes each call with the declared defaults and
115    /// `answered_by: "unattended"`, and the turn carries on in the same
116    /// iteration (EVE-1057).
117    ResolveAskUserUnattended {
118        /// The turn that asked, so the completion is filed against it rather
119        /// than floating free in the session.
120        turn_id: Option<TurnId>,
121        input_message_id: MessageId,
122        /// The `ask_user` calls to answer, as `(tool_call_id, arguments)`.
123        calls: Vec<(String, serde_json::Value)>,
124    },
125    /// Idle the session and emit `session.idled`.
126    SessionIdled {
127        turn_id: TurnId,
128        input_message_id: MessageId,
129        iterations: Option<u32>,
130        usage: Option<TokenUsage>,
131    },
132    /// Fail the turn with the already-disclosure-filtered error, emitting
133    /// `turn.failed` + `session.idled`.
134    TurnFailedWithDisclosure {
135        turn_id: TurnId,
136        input_message_id: MessageId,
137        text: String,
138        user_error: Option<UserFacingError>,
139        disclosure: Option<ErrorDisclosure>,
140    },
141    /// Fire the advisory `turn_end` lifecycle hooks.
142    FireTurnEndHooks {
143        harness_id: HarnessId,
144        agent_id: Option<AgentId>,
145        turn_id: TurnId,
146        success: bool,
147    },
148    /// Mark the session `waiting_for_tool_results`.
149    WaitingForToolResults,
150}
151
152/// Parsed `act` activity output the planner decides over.
153#[derive(Debug, Clone, Copy, Default)]
154pub struct ActOutcome {
155    pub blocked: bool,
156    pub waiting_for_tool_results: bool,
157    /// The pause is a URL mode elicitation consent card, which only a client
158    /// that declared `url_elicitation` can answer.
159    pub waiting_for_url_elicitation: bool,
160    /// The pause is an `ask_user` question set, which only a client that
161    /// declared `ask_user` can answer (EVE-1057).
162    pub waiting_for_ask_user: bool,
163}
164
165/// Session facts the host pre-resolves for the reason→act scheduling case.
166///
167/// The host fetches these (from its session store) only when
168/// [`reason_schedules_act`] is true, mirroring the original conditional fetch:
169/// `blueprint_id` scopes blueprint tool resolution, and `workspace_id` points
170/// tool file I/O at the (possibly shared) workspace rather than the session's
171/// own keyspace.
172#[derive(Debug, Clone, Default)]
173pub struct ActSchedulingFacts {
174    pub blueprint_id: Option<String>,
175    pub workspace_id: Option<WorkspaceId>,
176}
177
178/// Typed, parsed activity output the engine plans the next step from.
179///
180/// The host parses the raw serialized activity output into this before calling
181/// [`plan_next_turn`]; unknown activity kinds are rejected by the host, so the
182/// engine stays total.
183pub enum ActivityOutcome {
184    ProcessInput { turn_id: Option<TurnId> },
185    // Boxed: `ReasonResult` dwarfs the other variants (clippy::large_enum_variant).
186    Reason(Box<ReasonResult>),
187    Act(ActOutcome),
188}
189
190/// Host-resolved facts the engine needs but cannot fetch itself.
191///
192/// The host populates only the field relevant to the completed activity, doing
193/// I/O in exactly the same conditions as the original planner:
194/// `act_scheduling` only when [`reason_schedules_act`] is true, and
195/// `setup_connection_hint_enabled` only when the act paused for tool results.
196#[derive(Debug, Clone, Default)]
197pub struct HostFacts {
198    pub act_scheduling: Option<ActSchedulingFacts>,
199    pub setup_connection_hint_enabled: bool,
200    pub url_elicitation_hint_enabled: bool,
201    pub ask_user_hint_enabled: bool,
202    /// The `ask_user` calls the act left pending, as `(tool_call_id,
203    /// arguments)`. Carried here rather than on [`ActOutcome`], which is `Copy`
204    /// and cannot hold them. Empty unless the act paused on `ask_user`.
205    pub ask_user_calls: Vec<(String, serde_json::Value)>,
206}
207
208fn preview_final_answer(text: &str) -> Option<String> {
209    if text.is_empty() {
210        return None;
211    }
212
213    Some(text.chars().take(2000).collect())
214}
215
216fn add_usage(current: &mut Option<TokenUsage>, next: &TokenUsage) {
217    match current {
218        Some(current) => current.add(next),
219        None => *current = Some(next.clone()),
220    }
221}
222
223impl TurnState {
224    pub(crate) fn with_reason_summary(&self, reason_result: &ReasonResult) -> Self {
225        let mut next = self.clone();
226        next.llm_call_count = next.llm_call_count.saturating_add(
227            reason_result
228                .native_counts
229                .as_ref()
230                .map_or(1, |counts| counts.llm_calls),
231        );
232        next.tool_call_count = next.tool_call_count.saturating_add(
233            reason_result
234                .native_counts
235                .as_ref()
236                .map_or(reason_result.tool_calls.len() as u32, |counts| {
237                    counts.tool_calls
238                }),
239        );
240        if let Some(usage) = &reason_result.usage {
241            add_usage(&mut next.cumulative_usage, usage);
242        }
243        if next.time_to_first_token_ms.is_none() {
244            next.time_to_first_token_ms = reason_result.time_to_first_token_ms;
245        }
246        next.final_message_id = reason_result.output_message_id;
247        next.final_answer_preview = preview_final_answer(&reason_result.text);
248        next
249    }
250
251    /// Wall-clock duration since `started_at`, measured against the host-supplied
252    /// `now` so the calculation stays deterministic.
253    fn duration_ms(&self, now: DateTime<Utc>) -> Option<u64> {
254        self.started_at
255            .map(|started_at| now.signed_duration_since(started_at))
256            .and_then(|duration| u64::try_from(duration.num_milliseconds()).ok())
257    }
258}
259
260fn classify_reason_failure(reason_result: &ReasonResult) -> UserFacingError {
261    // The reason atom already classified and disclosure-filtered the failure.
262    // Reuse it so the turn.failed event matches what the session message
263    // showed; re-classifying strings here could leak past a generic mode.
264    if let Some(user_error) = &reason_result.user_facing_error {
265        return user_error.clone();
266    }
267
268    let from_text =
269        classify_runtime_error_message(&reason_result.text, &UserFacingErrorContext::default());
270
271    let Some(error) = reason_result.error.as_deref() else {
272        return from_text;
273    };
274
275    let from_error = classify_runtime_error_message(error, &UserFacingErrorContext::default());
276
277    if from_error.code == user_facing_error_codes::PROCESSING_ERROR {
278        return from_text;
279    }
280
281    if from_error.code == from_text.code
282        && from_error.fields.is_empty()
283        && !from_text.fields.is_empty()
284    {
285        return from_text;
286    }
287
288    from_error
289}
290
291/// Does this reason outcome schedule an act phase?
292///
293/// The host consults this predicate to decide whether to resolve
294/// [`ActSchedulingFacts`] (a session fetch) before calling [`plan_after_reason`]
295/// — the same condition under which the original planner fetched the session.
296/// The reason planner branches on this same function, so the rule has exactly
297/// one definition.
298pub fn reason_schedules_act(state: &TurnState, reason_result: &ReasonResult) -> bool {
299    let max_turn_requests_reached = state.iteration >= reason_result.max_iterations as u32;
300    reason_result.has_tool_calls && reason_result.success && !max_turn_requests_reached
301}
302
303/// Plan the next host step after an activity finishes.
304///
305/// The authoritative, deterministic turn-planning entry point. Given the carried
306/// [`TurnState`], the parsed [`ActivityOutcome`], the count of queued steering
307/// messages, the host-supplied `now`, and any [`HostFacts`] the host pre-resolved,
308/// it returns the [`TurnPlan`] together with the [`TurnLifecycleEffect`]s the
309/// host must perform (in order). It performs no I/O of its own.
310pub fn plan_next_turn(
311    state: &TurnState,
312    outcome: ActivityOutcome,
313    pending_user_message_count: usize,
314    now: DateTime<Utc>,
315    facts: HostFacts,
316) -> (TurnPlan, Vec<TurnLifecycleEffect>) {
317    match outcome {
318        ActivityOutcome::ProcessInput { turn_id } => {
319            (plan_after_process_input(state, turn_id, now), Vec::new())
320        }
321        ActivityOutcome::Reason(reason_result) => plan_after_reason(
322            state,
323            *reason_result,
324            pending_user_message_count,
325            now,
326            facts.act_scheduling,
327        ),
328        ActivityOutcome::Act(outcome) => plan_after_act(
329            state,
330            outcome,
331            facts.setup_connection_hint_enabled,
332            facts.url_elicitation_hint_enabled,
333            facts.ask_user_hint_enabled,
334            facts.ask_user_calls.clone(),
335        ),
336    }
337}
338
339/// Plan the reason step that follows a completed `process_input` activity.
340pub fn plan_after_process_input(
341    state: &TurnState,
342    turn_id: Option<TurnId>,
343    now: DateTime<Utc>,
344) -> TurnPlan {
345    let next = TurnState {
346        turn_id,
347        previous_response_id: None,
348        iteration: 1,
349        started_at: state.started_at.or(Some(now)),
350        ..state.clone()
351    };
352    debug!(session_id = %state.session_id, turn_id = ?turn_id, "planned reason step");
353    TurnPlan::ScheduleReason(next)
354}
355
356/// Plan the next step after a `reason` activity finishes.
357///
358/// When [`reason_schedules_act`] holds, `act_scheduling` supplies the session
359/// facts the host resolved for the act phase; it is ignored otherwise. A
360/// terminal reason outcome returns the lifecycle effects the host must perform;
361/// the continuing outcomes return an empty effect list.
362pub fn plan_after_reason(
363    state: &TurnState,
364    reason_result: ReasonResult,
365    pending_user_message_count: usize,
366    now: DateTime<Utc>,
367    act_scheduling: Option<ActSchedulingFacts>,
368) -> (TurnPlan, Vec<TurnLifecycleEffect>) {
369    let response_id = reason_result.response_id.clone();
370    let summarized_state = state.with_reason_summary(&reason_result);
371    let max_turn_requests_reached = state.iteration >= reason_result.max_iterations as u32;
372
373    if reason_schedules_act(state, &reason_result) {
374        let facts = act_scheduling.unwrap_or_default();
375        let plan = ActPlan {
376            input: ActInput {
377                org_id: Some(state.org_id),
378                context: ExecutionContext {
379                    session_id: state.session_id,
380                    turn_id: state.turn_id.unwrap_or_default(),
381                    input_message_id: state.input_message_id,
382                    exec_id: ExecId::new(),
383                    workspace_id: facts.workspace_id,
384                },
385                harness_id: state.harness_id,
386                agent_id: state.agent_id,
387                tool_calls: reason_result.tool_calls,
388                tool_definitions: reason_result.tool_definitions,
389                locale: reason_result.locale,
390                blueprint_id: facts.blueprint_id,
391                network_access: reason_result.network_access,
392                // Request-level parallel tool calling preference, carried
393                // from agent config through the reason path (EVE-598).
394                parallel_tool_calls: reason_result.parallel_tool_calls,
395            },
396            previous_response_id: response_id,
397            iteration: state.iteration,
398            request_id: state.request_id.clone(),
399            resume_state: Box::new(summarized_state),
400        };
401        return (TurnPlan::ScheduleAct(plan), Vec::new());
402    }
403
404    if reason_result.success && pending_user_message_count > 0 && !max_turn_requests_reached {
405        if pending_user_message_count > 1 {
406            info!(
407                session_id = %state.session_id,
408                pending_user_message_count,
409                "multiple steering messages arrived during turn"
410            );
411        }
412
413        let next = TurnState {
414            previous_response_id: response_id,
415            iteration: state.iteration.saturating_add(1),
416            ..summarized_state
417        };
418        return (TurnPlan::ScheduleReason(next), Vec::new());
419    }
420
421    let turn_id = state.turn_id.unwrap_or_default();
422    let mut effects = Vec::new();
423
424    if reason_result.success {
425        effects.push(TurnLifecycleEffect::TurnCompleted {
426            input_message_id: state.input_message_id,
427            data: TurnCompletedData {
428                turn_id,
429                iterations: state.iteration,
430                duration_ms: summarized_state.duration_ms(now),
431                usage: summarized_state.cumulative_usage.clone(),
432                input_content: None,
433                final_message_id: summarized_state.final_message_id,
434                final_answer_preview: summarized_state.final_answer_preview.clone(),
435                time_to_first_token_ms: summarized_state.time_to_first_token_ms,
436                tool_call_count: Some(summarized_state.tool_call_count),
437                llm_call_count: Some(summarized_state.llm_call_count),
438                status: Some("completed".to_string()),
439            },
440        });
441        effects.push(TurnLifecycleEffect::SessionIdled {
442            turn_id,
443            input_message_id: state.input_message_id,
444            iterations: Some(state.iteration),
445            usage: summarized_state.cumulative_usage.clone(),
446        });
447    } else {
448        let user_error = classify_reason_failure(&reason_result);
449        effects.push(TurnLifecycleEffect::TurnFailedWithDisclosure {
450            turn_id,
451            input_message_id: state.input_message_id,
452            text: reason_result.text.clone(),
453            user_error: Some(user_error),
454            disclosure: reason_result.error_disclosure,
455        });
456    }
457
458    // turn_end lifecycle hooks (advisory). Fired once the turn reaches a
459    // terminal reason outcome on the durable/strategy path.
460    effects.push(TurnLifecycleEffect::FireTurnEndHooks {
461        harness_id: state.harness_id,
462        agent_id: state.agent_id,
463        turn_id,
464        success: reason_result.success,
465    });
466
467    let stop_reason = if !reason_result.success {
468        match TurnStopReason::from_provider_finish_reason(reason_result.finish_reason.as_deref()) {
469            TurnStopReason::Refusal => TurnStopReason::Refusal,
470            _ => TurnStopReason::Error,
471        }
472    } else if max_turn_requests_reached
473        && (reason_result.has_tool_calls || pending_user_message_count > 0)
474    {
475        TurnStopReason::MaxTurnRequests
476    } else {
477        TurnStopReason::from_provider_finish_reason(reason_result.finish_reason.as_deref())
478    };
479
480    (
481        TurnPlan::Complete {
482            stop_reason,
483            error: reason_result.error,
484        },
485        effects,
486    )
487}
488
489/// Plan the next step after an `act` activity finishes.
490///
491/// `setup_connection_hint_enabled` and `url_elicitation_hint_enabled` are the
492/// resolved session hints; the host reads them only when the act reported
493/// `waiting_for_tool_results`, so passing `false` otherwise matches the original
494/// short-circuit exactly.
495pub fn plan_after_act(
496    state: &TurnState,
497    outcome: ActOutcome,
498    setup_connection_hint_enabled: bool,
499    url_elicitation_hint_enabled: bool,
500    ask_user_hint_enabled: bool,
501    ask_user_calls: Vec<(String, serde_json::Value)>,
502) -> (TurnPlan, Vec<TurnLifecycleEffect>) {
503    if outcome.blocked {
504        return (
505            TurnPlan::Complete {
506                stop_reason: TurnStopReason::EndTurn,
507                error: None,
508            },
509            Vec::new(),
510        );
511    }
512
513    // A pause is only useful if the client on the other end can answer it. A
514    // URL elicitation waits on a consent card, so it needs a client that
515    // declared it renders one; everything else rides the `setup_connection`
516    // hint as before. Without the matching hint the turn continues and the
517    // elicitation reaches the user as an ordinary tool result instead.
518    // An `ask_user` pause needs its own hint for the same reason: a question
519    // nobody can render is not worth holding a turn for (EVE-1057). It does not
520    // ride `setup_connection`, because a client can be able to finish a
521    // connection setup and still have no way to draw a question.
522    let should_pause_for_tool_results = outcome.waiting_for_tool_results
523        && if outcome.waiting_for_ask_user {
524            ask_user_hint_enabled
525        } else {
526            setup_connection_hint_enabled
527                || (outcome.waiting_for_url_elicitation && url_elicitation_hint_enabled)
528        };
529
530    let next = TurnState {
531        iteration: state.iteration.saturating_add(1),
532        ..state.clone()
533    };
534
535    if should_pause_for_tool_results {
536        return (
537            TurnPlan::WaitForToolResults { resume: next },
538            vec![TurnLifecycleEffect::WaitingForToolResults],
539        );
540    }
541
542    if outcome.waiting_for_tool_results {
543        info!(
544            session_id = %state.session_id,
545            waiting_for_url_elicitation = outcome.waiting_for_url_elicitation,
546            waiting_for_ask_user = outcome.waiting_for_ask_user,
547            "no hint declares this client can answer the pause, continuing turn instead"
548        );
549    }
550
551    // A URL elicitation continuing unanswered reaches the model as an ordinary
552    // tool result, which reads correctly. An `ask_user` call would instead sit
553    // in the transcript with nothing answering it, so the defaults are applied
554    // here and the model is told plainly that nobody could be asked (EVE-1057).
555    let effects = if outcome.waiting_for_ask_user && !ask_user_calls.is_empty() {
556        vec![TurnLifecycleEffect::ResolveAskUserUnattended {
557            turn_id: state.turn_id,
558            input_message_id: state.input_message_id,
559            calls: ask_user_calls,
560        }]
561    } else {
562        Vec::new()
563    };
564
565    (TurnPlan::ScheduleReason(next), effects)
566}