Skip to main content

everruns_runtime/
turn_strategy.rs

1// Shared turn-strategy planning for embedded, durable, and custom hosts.
2// Decision: everruns-runtime stays durable-agnostic and returns generic next-step plans.
3
4use crate::{RuntimeHostAdapter, RuntimeSessionLifecycle};
5use chrono::{DateTime, Utc};
6use everruns_core::atoms::{ActInput, AtomContext};
7use everruns_core::error::{AgentLoopError, Result};
8use everruns_core::events::TokenUsage;
9use everruns_core::turn::TurnStopReason;
10use everruns_core::typed_id::{AgentId, ExecId, HarnessId, MessageId, SessionId, TurnId};
11use everruns_core::{
12    Controls, ReasonResult, UserFacingError, UserFacingErrorContext,
13    classify_runtime_error_message, user_facing_error_codes,
14};
15use serde::{Deserialize, Serialize};
16use tracing::{debug, info};
17
18/// Host-owned state carried across turn phases.
19///
20/// Durable hosts can persist this between activities; in-memory hosts can hold
21/// it directly in memory. The type itself is runtime-level and has no durable
22/// engine coupling.
23///
24/// Hosts are expected to serialize this however they want. `everruns-runtime`
25/// only defines the fields required to resume the next semantic step.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct RuntimeTurnState {
28    pub org_id: i64,
29    pub session_id: SessionId,
30    pub harness_id: HarnessId,
31    pub agent_id: Option<AgentId>,
32    pub input_message_id: MessageId,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub turn_id: Option<TurnId>,
35    #[serde(skip_serializing_if = "Option::is_none", default)]
36    pub previous_response_id: Option<String>,
37    #[serde(default = "default_iteration")]
38    pub iteration: u32,
39    #[serde(skip_serializing_if = "Option::is_none", default)]
40    pub request_id: Option<String>,
41    #[serde(skip_serializing_if = "Option::is_none", default)]
42    pub started_at: Option<DateTime<Utc>>,
43    #[serde(skip_serializing_if = "Option::is_none", default)]
44    pub cumulative_usage: Option<TokenUsage>,
45    #[serde(default)]
46    pub tool_call_count: u32,
47    #[serde(default)]
48    pub llm_call_count: u32,
49    #[serde(skip_serializing_if = "Option::is_none", default)]
50    pub time_to_first_token_ms: Option<u64>,
51    #[serde(skip_serializing_if = "Option::is_none", default)]
52    pub final_message_id: Option<MessageId>,
53    #[serde(skip_serializing_if = "Option::is_none", default)]
54    pub final_answer_preview: Option<String>,
55}
56
57fn default_iteration() -> u32 {
58    1
59}
60
61/// Runtime-owned act scheduling payload.
62///
63/// Hosts enqueue or execute this immediately using their own worker model.
64#[derive(Debug, Clone)]
65pub struct RuntimeActPlan {
66    pub input: ActInput,
67    pub previous_response_id: Option<String>,
68    pub iteration: u32,
69    pub request_id: Option<String>,
70    pub resume_state: Box<RuntimeTurnState>,
71}
72
73/// Generic next-step decision for a host turn.
74///
75/// This intentionally stops at the semantic boundary:
76/// - runtime decides what should happen next
77/// - the host decides how to persist, enqueue, retry, or resume it
78#[derive(Debug, Clone)]
79pub enum RuntimeTurnPlan {
80    ScheduleReason(RuntimeTurnState),
81    ScheduleAct(RuntimeActPlan),
82    Complete {
83        stop_reason: TurnStopReason,
84        error: Option<String>,
85    },
86    WaitForToolResults {
87        resume: RuntimeTurnState,
88    },
89}
90
91fn preview_final_answer(text: &str) -> Option<String> {
92    if text.is_empty() {
93        return None;
94    }
95
96    Some(text.chars().take(2000).collect())
97}
98
99fn add_usage(current: &mut Option<TokenUsage>, next: &TokenUsage) {
100    match current {
101        Some(current) => current.add(next),
102        None => *current = Some(next.clone()),
103    }
104}
105
106impl RuntimeTurnState {
107    fn with_reason_summary(&self, reason_result: &ReasonResult) -> Self {
108        let mut next = self.clone();
109        next.llm_call_count = next.llm_call_count.saturating_add(1);
110        next.tool_call_count = next
111            .tool_call_count
112            .saturating_add(reason_result.tool_calls.len() as u32);
113        if let Some(usage) = &reason_result.usage {
114            add_usage(&mut next.cumulative_usage, usage);
115        }
116        if next.time_to_first_token_ms.is_none() {
117            next.time_to_first_token_ms = reason_result.time_to_first_token_ms;
118        }
119        next.final_message_id = reason_result.output_message_id;
120        next.final_answer_preview = preview_final_answer(&reason_result.text);
121        next
122    }
123
124    fn duration_ms(&self) -> Option<u64> {
125        self.started_at
126            .map(|started_at| Utc::now().signed_duration_since(started_at))
127            .and_then(|duration| u64::try_from(duration.num_milliseconds()).ok())
128    }
129}
130
131fn classify_reason_failure(reason_result: &ReasonResult) -> UserFacingError {
132    // The reason atom already classified and disclosure-filtered the failure.
133    // Reuse it so the turn.failed event matches what the session message
134    // showed; re-classifying strings here could leak past a generic mode.
135    if let Some(user_error) = &reason_result.user_facing_error {
136        return user_error.clone();
137    }
138
139    let from_text =
140        classify_runtime_error_message(&reason_result.text, &UserFacingErrorContext::default());
141
142    let Some(error) = reason_result.error.as_deref() else {
143        return from_text;
144    };
145
146    let from_error = classify_runtime_error_message(error, &UserFacingErrorContext::default());
147
148    if from_error.code == user_facing_error_codes::PROCESSING_ERROR {
149        return from_text;
150    }
151
152    if from_error.code == from_text.code
153        && from_error.fields.is_empty()
154        && !from_text.fields.is_empty()
155    {
156        return from_text;
157    }
158
159    from_error
160}
161
162/// Determine the next host step after an activity finishes.
163///
164/// The host provides:
165/// - `completed_activity`: which phase just finished
166/// - `state`: host-carried turn state
167/// - `output`: serialized activity output
168/// - `pending_user_message_count`: number of queued steering messages already consumed by the host
169///
170/// Runtime owns the semantic decision. Hosts translate the returned plan into
171/// their own queueing / persistence model.
172///
173/// Typical host mapping:
174/// - `ScheduleReason` => enqueue or invoke a reason phase with the returned state
175/// - `ScheduleAct` => enqueue or invoke an act phase with the returned payload
176/// - `WaitForToolResults` => persist the resume state until external tool input arrives
177/// - `Complete` => mark the host-owned workflow/session turn complete
178pub async fn plan_next_host_turn<A: RuntimeHostAdapter>(
179    adapter: &A,
180    completed_activity: &str,
181    state: &RuntimeTurnState,
182    output: &serde_json::Value,
183    pending_user_message_count: usize,
184) -> Result<RuntimeTurnPlan> {
185    match completed_activity {
186        "process_input" => {
187            let turn_id: Option<TurnId> = output
188                .get("turn_id")
189                .and_then(|value| value.as_str())
190                .and_then(|value| value.parse().ok());
191            let next = RuntimeTurnState {
192                turn_id,
193                previous_response_id: None,
194                iteration: 1,
195                started_at: state.started_at.or_else(|| Some(Utc::now())),
196                ..state.clone()
197            };
198            debug!(session_id = %state.session_id, turn_id = ?turn_id, "planned reason step");
199            Ok(RuntimeTurnPlan::ScheduleReason(next))
200        }
201        "reason" => {
202            let reason_result: ReasonResult = serde_json::from_value(output.clone())
203                .map_err(|error| AgentLoopError::Internal(error.into()))?;
204            let response_id = reason_result.response_id.clone();
205            let summarized_state = state.with_reason_summary(&reason_result);
206            let max_turn_requests_reached = state.iteration >= reason_result.max_iterations as u32;
207
208            if reason_result.has_tool_calls && reason_result.success && !max_turn_requests_reached {
209                let session = adapter
210                    .session_store(state.org_id)
211                    .get_session(state.session_id)
212                    .await?;
213                let session_blueprint_id = session.as_ref().and_then(|s| s.blueprint_id.clone());
214                // Attach the session's workspace so tool file I/O addresses the
215                // (possibly shared) workspace, not the session's own keyspace.
216                let workspace_id = session.as_ref().map(|s| s.workspace_id);
217                let plan = RuntimeActPlan {
218                    input: ActInput {
219                        org_id: Some(state.org_id),
220                        context: AtomContext {
221                            session_id: state.session_id,
222                            turn_id: state.turn_id.unwrap_or_default(),
223                            input_message_id: state.input_message_id,
224                            exec_id: ExecId::new(),
225                            workspace_id,
226                        },
227                        harness_id: state.harness_id,
228                        agent_id: state.agent_id,
229                        tool_calls: reason_result.tool_calls,
230                        tool_definitions: reason_result.tool_definitions,
231                        locale: reason_result.locale,
232                        blueprint_id: session_blueprint_id,
233                        network_access: reason_result.network_access,
234                        // Request-level parallel tool calling preference, carried
235                        // from agent config through the reason path (EVE-598).
236                        parallel_tool_calls: reason_result.parallel_tool_calls,
237                    },
238                    previous_response_id: response_id,
239                    iteration: state.iteration,
240                    request_id: state.request_id.clone(),
241                    resume_state: Box::new(summarized_state),
242                };
243                return Ok(RuntimeTurnPlan::ScheduleAct(plan));
244            }
245
246            if reason_result.success && pending_user_message_count > 0 && !max_turn_requests_reached
247            {
248                if pending_user_message_count > 1 {
249                    info!(
250                        session_id = %state.session_id,
251                        pending_user_message_count,
252                        "multiple steering messages arrived during turn"
253                    );
254                }
255
256                let next = RuntimeTurnState {
257                    previous_response_id: response_id,
258                    iteration: state.iteration.saturating_add(1),
259                    ..summarized_state
260                };
261                return Ok(RuntimeTurnPlan::ScheduleReason(next));
262            }
263
264            let lifecycle =
265                RuntimeSessionLifecycle::new(adapter.clone(), state.org_id, state.session_id);
266            let turn_id = state.turn_id.unwrap_or_default();
267
268            if reason_result.success {
269                lifecycle
270                    .emit_turn_completed(
271                        state.input_message_id,
272                        everruns_core::events::TurnCompletedData {
273                            turn_id,
274                            iterations: state.iteration,
275                            duration_ms: summarized_state.duration_ms(),
276                            usage: summarized_state.cumulative_usage.clone(),
277                            input_content: None,
278                            final_message_id: summarized_state.final_message_id,
279                            final_answer_preview: summarized_state.final_answer_preview.clone(),
280                            time_to_first_token_ms: summarized_state.time_to_first_token_ms,
281                            tool_call_count: Some(summarized_state.tool_call_count),
282                            llm_call_count: Some(summarized_state.llm_call_count),
283                            status: Some("completed".to_string()),
284                        },
285                    )
286                    .await;
287                lifecycle
288                    .emit_session_idled(
289                        turn_id,
290                        state.input_message_id,
291                        Some(state.iteration),
292                        summarized_state.cumulative_usage.clone(),
293                    )
294                    .await;
295            } else {
296                let user_error = classify_reason_failure(&reason_result);
297                lifecycle
298                    .turn_failed_with_disclosure(
299                        turn_id,
300                        state.input_message_id,
301                        &reason_result.text,
302                        Some(&user_error),
303                        reason_result.error_disclosure,
304                    )
305                    .await;
306            }
307
308            // turn_end lifecycle hooks (advisory). Fired once the turn reaches a
309            // terminal reason outcome on the durable/strategy path.
310            lifecycle
311                .fire_turn_end_hooks(
312                    state.harness_id,
313                    state.agent_id,
314                    turn_id,
315                    reason_result.success,
316                )
317                .await;
318
319            let stop_reason = if !reason_result.success {
320                match TurnStopReason::from_provider_finish_reason(
321                    reason_result.finish_reason.as_deref(),
322                ) {
323                    TurnStopReason::Refusal => TurnStopReason::Refusal,
324                    _ => TurnStopReason::Error,
325                }
326            } else if max_turn_requests_reached
327                && (reason_result.has_tool_calls || pending_user_message_count > 0)
328            {
329                TurnStopReason::MaxTurnRequests
330            } else {
331                TurnStopReason::from_provider_finish_reason(reason_result.finish_reason.as_deref())
332            };
333
334            Ok(RuntimeTurnPlan::Complete {
335                stop_reason,
336                error: reason_result.error,
337            })
338        }
339        "act" => {
340            if output
341                .get("blocked")
342                .and_then(|value| value.as_bool())
343                .unwrap_or(false)
344            {
345                return Ok(RuntimeTurnPlan::Complete {
346                    stop_reason: TurnStopReason::EndTurn,
347                    error: None,
348                });
349            }
350
351            let waiting_for_tool_results = output
352                .get("waiting_for_tool_results")
353                .and_then(|value| value.as_bool())
354                .unwrap_or(false);
355            let should_pause_for_tool_results = waiting_for_tool_results
356                && setup_connection_hint_enabled(adapter, state.org_id, state.session_id).await;
357
358            let next = RuntimeTurnState {
359                iteration: state.iteration.saturating_add(1),
360                ..state.clone()
361            };
362
363            if should_pause_for_tool_results {
364                let lifecycle =
365                    RuntimeSessionLifecycle::new(adapter.clone(), state.org_id, state.session_id);
366                lifecycle.waiting_for_tool_results().await;
367                return Ok(RuntimeTurnPlan::WaitForToolResults { resume: next });
368            }
369
370            if waiting_for_tool_results {
371                info!(
372                    session_id = %state.session_id,
373                    "setup_connection hint absent, continuing turn instead of pausing"
374                );
375            }
376
377            Ok(RuntimeTurnPlan::ScheduleReason(next))
378        }
379        other => Err(AgentLoopError::config(format!(
380            "Unknown activity type completed: {other}"
381        ))),
382    }
383}
384
385async fn setup_connection_hint_enabled<A: RuntimeHostAdapter>(
386    adapter: &A,
387    org_id: i64,
388    session_id: SessionId,
389) -> bool {
390    match adapter.session_store(org_id).get_session(session_id).await {
391        Ok(Some(session)) => {
392            let hints = Controls::resolve_hints(session.hints.as_ref(), None);
393            hints
394                .get("setup_connection")
395                .and_then(|value| value.as_bool())
396                .unwrap_or(false)
397        }
398        _ => false,
399    }
400}