Skip to main content

everruns_core/capabilities/
session.rs

1//! Session Capability
2//!
3//! Provides session metadata tools:
4//! - `write_session_title`: update session title
5//! - `get_session_info`: return session id, title, agent name, and cumulative usage
6
7use super::{Capability, CapabilityStatus};
8use crate::events::TokenUsage;
9use crate::tool_types::ToolHints;
10use crate::tools::{Tool, ToolExecutionResult};
11use crate::traits::ToolContext;
12use async_trait::async_trait;
13use serde_json::{Value, json};
14
15/// Session capability - read/update session metadata.
16pub struct SessionCapability;
17
18impl Capability for SessionCapability {
19    fn id(&self) -> &str {
20        "session"
21    }
22
23    fn name(&self) -> &str {
24        "Session"
25    }
26
27    fn description(&self) -> &str {
28        "Read and update current session metadata like title and agent info."
29    }
30
31    fn status(&self) -> CapabilityStatus {
32        CapabilityStatus::Available
33    }
34
35    fn icon(&self) -> Option<&str> {
36        Some("panel-left")
37    }
38
39    fn category(&self) -> Option<&str> {
40        Some("Session")
41    }
42
43    fn tools(&self) -> Vec<Box<dyn Tool>> {
44        vec![
45            Box::new(WriteSessionTitleTool),
46            Box::new(GetSessionInfoTool),
47        ]
48    }
49}
50
51/// Tool: write_session_title
52pub struct WriteSessionTitleTool;
53
54#[async_trait]
55impl Tool for WriteSessionTitleTool {
56    fn name(&self) -> &str {
57        "write_session_title"
58    }
59
60    fn display_name(&self) -> Option<&str> {
61        Some("Write Session Title")
62    }
63
64    fn description(&self) -> &str {
65        "Update the current session title."
66    }
67
68    fn parameters_schema(&self) -> Value {
69        json!({
70            "type": "object",
71            "properties": {
72                "title": {
73                    "type": "string",
74                    "description": "New session title"
75                }
76            },
77            "required": ["title"],
78            "additionalProperties": false
79        })
80    }
81
82    fn hints(&self) -> ToolHints {
83        ToolHints::default().with_idempotent(true)
84    }
85
86    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
87        ToolExecutionResult::tool_error(
88            "write_session_title requires context. This tool must be executed with session context.",
89        )
90    }
91
92    async fn execute_with_context(
93        &self,
94        arguments: Value,
95        context: &ToolContext,
96    ) -> ToolExecutionResult {
97        let title = match arguments.get("title").and_then(|v| v.as_str()) {
98            Some(t) if !t.trim().is_empty() => t.trim().to_string(),
99            _ => return ToolExecutionResult::tool_error("Missing required parameter: title"),
100        };
101
102        let Some(mutator) = &context.session_mutator else {
103            return ToolExecutionResult::tool_error(
104                "Session mutator not available in this context",
105            );
106        };
107
108        match mutator
109            .update_session_title(context.session_id, title.clone())
110            .await
111        {
112            Ok(session) => ToolExecutionResult::success(json!({
113                "session_id": session.id.to_string(),
114                "title": session.title,
115                "updated": true,
116            })),
117            Err(e) => ToolExecutionResult::internal_error(e),
118        }
119    }
120}
121
122/// Tool: get_session_info
123pub struct GetSessionInfoTool;
124
125#[async_trait]
126impl Tool for GetSessionInfoTool {
127    fn name(&self) -> &str {
128        "get_session_info"
129    }
130
131    fn display_name(&self) -> Option<&str> {
132        Some("Get Session Info")
133    }
134
135    fn description(&self) -> &str {
136        "Get current session metadata: id, title, locale, agent name, and cumulative token usage."
137    }
138
139    fn parameters_schema(&self) -> Value {
140        json!({
141            "type": "object",
142            "properties": {},
143            "additionalProperties": false
144        })
145    }
146
147    fn hints(&self) -> ToolHints {
148        ToolHints::default()
149            .with_readonly(true)
150            .with_idempotent(true)
151    }
152
153    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
154        ToolExecutionResult::tool_error(
155            "get_session_info requires context. This tool must be executed with session context.",
156        )
157    }
158
159    async fn execute_with_context(
160        &self,
161        _arguments: Value,
162        context: &ToolContext,
163    ) -> ToolExecutionResult {
164        let Some(session_store) = &context.session_store else {
165            return ToolExecutionResult::tool_error("Session store not available in this context");
166        };
167
168        let session = match session_store.get_session(context.session_id).await {
169            Ok(Some(session)) => session,
170            Ok(None) => return ToolExecutionResult::tool_error("Session not found"),
171            Err(e) => return ToolExecutionResult::internal_error(e),
172        };
173
174        let agent_name = if let (Some(agent_id), Some(agent_store)) =
175            (session.agent_id, &context.agent_store)
176        {
177            match agent_store.get_agent(agent_id).await {
178                Ok(Some(agent)) => Some(agent.display_name.unwrap_or_else(|| agent.name.clone())),
179                Ok(None) => None,
180                Err(e) => return ToolExecutionResult::internal_error(e),
181            }
182        } else {
183            None
184        };
185
186        ToolExecutionResult::success(json!({
187            "session_id": session.id.to_string(),
188            "title": session.title,
189            "locale": session.locale,
190            "agent_name": agent_name,
191            "usage": session.usage.as_ref().map(usage_json),
192        }))
193    }
194}
195
196fn usage_json(usage: &TokenUsage) -> Value {
197    json!({
198        "input_tokens": usage.input_tokens,
199        "output_tokens": usage.output_tokens,
200        "cache_read_tokens": usage.cache_read_tokens,
201        "cache_creation_tokens": usage.cache_creation_tokens,
202        "total_tokens": usage.total_tokens(),
203    })
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use crate::agent::{Agent, AgentStatus};
210    use crate::error::Result;
211    use crate::session::{Session, SessionStatus};
212    use crate::typed_id::{AgentId, HarnessId, ModelId, SessionId};
213    use crate::{AgentCapabilityConfig, Tool};
214    use async_trait::async_trait;
215    use chrono::Utc;
216    use std::sync::{Arc, Mutex};
217
218    #[derive(Clone)]
219    struct MockSessionStore {
220        session: Arc<Mutex<Option<Session>>>,
221    }
222
223    #[async_trait]
224    impl crate::traits::SessionStore for MockSessionStore {
225        async fn get_session(&self, _session_id: SessionId) -> Result<Option<Session>> {
226            Ok(self.session.lock().expect("poisoned").clone())
227        }
228    }
229
230    #[derive(Clone)]
231    struct MockSessionMutator {
232        session: Arc<Mutex<Session>>,
233    }
234
235    #[async_trait]
236    impl crate::traits::SessionMutator for MockSessionMutator {
237        async fn update_session_title(
238            &self,
239            _session_id: SessionId,
240            title: String,
241        ) -> Result<Session> {
242            let mut session = self.session.lock().expect("poisoned");
243            session.title = Some(title);
244            Ok(session.clone())
245        }
246    }
247
248    struct MockAgentStore {
249        agent: Option<Agent>,
250    }
251
252    #[async_trait]
253    impl crate::traits::AgentStore for MockAgentStore {
254        async fn get_agent(&self, _agent_id: AgentId) -> Result<Option<Agent>> {
255            Ok(self.agent.clone())
256        }
257    }
258
259    fn build_session(agent_id: Option<AgentId>) -> Session {
260        Session {
261            id: SessionId::new(),
262            organization_id: "org_00000000000000000000000000000001".to_string(),
263            harness_id: HarnessId::new(),
264            agent_id,
265            agent_version_id: None,
266            agent_identity_id: None,
267            owner_principal_id: crate::PrincipalId::from_seed(1),
268            resolved_owner_user_id: None,
269            owner: None,
270            effective_owner: None,
271            title: Some("Old title".to_string()),
272            locale: None,
273            preview: None,
274            output_preview: None,
275            tags: vec![],
276            model_id: Some(ModelId::new()),
277            capabilities: vec![],
278            tools: vec![],
279            mcp_servers: Default::default(),
280            system_prompt: None,
281            initial_files: vec![],
282            hints: None,
283            network_access: None,
284            max_iterations: None,
285            status: SessionStatus::Idle,
286            created_at: Utc::now(),
287            updated_at: Utc::now(),
288            started_at: None,
289            finished_at: None,
290            usage: None,
291            is_pinned: None,
292            active_schedule_count: None,
293            features: vec![],
294            parent_session_id: None,
295            subagent_name: None,
296            subagent_task: None,
297            subagent_status: None,
298            blueprint_id: None,
299            blueprint_config: None,
300        }
301    }
302
303    #[tokio::test]
304    async fn write_session_title_updates_title() {
305        let session = build_session(None);
306        let session_id = session.id;
307        let mut context = ToolContext::new(session_id);
308        context.session_mutator = Some(Arc::new(MockSessionMutator {
309            session: Arc::new(Mutex::new(session)),
310        }));
311
312        let tool = WriteSessionTitleTool;
313        let result = tool
314            .execute_with_context(json!({"title": "New title"}), &context)
315            .await;
316
317        match result {
318            ToolExecutionResult::Success(value) => {
319                assert_eq!(value["title"], "New title");
320                assert_eq!(value["updated"], true);
321            }
322            _ => panic!("expected success"),
323        }
324    }
325
326    #[tokio::test]
327    async fn get_session_info_returns_agent_name_when_assigned() {
328        let agent_id = AgentId::new();
329        let session = build_session(Some(agent_id));
330        let session_id = session.id;
331
332        let agent = Agent {
333            public_id: agent_id,
334            internal_id: agent_id.uuid(),
335            name: "research-agent".to_string(),
336            display_name: Some("Research Agent".to_string()),
337            description: Some("desc".to_string()),
338            system_prompt: "prompt".to_string(),
339            default_model_id: None,
340            default_version_id: None,
341            forked_from_agent_id: None,
342            forked_from_version_id: None,
343            root_agent_id: None,
344            tags: vec![],
345            capabilities: vec![AgentCapabilityConfig::new("session")],
346            initial_files: vec![],
347            network_access: None,
348            max_iterations: None,
349            tools: vec![],
350            mcp_servers: Default::default(),
351            status: AgentStatus::Active,
352            created_at: Utc::now(),
353            updated_at: Utc::now(),
354            archived_at: None,
355            deleted_at: None,
356            usage: None,
357        };
358
359        let context = ToolContext::new(session_id)
360            .with_session_store(Arc::new(MockSessionStore {
361                session: Arc::new(Mutex::new(Some(session))),
362            }))
363            .with_agent_store(Arc::new(MockAgentStore { agent: Some(agent) }));
364
365        let tool = GetSessionInfoTool;
366        let result = tool.execute_with_context(json!({}), &context).await;
367
368        match result {
369            ToolExecutionResult::Success(value) => {
370                assert_eq!(value["title"], "Old title");
371                assert_eq!(value["agent_name"], "Research Agent");
372                assert!(value["usage"].is_null());
373            }
374            _ => panic!("expected success"),
375        }
376    }
377
378    #[tokio::test]
379    async fn get_session_info_returns_cumulative_usage() {
380        let mut session = build_session(None);
381        session.usage = Some(TokenUsage::with_cache(120, 45, Some(30), Some(10)));
382        let session_id = session.id;
383
384        let context = ToolContext::new(session_id).with_session_store(Arc::new(MockSessionStore {
385            session: Arc::new(Mutex::new(Some(session))),
386        }));
387
388        let tool = GetSessionInfoTool;
389        let result = tool.execute_with_context(json!({}), &context).await;
390
391        match result {
392            ToolExecutionResult::Success(value) => {
393                assert_eq!(value["usage"]["input_tokens"], 120);
394                assert_eq!(value["usage"]["output_tokens"], 45);
395                assert_eq!(value["usage"]["cache_read_tokens"], 30);
396                assert_eq!(value["usage"]["cache_creation_tokens"], 10);
397                assert_eq!(value["usage"]["total_tokens"], 165);
398            }
399            _ => panic!("expected success"),
400        }
401    }
402}