Skip to main content

everruns_core/
session.rs

1// Neutral session identity and turn execution state (EVE-882).
2//
3// Decision: the persisted `Session` database/API aggregate — source/facet
4// classifications, participants and ownership references, UI/list activity
5// projections, timestamps, catalog relationships — lives in
6// `everruns-platform`. Core keeps only this portable, execution-facing view:
7// the session correlation values and effective per-session configuration a
8// turn consumes, plus the small neutral execution state the host lifecycle
9// drives. The platform loading seam (server repositories, worker adapters,
10// hosted stores) projects stored records into these values before host
11// execution begins — the host never requests or receives a stored Session.
12
13use std::collections::HashMap;
14
15use serde::{Deserialize, Serialize};
16
17use crate::capability_types::AgentCapabilityConfig;
18use crate::events::TokenUsage;
19use crate::mcp_server::{ScopedMcpServers, scoped_mcp_servers_is_empty};
20use crate::network_access::NetworkAccessList;
21use crate::session_file::InitialFile;
22use crate::tool_types::ToolDefinition;
23use crate::typed_id::{AgentId, HarnessId, ModelId, SessionId, WorkspaceId};
24
25/// Subagent lifecycle status.
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
28#[serde(rename_all = "snake_case")]
29pub enum SubagentStatus {
30    Spawning,
31    Running,
32    Completed,
33    Failed,
34    Cancelled,
35    MaxIterationsReached,
36    /// The durable engine deliberately stopped (sealed) the child's turn to
37    /// prevent further waste (no forward progress, or budget exhausted). This is
38    /// terminal and non-retryable, and is intentionally distinct from `Failed`
39    /// so the parent agent can decide what to do next (the seal reason is
40    /// carried in the child's final assistant message / spawn `result`).
41    Sealed,
42}
43
44impl std::fmt::Display for SubagentStatus {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        match self {
47            SubagentStatus::Spawning => write!(f, "spawning"),
48            SubagentStatus::Running => write!(f, "running"),
49            SubagentStatus::Completed => write!(f, "completed"),
50            SubagentStatus::Failed => write!(f, "failed"),
51            SubagentStatus::Cancelled => write!(f, "cancelled"),
52            SubagentStatus::MaxIterationsReached => write!(f, "max_iterations_reached"),
53            SubagentStatus::Sealed => write!(f, "sealed"),
54        }
55    }
56}
57
58impl From<&str> for SubagentStatus {
59    fn from(s: &str) -> Self {
60        match s {
61            "spawning" => SubagentStatus::Spawning,
62            "running" => SubagentStatus::Running,
63            "completed" => SubagentStatus::Completed,
64            "failed" => SubagentStatus::Failed,
65            "cancelled" => SubagentStatus::Cancelled,
66            "max_iterations_reached" => SubagentStatus::MaxIterationsReached,
67            "sealed" => SubagentStatus::Sealed,
68            _ => SubagentStatus::Spawning,
69        }
70    }
71}
72
73/// Neutral session execution state (EVE-882).
74///
75/// The small value host planning and lifecycle transitions operate on:
76/// - `started`: session created, no turn executed yet
77/// - `active`: a turn is currently running
78/// - `idle`: turn completed, session waiting for next input
79/// - `waiting_for_tool_results`: waiting for the client to submit tool results
80/// - `paused`: budget limit reached, waiting for the user to resume
81///
82/// The persisted product status enum lives in `everruns-platform`
83/// (`SessionStatus`) and maps to/from this value at the adapter boundary; its
84/// wire strings are the [`SessionExecutionState::as_str`] values below.
85#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
86#[serde(rename_all = "snake_case")]
87pub enum SessionExecutionState {
88    /// Session just created, no turn executed yet.
89    Started,
90    /// A turn is currently running (session is active).
91    Active,
92    /// Turn completed, session waiting for next input (idle).
93    Idle,
94    /// Waiting for client to submit tool results.
95    WaitingForToolResults,
96    /// Budget limit reached — session paused until user resumes or increases limit.
97    Paused,
98}
99
100impl SessionExecutionState {
101    pub fn as_str(self) -> &'static str {
102        match self {
103            SessionExecutionState::Started => "started",
104            SessionExecutionState::Active => "active",
105            SessionExecutionState::Idle => "idle",
106            SessionExecutionState::WaitingForToolResults => "waiting_for_tool_results",
107            SessionExecutionState::Paused => "paused",
108        }
109    }
110}
111
112impl std::fmt::Display for SessionExecutionState {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        f.write_str(self.as_str())
115    }
116}
117
118impl From<&str> for SessionExecutionState {
119    fn from(s: &str) -> Self {
120        match s {
121            "active" => SessionExecutionState::Active,
122            "idle" => SessionExecutionState::Idle,
123            "waiting_for_tool_results" => SessionExecutionState::WaitingForToolResults,
124            "paused" => SessionExecutionState::Paused,
125            // Handle legacy values during migration
126            "running" => SessionExecutionState::Active,
127            "pending" | "completed" | "failed" => SessionExecutionState::Idle,
128            _ => SessionExecutionState::Started,
129        }
130    }
131}
132
133/// Portable execution view of one session (EVE-882).
134///
135/// Carries exactly what turn execution consumes: the typed correlation
136/// values, the session's own configuration overlay layer (leaf of the
137/// harness → agent → session chain), the neutral execution state, and
138/// cumulative usage accounting. It is not a persistence record — origin
139/// facets, activity projections, participants, ownership summaries,
140/// timestamps, and list/UI metadata stay in `everruns-platform`.
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct ExecutionSession {
143    /// Session identity (correlation value).
144    pub id: SessionId,
145    /// Public (`org_…`) organization id used for execution scoping. This is a
146    /// correlation value, not the organization record.
147    pub organization_id: String,
148    /// Workspace owning the session's virtual filesystem. For the default 1:1
149    /// case this mirrors the session id.
150    pub workspace_id: WorkspaceId,
151    /// Harness providing the base environment configuration layer.
152    pub harness_id: HarnessId,
153    /// Optional agent working in this session.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub agent_id: Option<AgentId>,
156    /// Human-readable title (readable/writable through the session capability).
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub title: Option<String>,
159    /// Session objective visible to the runtime agent at system-prompt level.
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub goal: Option<String>,
162    /// Locale for localized agent behavior and formatting (BCP 47).
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub locale: Option<String>,
165    /// Tags consulted by execution modes (e.g. progress reporting).
166    #[serde(default, skip_serializing_if = "Vec::is_empty")]
167    pub tags: Vec<String>,
168    /// Session-level model override (higher priority than agent/harness).
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub model_id: Option<ModelId>,
171    /// Session-level capabilities (additive to agent capabilities).
172    #[serde(default, skip_serializing_if = "Vec::is_empty")]
173    pub capabilities: Vec<AgentCapabilityConfig>,
174    /// Client-side tools for this session (additive to agent tools).
175    #[serde(default, skip_serializing_if = "Vec::is_empty")]
176    pub tools: Vec<ToolDefinition>,
177    /// Remote MCP servers scoped to this session only.
178    #[serde(
179        default,
180        rename = "mcpServers",
181        alias = "mcp_servers",
182        skip_serializing_if = "scoped_mcp_servers_is_empty"
183    )]
184    pub mcp_servers: ScopedMcpServers,
185    /// Session-level system prompt override.
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub system_prompt: Option<String>,
188    /// Session-level initial files (additive to agent initial_files).
189    #[serde(default, skip_serializing_if = "Vec::is_empty")]
190    pub initial_files: Vec<InitialFile>,
191    /// Session-level client hints; per-message `controls.hints` override these
192    /// key-by-key (shallow merge).
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub hints: Option<HashMap<String, serde_json::Value>>,
195    /// Network access list merged with harness and agent layers.
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub network_access: Option<NetworkAccessList>,
198    /// Maximum number of LLM iterations per turn for this session.
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub max_iterations: Option<usize>,
201    /// Request-level parallel tool calling preference (EVE-598).
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub parallel_tool_calls: Option<bool>,
204    /// Neutral execution state driven by the host lifecycle.
205    pub status: SessionExecutionState,
206    /// Cumulative token usage for all LLM calls in this session.
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub usage: Option<TokenUsage>,
209    /// Parent session that spawned this subagent (subagent nesting depth).
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub parent_session_id: Option<SessionId>,
212    /// Session this one was forked from (delegation-result correlation).
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub forked_from_session_id: Option<SessionId>,
215    /// Blueprint ID. When set, execution builds the RuntimeAgent from the
216    /// blueprint definition instead of harness/agent configuration.
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub blueprint_id: Option<String>,
219    /// Validated config passed by host at blueprint spawn time.
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub blueprint_config: Option<serde_json::Value>,
222}
223
224impl ExecutionSession {
225    /// Create an execution session with the given correlation identity; all
226    /// configuration starts empty, status starts at `started`, and the
227    /// organization defaults to the single-tenant default org.
228    pub fn new(id: SessionId, workspace_id: WorkspaceId, harness_id: HarnessId) -> Self {
229        Self {
230            id,
231            organization_id: crate::DEFAULT_ORG_PUBLIC_ID.to_string(),
232            workspace_id,
233            harness_id,
234            agent_id: None,
235            title: None,
236            goal: None,
237            locale: None,
238            tags: Vec::new(),
239            model_id: None,
240            capabilities: Vec::new(),
241            tools: Vec::new(),
242            mcp_servers: ScopedMcpServers::default(),
243            system_prompt: None,
244            initial_files: Vec::new(),
245            hints: None,
246            network_access: None,
247            max_iterations: None,
248            parallel_tool_calls: None,
249            status: SessionExecutionState::Started,
250            usage: None,
251            parent_session_id: None,
252            forked_from_session_id: None,
253            blueprint_id: None,
254            blueprint_config: None,
255        }
256    }
257
258    /// Create an execution session under the default 1:1 workspace identity
259    /// (`workspace.id == session.id`).
260    pub fn with_own_workspace(id: SessionId, harness_id: HarnessId) -> Self {
261        Self::new(id, WorkspaceId::from_uuid(id.uuid()), harness_id)
262    }
263}
264
265/// Seed mode used when creating a peer session from an existing session.
266#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
267#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
268#[serde(rename_all = "snake_case")]
269pub enum SessionSeedMode {
270    /// Create an empty session and only record lineage when provided.
271    #[default]
272    Fresh,
273    /// Copy conversation events, workspace files, and durable session storage.
274    Fork,
275    /// Copy workspace files only.
276    Workspace,
277}
278
279impl SessionSeedMode {
280    pub fn as_str(self) -> &'static str {
281        match self {
282            Self::Fresh => "fresh",
283            Self::Fork => "fork",
284            Self::Workspace => "workspace",
285        }
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    #[test]
294    fn execution_state_preserves_wire_values_and_legacy_inputs() {
295        for (state, wire) in [
296            (SessionExecutionState::Started, "started"),
297            (SessionExecutionState::Active, "active"),
298            (SessionExecutionState::Idle, "idle"),
299            (
300                SessionExecutionState::WaitingForToolResults,
301                "waiting_for_tool_results",
302            ),
303            (SessionExecutionState::Paused, "paused"),
304        ] {
305            assert_eq!(state.as_str(), wire);
306            assert_eq!(state.to_string(), wire);
307            assert_eq!(SessionExecutionState::from(wire), state);
308            assert_eq!(
309                serde_json::to_value(state).unwrap(),
310                serde_json::json!(wire)
311            );
312            assert_eq!(
313                serde_json::from_value::<SessionExecutionState>(serde_json::json!(wire)).unwrap(),
314                state
315            );
316        }
317        for (legacy, expected) in [
318            ("running", SessionExecutionState::Active),
319            ("pending", SessionExecutionState::Idle),
320            ("completed", SessionExecutionState::Idle),
321            ("failed", SessionExecutionState::Idle),
322            ("garbage", SessionExecutionState::Started),
323        ] {
324            assert_eq!(SessionExecutionState::from(legacy), expected, "{legacy}");
325        }
326    }
327
328    #[test]
329    fn execution_session_preserves_minimal_portable_shape() {
330        let session = ExecutionSession::with_own_workspace(
331            "session_01933b5a000070008000000000000001".parse().unwrap(),
332            "harness_01933b5a000070008000000000000002".parse().unwrap(),
333        );
334        assert_eq!(
335            serde_json::to_value(&session).unwrap(),
336            serde_json::json!({
337                "id": "session_01933b5a000070008000000000000001",
338                "organization_id": "org_00000000000000000000000000000001",
339                "workspace_id": "wsp_01933b5a000070008000000000000001",
340                "harness_id": "harness_01933b5a000070008000000000000002",
341                "status": "started",
342            })
343        );
344    }
345}