Skip to main content

everruns_core/
subagent_delegation.rs

1//! Narrow session-delegation contract for portable subagent/handoff orchestration.
2//!
3//! EVE-839: portable capabilities (`subagents`, `agent_handoff`) drive child
4//! sessions — create, message, wait, read — without depending on the full
5//! hosted [`PlatformStore`](https://docs.rs/everruns-platform) seam. They use
6//! this narrow, `everruns-core`-owned trait instead. The hosted platform crate
7//! implements it by delegating to its `PlatformStore`, so core carries no
8//! `PlatformStore` symbol while server/worker keep identical behavior.
9//!
10//! The request/message DTOs live here (not in `everruns-platform`) because the
11//! trait signature needs them and core cannot depend on platform.
12
13use crate::agent::Agent;
14use crate::error::Result;
15use crate::harness::Harness;
16use crate::session::{Session, SessionParticipant, SessionSeedMode};
17use crate::typed_id::{AgentId, HarnessId, SessionId};
18use async_trait::async_trait;
19use chrono::{DateTime, Utc};
20use serde::{Deserialize, Serialize};
21use std::collections::HashSet;
22
23/// Simplified message representation for subagent/handoff result collection.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct PlatformMessage {
26    pub role: String,
27    pub content: String,
28    pub created_at: DateTime<Utc>,
29}
30
31/// Options for delegate-backed child-session creation from model-facing tools.
32#[derive(Debug, Clone)]
33pub struct PlatformCreateSessionRequest {
34    pub harness_id: HarnessId,
35    pub agent_id: Option<AgentId>,
36    pub title: Option<String>,
37    pub goal: Option<String>,
38    pub locale: Option<String>,
39    pub blueprint_id: Option<String>,
40    pub blueprint_config: Option<serde_json::Value>,
41    pub parent_session_id: Option<SessionId>,
42    pub forked_from_session_id: Option<SessionId>,
43    /// Internal-only override for the budget/delegation root. Detached spawns
44    /// set this explicitly; ordinary forks must leave it unset.
45    pub budget_root_session_id: Option<SessionId>,
46    pub seed: SessionSeedMode,
47}
48
49/// The narrow set of child-session operations portable subagent orchestration
50/// needs. Implemented by the hosted platform adapter (over `PlatformStore`);
51/// carried on [`ToolContext`](crate::ToolContext) as an optional service.
52#[async_trait]
53pub trait SubagentSessionDelegate: Send + Sync {
54    /// Look up an agent by id (target validation for handoff/spawn).
55    async fn get_agent_by_id(&self, id: AgentId) -> Result<Option<Agent>>;
56
57    /// Look up a harness by id (parent harness resolution).
58    async fn get_harness(&self, id: HarnessId) -> Result<Option<Harness>>;
59
60    /// Resolve the inheritance chain (root-first) for a harness. Default impl
61    /// walks `parent_harness_id` via [`Self::get_harness`], guarding cycles.
62    async fn get_harness_chain(&self, id: HarnessId) -> Result<Vec<Harness>> {
63        let mut chain = Vec::new();
64        let mut current_id = Some(id);
65        let mut seen = HashSet::new();
66
67        while let Some(harness_id) = current_id {
68            if !seen.insert(harness_id) {
69                return Err(crate::error::AgentLoopError::tool(format!(
70                    "Harness inheritance cycle detected at {harness_id}"
71                )));
72            }
73            let Some(harness) = self.get_harness(harness_id).await? else {
74                return Ok(Vec::new());
75            };
76            current_id = harness.parent_harness_id;
77            chain.push(harness);
78        }
79
80        chain.reverse();
81        Ok(chain)
82    }
83
84    /// Add an agent as a member participant in an existing session.
85    async fn add_agent_session_participant(
86        &self,
87        session_id: SessionId,
88        agent_id: AgentId,
89    ) -> Result<SessionParticipant>;
90
91    /// Create a child session with the given options.
92    async fn create_session_with_options(
93        &self,
94        request: PlatformCreateSessionRequest,
95    ) -> Result<Session>;
96
97    /// Get a session by id.
98    async fn get_session_by_id(&self, id: SessionId) -> Result<Option<Session>>;
99
100    /// Send a user message to a session, triggering a turn.
101    async fn send_message(&self, session_id: SessionId, content: &str) -> Result<()>;
102
103    /// Get messages from a session (most recent first). Default limit is 10.
104    async fn get_messages(
105        &self,
106        session_id: SessionId,
107        limit: Option<usize>,
108    ) -> Result<Vec<PlatformMessage>>;
109
110    /// Wait for a session to become idle; returns the final status string.
111    async fn wait_for_idle(
112        &self,
113        session_id: SessionId,
114        timeout_secs: Option<u64>,
115    ) -> Result<String>;
116}
117
118#[cfg(test)]
119pub mod tests {
120    use super::*;
121    use crate::AgentCapabilityConfig;
122    use crate::agent::AgentStatus;
123    use crate::harness::HarnessStatus;
124    use crate::session::{SessionParticipant, SessionStatus};
125
126    /// Mock [`SubagentSessionDelegate`] for portable subagent/handoff tests.
127    ///
128    /// Carries the same simulated harness/agent/session state the former
129    /// `MockPlatformStore` provided, restricted to the narrow delegate surface
130    /// (EVE-839). The full hosted mock still lives in `everruns-platform`.
131    pub struct MockSubagentDelegate {
132        pub harness: Harness,
133        pub extra_harnesses: std::sync::Mutex<std::collections::HashMap<HarnessId, Harness>>,
134        pub agent: Agent,
135        pub session: Session,
136        pub extra_sessions: std::sync::Mutex<std::collections::HashMap<SessionId, Session>>,
137        pub joined_participants: std::sync::Mutex<Vec<SessionParticipant>>,
138        pub created_session_harness_ids: std::sync::Mutex<Vec<HarnessId>>,
139        pub created_session_budget_roots: std::sync::Mutex<Vec<Option<SessionId>>>,
140        pub wait_for_idle_status: std::sync::Mutex<String>,
141        pub sent_messages: std::sync::Mutex<Vec<(SessionId, String)>>,
142    }
143
144    impl Default for MockSubagentDelegate {
145        fn default() -> Self {
146            Self::new()
147        }
148    }
149
150    impl MockSubagentDelegate {
151        pub fn new() -> Self {
152            Self {
153                harness: Harness {
154                    id: HarnessId::new(),
155                    name: "test-harness".to_string(),
156                    display_name: Some("Test Harness".to_string()),
157                    description: Some("test harness".to_string()),
158                    system_prompt: Some("You are helpful.".to_string()),
159                    parent_harness_id: None,
160                    default_model_id: None,
161                    tags: vec![],
162                    capabilities: vec![AgentCapabilityConfig::new("session")],
163                    initial_files: vec![],
164                    network_access: None,
165                    parallel_tool_calls: None,
166                    mcp_servers: Default::default(),
167                    embedder_metadata: Default::default(),
168                    is_built_in: false,
169                    status: HarnessStatus::Active,
170                    created_at: chrono::Utc::now(),
171                    updated_at: chrono::Utc::now(),
172                    archived_at: None,
173                    deleted_at: None,
174                },
175                extra_harnesses: std::sync::Mutex::new(std::collections::HashMap::new()),
176                agent: Agent {
177                    public_id: crate::typed_id::AgentId::new(),
178                    internal_id: uuid::Uuid::now_v7(),
179                    name: "test-agent".to_string(),
180                    display_name: Some("Test Agent".to_string()),
181                    description: Some("test agent".to_string()),
182                    system_prompt: "You are helpful.".to_string(),
183                    default_model_id: None,
184                    harness_id: crate::typed_id::HarnessId::from_uuid(uuid::Uuid::nil()),
185                    default_version_id: None,
186                    forked_from_agent_id: None,
187                    forked_from_version_id: None,
188                    root_agent_id: None,
189                    tags: vec![],
190                    capabilities: vec![],
191                    initial_files: vec![],
192                    network_access: None,
193                    max_iterations: None,
194                    parallel_tool_calls: None,
195                    tools: vec![],
196                    mcp_servers: Default::default(),
197                    status: AgentStatus::Active,
198                    created_at: chrono::Utc::now(),
199                    updated_at: chrono::Utc::now(),
200                    archived_at: None,
201                    deleted_at: None,
202                    usage: None,
203                },
204                session: {
205                    let session_id = SessionId::new();
206                    Session {
207                        id: session_id,
208                        workspace_id: crate::WorkspaceId::from_uuid(session_id.uuid()),
209                        organization_id: "org_00000000000000000000000000000001".to_string(),
210                        harness_id: HarnessId::new(),
211                        agent_id: None,
212                        agent_version_id: None,
213                        agent_identity_id: None,
214                        owner_principal_id: crate::PrincipalId::from_seed(1),
215                        resolved_owner_user_id: None,
216                        owner: None,
217                        effective_owner: None,
218                        title: Some("Test Session".to_string()),
219                        goal: None,
220                        locale: None,
221                        preview: None,
222                        output_preview: None,
223                        tags: vec![],
224                        model_id: None,
225                        capabilities: vec![],
226                        tools: vec![],
227                        mcp_servers: Default::default(),
228                        system_prompt: None,
229                        initial_files: vec![],
230                        hints: None,
231                        network_access: None,
232                        max_iterations: None,
233                        parallel_tool_calls: None,
234                        status: SessionStatus::Idle,
235                        created_at: chrono::Utc::now(),
236                        updated_at: chrono::Utc::now(),
237                        started_at: None,
238                        finished_at: None,
239                        usage: None,
240                        is_pinned: None,
241                        active_schedule_count: None,
242                        features: vec![],
243                        parent_session_id: None,
244                        forked_from_session_id: None,
245                        forked_from_sequence: None,
246                        blueprint_id: None,
247                        blueprint_config: None,
248                    }
249                },
250                extra_sessions: std::sync::Mutex::new(std::collections::HashMap::new()),
251                joined_participants: std::sync::Mutex::new(Vec::new()),
252                created_session_harness_ids: std::sync::Mutex::new(Vec::new()),
253                created_session_budget_roots: std::sync::Mutex::new(Vec::new()),
254                wait_for_idle_status: std::sync::Mutex::new("idle".to_string()),
255                sent_messages: std::sync::Mutex::new(Vec::new()),
256            }
257        }
258
259        #[allow(clippy::too_many_arguments)]
260        async fn create_session(
261            &self,
262            hid: HarnessId,
263            aid: Option<crate::typed_id::AgentId>,
264            title: Option<&str>,
265            locale: Option<&str>,
266            blueprint_id: Option<&str>,
267            blueprint_config: Option<&serde_json::Value>,
268            parent_session_id: Option<SessionId>,
269        ) -> Result<Session> {
270            if let Ok(mut recorder) = self.created_session_harness_ids.lock() {
271                recorder.push(hid);
272            }
273            let mut s = self.session.clone();
274            s.id = SessionId::new();
275            s.harness_id = hid;
276            s.agent_id = aid;
277            s.title = title.map(|t| t.to_string());
278            s.locale = locale.map(|value| value.to_string());
279            s.blueprint_id = blueprint_id.map(|b| b.to_string());
280            s.blueprint_config = blueprint_config.cloned();
281            s.parent_session_id = parent_session_id;
282            if let Ok(mut sessions) = self.extra_sessions.lock() {
283                sessions.insert(s.id, s.clone());
284            }
285            Ok(s)
286        }
287    }
288
289    #[async_trait]
290    impl SubagentSessionDelegate for MockSubagentDelegate {
291        async fn get_agent_by_id(&self, _id: crate::typed_id::AgentId) -> Result<Option<Agent>> {
292            Ok(Some(self.agent.clone()))
293        }
294
295        async fn add_agent_session_participant(
296            &self,
297            session_id: SessionId,
298            agent_id: AgentId,
299        ) -> Result<SessionParticipant> {
300            let participant = SessionParticipant {
301                id: crate::typed_id::SessionParticipantId::new(),
302                session_id,
303                kind: crate::session::SessionParticipantKind::Agent,
304                agent_id: Some(agent_id),
305                agent_version_id: self.agent.default_version_id,
306                principal_id: self.session.owner_principal_id,
307                display_name: None,
308                role: crate::session::SessionParticipantRole::Member,
309                joined_at: chrono::Utc::now(),
310                left_at: None,
311            };
312            if let Ok(mut participants) = self.joined_participants.lock() {
313                participants.push(participant.clone());
314            }
315            Ok(participant)
316        }
317
318        async fn get_harness(&self, id: HarnessId) -> Result<Option<Harness>> {
319            if let Some(harness) = self.extra_harnesses.lock().unwrap().get(&id).cloned() {
320                return Ok(Some(harness));
321            }
322            Ok(Some(self.harness.clone()))
323        }
324
325        async fn create_session_with_options(
326            &self,
327            request: PlatformCreateSessionRequest,
328        ) -> Result<Session> {
329            self.created_session_budget_roots
330                .lock()
331                .expect("budget root recorder")
332                .push(request.budget_root_session_id);
333            let mut session = self
334                .create_session(
335                    request.harness_id,
336                    request.agent_id,
337                    request.title.as_deref(),
338                    request.locale.as_deref(),
339                    request.blueprint_id.as_deref(),
340                    request.blueprint_config.as_ref(),
341                    request.parent_session_id,
342                )
343                .await?;
344            session.goal = request.goal;
345            session.forked_from_session_id = request.forked_from_session_id;
346            if let Ok(mut sessions) = self.extra_sessions.lock() {
347                sessions.insert(session.id, session.clone());
348            }
349            Ok(session)
350        }
351
352        async fn get_session_by_id(&self, id: SessionId) -> Result<Option<Session>> {
353            if id == self.session.id {
354                return Ok(Some(self.session.clone()));
355            }
356            if let Some(session) = self
357                .extra_sessions
358                .lock()
359                .ok()
360                .and_then(|sessions| sessions.get(&id).cloned())
361            {
362                return Ok(Some(session));
363            }
364            Ok(Some(self.session.clone()))
365        }
366
367        async fn send_message(&self, id: SessionId, content: &str) -> Result<()> {
368            self.sent_messages
369                .lock()
370                .unwrap()
371                .push((id, content.to_string()));
372            Ok(())
373        }
374
375        async fn get_messages(
376            &self,
377            _id: SessionId,
378            _limit: Option<usize>,
379        ) -> Result<Vec<PlatformMessage>> {
380            Ok(vec![
381                PlatformMessage {
382                    role: "user".into(),
383                    content: "Hello".into(),
384                    created_at: chrono::Utc::now(),
385                },
386                PlatformMessage {
387                    role: "agent".into(),
388                    content: "Hi!".into(),
389                    created_at: chrono::Utc::now(),
390                },
391            ])
392        }
393
394        async fn wait_for_idle(&self, _id: SessionId, _t: Option<u64>) -> Result<String> {
395            Ok(self.wait_for_idle_status.lock().unwrap().clone())
396        }
397    }
398}