1use 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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(deny_unknown_fields)]
27pub struct SessionCapabilityConfig {
28 #[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#[derive(Debug, Clone)]
41pub struct SessionTitleMutation {
42 pub session: ExecutionSession,
44 pub changed: bool,
46}
47
48pub 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
71pub 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
111pub 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
195pub 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
293pub 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 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}