Skip to main content

everruns_core/
subagent_delegation.rs

1//! Narrow, host-neutral session-delegation contract.
2//!
3//! Hosted delegation capabilities drive child sessions — create, message,
4//! wait, read — through this narrow `everruns-core`-owned trait instead of the
5//! full hosted [`PlatformStore`](https://docs.rs/everruns-platform) seam. The
6//! platform crate implements it by delegating to `PlatformStore`, so core owns
7//! only the execution contract while server/worker keep identical behavior.
8//!
9//! The request/message DTOs live here (not in `everruns-platform`) because the
10//! trait signature needs them and core cannot depend on platform.
11
12use crate::agent_definition::AgentDefinition;
13use crate::error::Result;
14use crate::harness_definition::HarnessDefinition;
15use crate::session::{ExecutionSession, SessionSeedMode};
16use crate::typed_id::{AgentId, HarnessId, SessionId, SessionParticipantId};
17use async_trait::async_trait;
18use chrono::{DateTime, Utc};
19use serde::{Deserialize, Serialize};
20
21/// Simplified message representation for subagent/handoff result collection.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct PlatformMessage {
24    pub role: String,
25    pub content: String,
26    pub created_at: DateTime<Utc>,
27}
28
29/// Options for delegate-backed child-session creation from model-facing tools.
30#[derive(Debug, Clone)]
31pub struct PlatformCreateSessionRequest {
32    pub harness_id: HarnessId,
33    pub agent_id: Option<AgentId>,
34    pub title: Option<String>,
35    pub goal: Option<String>,
36    pub locale: Option<String>,
37    pub blueprint_id: Option<String>,
38    pub blueprint_config: Option<serde_json::Value>,
39    pub parent_session_id: Option<SessionId>,
40    pub forked_from_session_id: Option<SessionId>,
41    /// Internal-only override for the budget/delegation root. Detached spawns
42    /// set this explicitly; ordinary forks must leave it unset.
43    pub budget_root_session_id: Option<SessionId>,
44    pub seed: SessionSeedMode,
45}
46
47/// The narrow set of child-session operations a delegation provider needs.
48/// Implemented by a host adapter and carried on [`ToolContext`](crate::tool_context::ToolContext)
49/// as an optional service.
50#[async_trait]
51pub trait SubagentSessionDelegate: Send + Sync {
52    /// Look up an agent's execution definition by id (target validation for
53    /// handoff/spawn). Stored persistence records stay behind the hosted
54    /// platform adapter (EVE-877).
55    async fn get_agent_by_id(&self, id: AgentId) -> Result<Option<AgentDefinition>>;
56
57    /// Look up a harness's effective (inheritance-resolved) execution
58    /// configuration by id. Parent-chain walking, cycle guarding, and the
59    /// stored persistence record stay behind the hosted platform adapter
60    /// (EVE-881).
61    async fn get_harness(&self, id: HarnessId) -> Result<Option<HarnessDefinition>>;
62
63    /// Add an agent as a member participant in an existing session.
64    ///
65    /// Returns the new participant row's id (a neutral correlation value);
66    /// the stored participant record stays behind the platform seam (EVE-882).
67    async fn add_agent_session_participant(
68        &self,
69        session_id: SessionId,
70        agent_id: AgentId,
71    ) -> Result<SessionParticipantId>;
72
73    /// Create a child session with the given options. Returns the portable
74    /// execution view of the new session (EVE-882).
75    async fn create_session_with_options(
76        &self,
77        request: PlatformCreateSessionRequest,
78    ) -> Result<ExecutionSession>;
79
80    /// Get a session's portable execution view by id.
81    async fn get_session_by_id(&self, id: SessionId) -> Result<Option<ExecutionSession>>;
82
83    /// Send a user message to a session, triggering a turn.
84    async fn send_message(&self, session_id: SessionId, content: &str) -> Result<()>;
85
86    /// Get messages from a session (most recent first). Default limit is 10.
87    async fn get_messages(
88        &self,
89        session_id: SessionId,
90        limit: Option<usize>,
91    ) -> Result<Vec<PlatformMessage>>;
92
93    /// Wait for a session to become idle; returns the final status string.
94    async fn wait_for_idle(
95        &self,
96        session_id: SessionId,
97        timeout_secs: Option<u64>,
98    ) -> Result<String>;
99}
100
101#[cfg(test)]
102pub mod tests {
103    use super::*;
104    use crate::AgentCapabilityConfig;
105    use crate::session::SessionExecutionState;
106
107    /// Mock [`SubagentSessionDelegate`] for neutral delegation-contract tests.
108    ///
109    /// Carries the same simulated harness/agent/session state the former
110    /// `MockPlatformStore` provided, restricted to the narrow delegate surface
111    /// (EVE-839). The full hosted mock still lives in `everruns-platform`.
112    pub struct MockSubagentDelegate {
113        pub harness: HarnessDefinition,
114        pub extra_harnesses:
115            std::sync::Mutex<std::collections::HashMap<HarnessId, HarnessDefinition>>,
116        pub agent: AgentDefinition,
117        pub session: ExecutionSession,
118        pub extra_sessions:
119            std::sync::Mutex<std::collections::HashMap<SessionId, ExecutionSession>>,
120        pub joined_participants: std::sync::Mutex<Vec<(SessionId, Option<AgentId>)>>,
121        pub created_session_harness_ids: std::sync::Mutex<Vec<HarnessId>>,
122        pub created_session_budget_roots: std::sync::Mutex<Vec<Option<SessionId>>>,
123        pub wait_for_idle_status: std::sync::Mutex<String>,
124        pub sent_messages: std::sync::Mutex<Vec<(SessionId, String)>>,
125    }
126
127    impl Default for MockSubagentDelegate {
128        fn default() -> Self {
129            Self::new()
130        }
131    }
132
133    impl MockSubagentDelegate {
134        pub fn new() -> Self {
135            Self {
136                harness: HarnessDefinition {
137                    capabilities: vec![AgentCapabilityConfig::new("session")],
138                    ..HarnessDefinition::new("test-harness", "You are helpful.")
139                },
140                extra_harnesses: std::sync::Mutex::new(std::collections::HashMap::new()),
141                agent: AgentDefinition {
142                    display_name: Some("Test Agent".to_string()),
143                    description: Some("test agent".to_string()),
144                    ..AgentDefinition::new(
145                        crate::typed_id::AgentId::new(),
146                        "test-agent",
147                        "You are helpful.",
148                    )
149                },
150                session: ExecutionSession {
151                    title: Some("Test Session".to_string()),
152                    status: SessionExecutionState::Idle,
153                    ..ExecutionSession::with_own_workspace(SessionId::new(), HarnessId::new())
154                },
155                extra_sessions: std::sync::Mutex::new(std::collections::HashMap::new()),
156                joined_participants: std::sync::Mutex::new(Vec::new()),
157                created_session_harness_ids: std::sync::Mutex::new(Vec::new()),
158                created_session_budget_roots: std::sync::Mutex::new(Vec::new()),
159                wait_for_idle_status: std::sync::Mutex::new("idle".to_string()),
160                sent_messages: std::sync::Mutex::new(Vec::new()),
161            }
162        }
163
164        #[allow(clippy::too_many_arguments)]
165        async fn create_session(
166            &self,
167            hid: HarnessId,
168            aid: Option<crate::typed_id::AgentId>,
169            title: Option<&str>,
170            locale: Option<&str>,
171            blueprint_id: Option<&str>,
172            blueprint_config: Option<&serde_json::Value>,
173            parent_session_id: Option<SessionId>,
174        ) -> Result<ExecutionSession> {
175            if let Ok(mut recorder) = self.created_session_harness_ids.lock() {
176                recorder.push(hid);
177            }
178            let mut s = self.session.clone();
179            s.id = SessionId::new();
180            s.harness_id = hid;
181            s.agent_id = aid;
182            s.title = title.map(|t| t.to_string());
183            s.locale = locale.map(|value| value.to_string());
184            s.blueprint_id = blueprint_id.map(|b| b.to_string());
185            s.blueprint_config = blueprint_config.cloned();
186            s.parent_session_id = parent_session_id;
187            if let Ok(mut sessions) = self.extra_sessions.lock() {
188                sessions.insert(s.id, s.clone());
189            }
190            Ok(s)
191        }
192    }
193
194    #[async_trait]
195    impl SubagentSessionDelegate for MockSubagentDelegate {
196        async fn get_agent_by_id(
197            &self,
198            _id: crate::typed_id::AgentId,
199        ) -> Result<Option<AgentDefinition>> {
200            Ok(Some(self.agent.clone()))
201        }
202
203        async fn add_agent_session_participant(
204            &self,
205            session_id: SessionId,
206            agent_id: AgentId,
207        ) -> Result<SessionParticipantId> {
208            if let Ok(mut participants) = self.joined_participants.lock() {
209                participants.push((session_id, Some(agent_id)));
210            }
211            Ok(SessionParticipantId::new())
212        }
213
214        async fn get_harness(&self, id: HarnessId) -> Result<Option<HarnessDefinition>> {
215            if let Some(harness) = self.extra_harnesses.lock().unwrap().get(&id).cloned() {
216                return Ok(Some(harness));
217            }
218            Ok(Some(self.harness.clone()))
219        }
220
221        async fn create_session_with_options(
222            &self,
223            request: PlatformCreateSessionRequest,
224        ) -> Result<ExecutionSession> {
225            self.created_session_budget_roots
226                .lock()
227                .expect("budget root recorder")
228                .push(request.budget_root_session_id);
229            let mut session = self
230                .create_session(
231                    request.harness_id,
232                    request.agent_id,
233                    request.title.as_deref(),
234                    request.locale.as_deref(),
235                    request.blueprint_id.as_deref(),
236                    request.blueprint_config.as_ref(),
237                    request.parent_session_id,
238                )
239                .await?;
240            session.goal = request.goal;
241            session.forked_from_session_id = request.forked_from_session_id;
242            if let Ok(mut sessions) = self.extra_sessions.lock() {
243                sessions.insert(session.id, session.clone());
244            }
245            Ok(session)
246        }
247
248        async fn get_session_by_id(&self, id: SessionId) -> Result<Option<ExecutionSession>> {
249            if id == self.session.id {
250                return Ok(Some(self.session.clone()));
251            }
252            if let Some(session) = self
253                .extra_sessions
254                .lock()
255                .ok()
256                .and_then(|sessions| sessions.get(&id).cloned())
257            {
258                return Ok(Some(session));
259            }
260            Ok(Some(self.session.clone()))
261        }
262
263        async fn send_message(&self, id: SessionId, content: &str) -> Result<()> {
264            self.sent_messages
265                .lock()
266                .unwrap()
267                .push((id, content.to_string()));
268            Ok(())
269        }
270
271        async fn get_messages(
272            &self,
273            _id: SessionId,
274            _limit: Option<usize>,
275        ) -> Result<Vec<PlatformMessage>> {
276            Ok(vec![
277                PlatformMessage {
278                    role: "user".into(),
279                    content: "Hello".into(),
280                    created_at: chrono::Utc::now(),
281                },
282                PlatformMessage {
283                    role: "agent".into(),
284                    content: "Hi!".into(),
285                    created_at: chrono::Utc::now(),
286                },
287            ])
288        }
289
290        async fn wait_for_idle(&self, _id: SessionId, _t: Option<u64>) -> Result<String> {
291            Ok(self.wait_for_idle_status.lock().unwrap().clone())
292        }
293    }
294}