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