Skip to main content

navi_core/
session_title.rs

1//! Session-title state shared between the runtime and the agent tool.
2
3use anyhow::{Context, Result};
4use async_trait::async_trait;
5use serde_json::json;
6use std::sync::atomic::{AtomicBool, Ordering};
7use std::sync::{Arc, Mutex};
8
9use crate::tool::{Tool, ToolDefinition, ToolInvocation, ToolKind, ToolResult};
10
11/// Session-local title state. The tool writes a pending title and the runtime
12/// applies it to the persisted session at the next safe lifecycle boundary.
13#[derive(Clone, Default)]
14pub struct SessionTitleHandle {
15    pending: Arc<Mutex<Option<String>>>,
16    assigned: Arc<AtomicBool>,
17}
18
19impl SessionTitleHandle {
20    pub fn new() -> Self {
21        Self::default()
22    }
23
24    pub fn is_assigned(&self) -> bool {
25        self.assigned.load(Ordering::SeqCst)
26    }
27
28    pub fn set(&self, title: &str) -> Result<String> {
29        let title = title.split_whitespace().collect::<Vec<_>>().join(" ");
30        let title = title.trim();
31        if title.is_empty() {
32            anyhow::bail!("session title must not be empty");
33        }
34        let title: String = title.chars().take(120).collect();
35        *self.pending.lock().unwrap_or_else(|e| e.into_inner()) = Some(title.clone());
36        self.assigned.store(true, Ordering::SeqCst);
37        Ok(title)
38    }
39
40    pub fn take(&self) -> Option<String> {
41        self.pending
42            .lock()
43            .unwrap_or_else(|e| e.into_inner())
44            .take()
45    }
46}
47
48/// Lets the chat model name the current session without starting a separate
49/// completion. The first turn is instructed to call this before doing work;
50/// later calls are reserved for material changes of topic.
51pub struct SessionTitleTool {
52    handle: SessionTitleHandle,
53}
54
55impl SessionTitleTool {
56    pub fn new(handle: SessionTitleHandle) -> Self {
57        Self { handle }
58    }
59}
60
61#[async_trait]
62impl Tool for SessionTitleTool {
63    fn definition(&self) -> ToolDefinition {
64        ToolDefinition::new(
65            "set_session_title",
66            "Set a concise title for the current session. Call this as the first action of a new session using the user's initial request. Call it again only when the session's primary objective changes materially.",
67            ToolKind::Read,
68            json!({
69                "type": "object",
70                "properties": {
71                    "title": {
72                        "type": "string",
73                        "description": "Concise, specific session title (maximum 120 characters)."
74                    }
75                },
76                "required": ["title"],
77                "additionalProperties": false
78            }),
79        )
80    }
81
82    async fn invoke(&self, invocation: ToolInvocation) -> Result<ToolResult> {
83        let title = invocation
84            .input
85            .get("title")
86            .and_then(serde_json::Value::as_str)
87            .context("missing required string `title`")?;
88        let title = self.handle.set(title)?;
89        Ok(ToolResult {
90            invocation_id: invocation.id,
91            ok: true,
92            output: json!({ "title": title }),
93        })
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    #[tokio::test]
102    async fn tool_normalizes_and_records_title() {
103        let handle = SessionTitleHandle::new();
104        let tool = SessionTitleTool::new(handle.clone());
105        let result = tool
106            .invoke(ToolInvocation {
107                id: "title-1".into(),
108                tool_name: "set_session_title".into(),
109                input: json!({ "title": "  Repair   prompt   cache  " }),
110            })
111            .await
112            .expect("title tool succeeds");
113
114        assert!(result.ok);
115        assert!(handle.is_assigned());
116        assert_eq!(handle.take().as_deref(), Some("Repair prompt cache"));
117    }
118}