Skip to main content

everruns_host/session_services/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 crate::session_services::session_mutator::{SessionMutator, SessionMutatorExt};
8use async_trait::async_trait;
9use everruns_core::capabilities::{Capability, CapabilityLocalization, CapabilityStatus};
10use everruns_core::events::{EventContext, EventRequest, SessionTitleUpdatedData, TokenUsage};
11use everruns_core::session::ExecutionSession;
12use everruns_core::tools::{Tool, ToolExecutionResult};
13use everruns_core::{
14    event_emitter::EventEmitter, execution_loading::SessionStore, tool_context::ToolContext,
15};
16use everruns_provider::error::{AgentLoopError, Result};
17use everruns_provider::tool_types::ToolHints;
18use everruns_provider::typed_id::SessionId;
19use serde::{Deserialize, Serialize};
20use serde_json::{Value, json};
21
22pub const SESSION_CAPABILITY_ID: &str = "session";
23
24/// Per-agent/session settings for the session capability.
25#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(deny_unknown_fields)]
27pub struct SessionCapabilityConfig {
28    /// Ask the model to maintain a concise title as the conversation evolves.
29    #[serde(default)]
30    pub auto_title: bool,
31}
32
33impl SessionCapabilityConfig {
34    fn from_value(config: &Value) -> Self {
35        serde_json::from_value(config.clone()).unwrap_or_default()
36    }
37}
38
39/// Result of a semantic session-title mutation.
40#[derive(Debug, Clone)]
41pub struct SessionTitleMutation {
42    /// Current session snapshot.
43    pub session: ExecutionSession,
44    /// Whether the stored title changed and an event was emitted.
45    pub changed: bool,
46}
47
48/// Build the semantic event for an actual title change.
49///
50/// Returns `None` when `title` is already current, giving tool and non-tool
51/// mutation paths one source of truth for no-op suppression and payload shape.
52pub fn session_title_updated_event(
53    session_id: SessionId,
54    event_context: EventContext,
55    previous_title: Option<String>,
56    title: String,
57) -> Option<EventRequest> {
58    if previous_title.as_deref() == Some(title.as_str()) {
59        return None;
60    }
61    Some(EventRequest::new(
62        session_id,
63        event_context,
64        SessionTitleUpdatedData {
65            previous_title,
66            title,
67        },
68    ))
69}
70
71/// Update a session title and emit the corresponding semantic event.
72///
73/// Embedders and non-tool mutation paths can use this helper to share the same
74/// change detection, typed payload, and correlation semantics as
75/// [`WriteSessionTitleTool`]. The event is emitted only after an actual change;
76/// an unchanged title is a successful no-op.
77pub async fn update_session_title_with_event(
78    session_id: SessionId,
79    title: String,
80    event_context: EventContext,
81    session_store: &dyn SessionStore,
82    session_mutator: &dyn SessionMutator,
83    event_emitter: &dyn EventEmitter,
84) -> Result<SessionTitleMutation> {
85    let current = session_store
86        .get_session(session_id)
87        .await?
88        .ok_or_else(|| AgentLoopError::store(format!("session not found: {session_id}")))?;
89    let previous_title = current.title.clone();
90
91    let Some(event) =
92        session_title_updated_event(session_id, event_context, previous_title, title.clone())
93    else {
94        return Ok(SessionTitleMutation {
95            session: current,
96            changed: false,
97        });
98    };
99
100    let session = session_mutator
101        .update_session_title(session_id, title.clone())
102        .await?;
103    event_emitter.emit(event).await?;
104
105    Ok(SessionTitleMutation {
106        session,
107        changed: true,
108    })
109}
110
111/// Session capability - read/update session metadata.
112pub struct SessionCapability;
113
114#[async_trait]
115impl Capability for SessionCapability {
116    fn id(&self) -> &str {
117        SESSION_CAPABILITY_ID
118    }
119
120    fn name(&self) -> &str {
121        "Session"
122    }
123
124    fn description(&self) -> &str {
125        "Read and update current session metadata like title and agent info."
126    }
127
128    fn localizations(&self) -> Vec<CapabilityLocalization> {
129        vec![CapabilityLocalization::text(
130            "uk",
131            "Сесія",
132            "Читання та оновлення метаданих поточної сесії, як-от назви й інформації про агента.",
133        )]
134    }
135
136    fn status(&self) -> CapabilityStatus {
137        CapabilityStatus::Available
138    }
139
140    fn icon(&self) -> Option<&str> {
141        Some("panel-left")
142    }
143
144    fn category(&self) -> Option<&str> {
145        Some("Session")
146    }
147
148    fn config_schema(&self) -> Option<Value> {
149        Some(json!({
150            "type": "object",
151            "properties": {
152                "auto_title": {
153                    "type": "boolean",
154                    "title": "Automatic session titles",
155                    "description": "Require a concise title before handling the first substantive request and update it only when the conversation's primary theme materially changes.",
156                    "default": false
157                }
158            },
159            "additionalProperties": false
160        }))
161    }
162
163    fn validate_config(&self, config: &Value) -> std::result::Result<(), String> {
164        if config.is_null() {
165            return Ok(());
166        }
167        serde_json::from_value::<SessionCapabilityConfig>(config.clone())
168            .map(|_| ())
169            .map_err(|error| format!("invalid session config: {error}"))
170    }
171
172    async fn system_prompt_contribution_with_config(
173        &self,
174        _ctx: &everruns_core::capabilities::SystemPromptContext,
175        config: &Value,
176    ) -> Option<String> {
177        if !SessionCapabilityConfig::from_value(config).auto_title {
178            return None;
179        }
180
181        Some(format!(
182            "<capability id=\"{}\">\nTitle maintenance is mandatory when automatic titles are enabled. On the first substantive user request, you MUST call `write_session_title` with a concise 3–7 word title for the conversation's primary theme before using any other tool, doing substantive work, or giving a substantive response. Ignore greetings, acknowledgements, and other non-substantive messages. On later turns, if the primary theme materially changes, you MUST call `write_session_title` before using any other tool, doing substantive work, or responding. You MUST NOT update the title for minor subtopics, follow-ups, or implementation details. Calling `write_session_title` updates session metadata only; it does not change project or workspace files and must not be treated as a project-file change.\n</capability>",
183            self.id()
184        ))
185    }
186
187    fn tools(&self) -> Vec<Box<dyn Tool>> {
188        vec![
189            Box::new(WriteSessionTitleTool),
190            Box::new(GetSessionInfoTool),
191        ]
192    }
193}
194
195/// Tool: write_session_title
196pub struct WriteSessionTitleTool;
197
198#[async_trait]
199impl Tool for WriteSessionTitleTool {
200    fn narrate(
201        &self,
202        tool_call: &everruns_provider::tool_types::ToolCall,
203        phase: everruns_core::tool_narration::ToolNarrationPhase,
204        locale: Option<&str>,
205        _ctx: everruns_core::tool_narration::ToolNarrationContext<'_>,
206    ) -> Option<String> {
207        Some(everruns_core::tool_narration::narrate_write_session_title(
208            &tool_call.arguments,
209            phase,
210            locale,
211        ))
212    }
213
214    fn name(&self) -> &str {
215        "write_session_title"
216    }
217
218    fn display_name(&self) -> Option<&str> {
219        Some("Write Session Title")
220    }
221
222    fn description(&self) -> &str {
223        "Update the current session title."
224    }
225
226    fn parameters_schema(&self) -> Value {
227        json!({
228            "type": "object",
229            "properties": {
230                "title": {
231                    "type": "string",
232                    "description": "New session title"
233                }
234            },
235            "required": ["title"],
236            "additionalProperties": false
237        })
238    }
239
240    fn hints(&self) -> ToolHints {
241        ToolHints::default().with_idempotent(true)
242    }
243
244    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
245        ToolExecutionResult::tool_error(
246            "write_session_title requires context. This tool must be executed with session context.",
247        )
248    }
249
250    async fn execute_with_context(
251        &self,
252        arguments: Value,
253        context: &ToolContext,
254    ) -> ToolExecutionResult {
255        let title = match arguments.get("title").and_then(|v| v.as_str()) {
256            Some(t) if !t.trim().is_empty() => t.trim().to_string(),
257            _ => return ToolExecutionResult::tool_error("Missing required parameter: title"),
258        };
259
260        let Some(session_store) = &context.session_store else {
261            return ToolExecutionResult::tool_error("Session store not available in this context");
262        };
263        let Some(mutator) = context.extensions.get::<SessionMutatorExt>() else {
264            return ToolExecutionResult::tool_error(
265                "Session mutator not available in this context",
266            );
267        };
268        let mutator = &mutator.0;
269        let Some(event_emitter) = &context.event_emitter else {
270            return ToolExecutionResult::tool_error("Event emitter not available in this context");
271        };
272
273        match update_session_title_with_event(
274            context.session_id,
275            title,
276            context.event_context.clone().unwrap_or_default(),
277            session_store.as_ref(),
278            mutator.as_ref(),
279            event_emitter.as_ref(),
280        )
281        .await
282        {
283            Ok(outcome) => ToolExecutionResult::success(json!({
284                "session_id": outcome.session.id.to_string(),
285                "title": outcome.session.title,
286                "updated": outcome.changed,
287            })),
288            Err(e) => ToolExecutionResult::internal_error(e),
289        }
290    }
291}
292
293/// Tool: get_session_info
294pub struct GetSessionInfoTool;
295
296#[async_trait]
297impl Tool for GetSessionInfoTool {
298    fn narrate(
299        &self,
300        _tool_call: &everruns_provider::tool_types::ToolCall,
301        phase: everruns_core::tool_narration::ToolNarrationPhase,
302        locale: Option<&str>,
303        _ctx: everruns_core::tool_narration::ToolNarrationContext<'_>,
304    ) -> Option<String> {
305        Some(everruns_core::tool_narration::narrate_get_session_info(
306            phase, locale,
307        ))
308    }
309
310    fn name(&self) -> &str {
311        "get_session_info"
312    }
313
314    fn display_name(&self) -> Option<&str> {
315        Some("Get Session Info")
316    }
317
318    fn description(&self) -> &str {
319        "Get current session metadata: id, title, locale, agent name, and cumulative token usage."
320    }
321
322    fn parameters_schema(&self) -> Value {
323        json!({
324            "type": "object",
325            "properties": {},
326            "additionalProperties": false
327        })
328    }
329
330    fn hints(&self) -> ToolHints {
331        ToolHints::default()
332            .with_readonly(true)
333            .with_idempotent(true)
334    }
335
336    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
337        ToolExecutionResult::tool_error(
338            "get_session_info requires context. This tool must be executed with session context.",
339        )
340    }
341
342    async fn execute_with_context(
343        &self,
344        _arguments: Value,
345        context: &ToolContext,
346    ) -> ToolExecutionResult {
347        let Some(session_store) = &context.session_store else {
348            return ToolExecutionResult::tool_error("Session store not available in this context");
349        };
350
351        let session = match session_store.get_session(context.session_id).await {
352            Ok(Some(session)) => session,
353            Ok(None) => return ToolExecutionResult::tool_error("Session not found"),
354            Err(e) => return ToolExecutionResult::internal_error(e),
355        };
356
357        let agent_name = if let (Some(agent_id), Some(agent_store)) =
358            (session.agent_id, &context.agent_store)
359        {
360            match agent_store.get_agent(agent_id).await {
361                Ok(Some(agent)) => Some(agent.display_name.unwrap_or_else(|| agent.name.clone())),
362                Ok(None) => None,
363                Err(e) => return ToolExecutionResult::internal_error(e),
364            }
365        } else {
366            None
367        };
368
369        ToolExecutionResult::success(json!({
370            "session_id": session.id.to_string(),
371            "title": session.title,
372            "locale": session.locale,
373            "agent_name": agent_name,
374            "usage": session.usage.as_ref().map(usage_json),
375        }))
376    }
377}
378
379fn usage_json(usage: &TokenUsage) -> Value {
380    json!({
381        "input_tokens": usage.input_tokens,
382        "output_tokens": usage.output_tokens,
383        "cache_read_tokens": usage.cache_read_tokens,
384        "cache_creation_tokens": usage.cache_creation_tokens,
385        "total_tokens": usage.total_tokens(),
386    })
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use async_trait::async_trait;
393    use everruns_core::AgentDefinition;
394    use everruns_core::Tool;
395    use everruns_core::events::{Event, EventRequest};
396    use everruns_core::session::{ExecutionSession, SessionExecutionState};
397    use everruns_provider::typed_id::{
398        AgentId, EventId, HarnessId, MessageId, ModelId, SessionId, TurnId,
399    };
400    use std::sync::{Arc, Mutex};
401
402    #[derive(Clone)]
403    struct MockSessionStore {
404        session: Arc<Mutex<Option<ExecutionSession>>>,
405    }
406
407    #[async_trait]
408    impl everruns_core::execution_loading::SessionStore for MockSessionStore {
409        async fn get_session(&self, _session_id: SessionId) -> Result<Option<ExecutionSession>> {
410            Ok(self.session.lock().expect("poisoned").clone())
411        }
412    }
413
414    #[derive(Clone)]
415    struct MockSessionMutator {
416        session: Arc<Mutex<ExecutionSession>>,
417    }
418
419    #[async_trait]
420    impl SessionMutator for MockSessionMutator {
421        async fn update_session_title(
422            &self,
423            _session_id: SessionId,
424            title: String,
425        ) -> Result<ExecutionSession> {
426            let mut session = self.session.lock().expect("poisoned");
427            session.title = Some(title);
428            Ok(session.clone())
429        }
430    }
431
432    struct MockAgentStore {
433        agent: Option<AgentDefinition>,
434    }
435
436    #[derive(Clone, Default)]
437    struct RecordingEventEmitter {
438        requests: Arc<Mutex<Vec<EventRequest>>>,
439    }
440
441    #[async_trait]
442    impl everruns_core::event_emitter::EventEmitter for RecordingEventEmitter {
443        async fn emit(&self, request: EventRequest) -> Result<Event> {
444            self.requests
445                .lock()
446                .expect("poisoned")
447                .push(request.clone());
448            Ok(request.into_event(EventId::new(), 1))
449        }
450    }
451
452    #[async_trait]
453    impl everruns_core::execution_loading::AgentStore for MockAgentStore {
454        async fn get_agent(&self, _agent_id: AgentId) -> Result<Option<AgentDefinition>> {
455            Ok(self.agent.clone())
456        }
457    }
458
459    fn build_session(agent_id: Option<AgentId>) -> ExecutionSession {
460        ExecutionSession {
461            agent_id,
462            title: Some("Old title".to_string()),
463            model_id: Some(ModelId::new()),
464            status: SessionExecutionState::Idle,
465            ..ExecutionSession::with_own_workspace(SessionId::new(), HarnessId::new())
466        }
467    }
468
469    #[test]
470    fn session_tools_narrate_all_phases() {
471        use everruns_core::tool_narration::{ToolNarrationContext, ToolNarrationPhase};
472        use everruns_provider::tool_types::ToolCall;
473
474        let ctx = ToolNarrationContext::default();
475        let title_call = ToolCall {
476            id: "c1".to_string(),
477            name: "write_session_title".to_string(),
478            arguments: json!({ "title": "Improve tool narration" }),
479        };
480        assert_eq!(
481            WriteSessionTitleTool.narrate(&title_call, ToolNarrationPhase::Started, None, ctx),
482            Some("Updating session title: Improve tool narration".to_string())
483        );
484        assert_eq!(
485            WriteSessionTitleTool.narrate(&title_call, ToolNarrationPhase::Completed, None, ctx),
486            Some("Updated session title: Improve tool narration".to_string())
487        );
488        assert_eq!(
489            WriteSessionTitleTool.narrate(&title_call, ToolNarrationPhase::Failed, None, ctx),
490            Some("Failed to update session title: Improve tool narration".to_string())
491        );
492
493        // The capability surfaces the tool's narration via the default dispatch.
494        let cap = SessionCapability;
495        let def = WriteSessionTitleTool.to_definition();
496        assert_eq!(
497            cap.narrate(
498                Some(&def),
499                &title_call,
500                ToolNarrationPhase::Started,
501                None,
502                ctx
503            ),
504            Some("Updating session title: Improve tool narration".to_string())
505        );
506
507        let info_call = ToolCall {
508            id: "c2".to_string(),
509            name: "get_session_info".to_string(),
510            arguments: json!({}),
511        };
512        assert_eq!(
513            GetSessionInfoTool.narrate(&info_call, ToolNarrationPhase::Completed, None, ctx),
514            Some("Read session info".to_string())
515        );
516    }
517
518    #[tokio::test]
519    async fn write_session_title_updates_title() {
520        let session = build_session(None);
521        let session_id = session.id;
522        let stored = Arc::new(Mutex::new(Some(session.clone())));
523        let emitter = RecordingEventEmitter::default();
524        let turn_id = TurnId::new();
525        let input_message_id = MessageId::new();
526        let mut context = ToolContext::new(session_id);
527        context.session_store = Some(Arc::new(MockSessionStore { session: stored }));
528        context
529            .extensions
530            .insert(Arc::new(SessionMutatorExt(Arc::new(MockSessionMutator {
531                session: Arc::new(Mutex::new(session)),
532            }))));
533        context.event_emitter = Some(Arc::new(emitter.clone()));
534        context.event_context = Some(EventContext::turn(turn_id, input_message_id));
535
536        let tool = WriteSessionTitleTool;
537        let result = tool
538            .execute_with_context(json!({"title": "New title"}), &context)
539            .await;
540
541        match result {
542            ToolExecutionResult::Success(value) => {
543                assert_eq!(value["title"], "New title");
544                assert_eq!(value["updated"], true);
545            }
546            _ => panic!("expected success"),
547        }
548
549        let requests = emitter.requests.lock().expect("poisoned");
550        assert_eq!(requests.len(), 1);
551        assert_eq!(
552            requests[0].event_type,
553            everruns_core::events::SESSION_TITLE_UPDATED
554        );
555        assert_eq!(requests[0].context.turn_id, Some(turn_id));
556        assert_eq!(requests[0].context.input_message_id, Some(input_message_id));
557        match &requests[0].data {
558            everruns_core::events::EventData::SessionTitleUpdated(data) => {
559                assert_eq!(data.previous_title.as_deref(), Some("Old title"));
560                assert_eq!(data.title, "New title");
561            }
562            data => panic!("unexpected event data: {data:?}"),
563        }
564    }
565
566    #[tokio::test]
567    async fn write_session_title_is_noop_when_title_is_unchanged() {
568        let session = build_session(None);
569        let session_id = session.id;
570        let emitter = RecordingEventEmitter::default();
571        let mut context = ToolContext::new(session_id);
572        context.session_store = Some(Arc::new(MockSessionStore {
573            session: Arc::new(Mutex::new(Some(session.clone()))),
574        }));
575        context
576            .extensions
577            .insert(Arc::new(SessionMutatorExt(Arc::new(MockSessionMutator {
578                session: Arc::new(Mutex::new(session)),
579            }))));
580        context.event_emitter = Some(Arc::new(emitter.clone()));
581
582        let result = WriteSessionTitleTool
583            .execute_with_context(json!({"title": "Old title"}), &context)
584            .await;
585
586        match result {
587            ToolExecutionResult::Success(value) => assert_eq!(value["updated"], false),
588            _ => panic!("expected success"),
589        }
590        assert!(emitter.requests.lock().expect("poisoned").is_empty());
591    }
592
593    #[tokio::test]
594    async fn auto_title_policy_is_opt_in_and_mandatory_when_enabled() {
595        let capability = SessionCapability;
596        let ctx =
597            everruns_core::capabilities::SystemPromptContext::without_file_store(SessionId::new());
598
599        assert!(
600            capability
601                .system_prompt_contribution_with_config(&ctx, &json!({}))
602                .await
603                .is_none()
604        );
605        let prompt = capability
606            .system_prompt_contribution_with_config(&ctx, &json!({"auto_title": true}))
607            .await
608            .expect("auto-title prompt");
609        assert_eq!(
610            prompt,
611            "<capability id=\"session\">\nTitle maintenance is mandatory when automatic titles are enabled. On the first substantive user request, you MUST call `write_session_title` with a concise 3–7 word title for the conversation's primary theme before using any other tool, doing substantive work, or giving a substantive response. Ignore greetings, acknowledgements, and other non-substantive messages. On later turns, if the primary theme materially changes, you MUST call `write_session_title` before using any other tool, doing substantive work, or responding. You MUST NOT update the title for minor subtopics, follow-ups, or implementation details. Calling `write_session_title` updates session metadata only; it does not change project or workspace files and must not be treated as a project-file change.\n</capability>"
612        );
613    }
614
615    #[tokio::test]
616    async fn get_session_info_returns_agent_name_when_assigned() {
617        let agent_id = AgentId::new();
618        let session = build_session(Some(agent_id));
619        let session_id = session.id;
620
621        let agent = AgentDefinition {
622            display_name: Some("Research Agent".to_string()),
623            description: Some("desc".to_string()),
624            capabilities: vec![everruns_capability::CapabilityRef::new("session")],
625            ..AgentDefinition::new(agent_id, "research-agent", "prompt")
626        };
627
628        let context = ToolContext::new(session_id)
629            .with_session_store(Arc::new(MockSessionStore {
630                session: Arc::new(Mutex::new(Some(session))),
631            }))
632            .with_agent_store(Arc::new(MockAgentStore { agent: Some(agent) }));
633
634        let tool = GetSessionInfoTool;
635        let result = tool.execute_with_context(json!({}), &context).await;
636
637        match result {
638            ToolExecutionResult::Success(value) => {
639                assert_eq!(value["title"], "Old title");
640                assert_eq!(value["agent_name"], "Research Agent");
641                assert!(value["usage"].is_null());
642            }
643            _ => panic!("expected success"),
644        }
645    }
646
647    #[tokio::test]
648    async fn get_session_info_returns_cumulative_usage() {
649        let mut session = build_session(None);
650        session.usage = Some(TokenUsage::with_cache(120, 45, Some(30), Some(10)));
651        let session_id = session.id;
652
653        let context = ToolContext::new(session_id).with_session_store(Arc::new(MockSessionStore {
654            session: Arc::new(Mutex::new(Some(session))),
655        }));
656
657        let tool = GetSessionInfoTool;
658        let result = tool.execute_with_context(json!({}), &context).await;
659
660        match result {
661            ToolExecutionResult::Success(value) => {
662                assert_eq!(value["usage"]["input_tokens"], 120);
663                assert_eq!(value["usage"]["output_tokens"], 45);
664                assert_eq!(value["usage"]["cache_read_tokens"], 30);
665                assert_eq!(value["usage"]["cache_creation_tokens"], 10);
666                assert_eq!(value["usage"]["total_tokens"], 165);
667            }
668            _ => panic!("expected success"),
669        }
670    }
671}