1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
//! Names ACP sessions for the host, `/resume`, and `rho sessions`, as the TUI
//! names its own: after the first turn a host completes, a background
//! title-agent call turns that exchange into a short title, unless the
//! session has one. A first turn the host cancelled does not count, and a
//! loaded session that was never named gets its title then. Until it lands,
//! or if it fails, the session is listed by its first message.
//!
//! The host only asks; the agent runs the request once the prompt returns
//! and sends the title it saves as `session_info_update`, if the host that
//! asked still owns the session (`agent_titles.rs`).
use std::{path::PathBuf, sync::Arc};
use agent_client_protocol::schema::v1::SessionId;
use rho_sdk::{
model::{ContentBlock, Message},
CancellationToken, UserInput,
};
use super::{model_scoped_config, PromptGate, SessionHost};
use crate::{config::Config, session::Session as StoredSession, title::TitleModel};
/// A title one host asked for, not yet generated.
pub(in crate::app::acp) struct PendingTitle {
pub(in crate::app::acp) session_id: SessionId,
/// The asking host's prompt gate, which its published slot shares, so
/// the agent can tell that host from a successor with the same ID.
pub(in crate::app::acp) asking_gate: Arc<PromptGate>,
pub(in crate::app::acp) job: TitleJob,
}
/// What the title agent needs to name one session.
pub(in crate::app::acp) struct TitleJob {
model: TitleModel,
/// The first exchange, as the title agent reads it.
input: String,
sdk_session_id: rho_sdk::SessionId,
stored_id: String,
cwd: PathBuf,
}
impl TitleJob {
/// Generates and saves the title. Yields it only when this request
/// saved it: a title someone else set first wins and is not resent.
pub(in crate::app::acp) async fn generate(self) -> Option<String> {
let Self {
model,
input,
sdk_session_id,
stored_id,
cwd,
} = self;
if StoredSession::title_is_set(&cwd, &stored_id).unwrap_or(false) {
return None;
}
let usage_recording = crate::usage::default_recording().await;
let title = crate::title::generate_title(
model,
input,
sdk_session_id,
cwd.clone(),
usage_recording,
CancellationToken::new(),
)
.await;
match title.and_then(|title| StoredSession::set_generated_title(&cwd, &stored_id, &title)) {
Ok(saved) => saved.map(|update| update.title),
Err(error) => {
tracing::debug!(%error, "could not title ACP session");
None
}
}
}
}
impl SessionHost {
/// Asks to name the session from `user`'s message and the reply the
/// turn ended with, once per host, for [`Self::take_title`].
pub(super) fn request_title(&mut self, process_config: &Config, user: &UserInput) {
let Some(stored) = self.storage.file() else {
return;
};
let history = self.built.session.history();
let Some(reply) = history
.last()
.and_then(Message::completed_assistant_content)
else {
return;
};
self.title_requested = true;
self.pending_title = Some(PendingTitle {
session_id: self.acp_session_id.clone(),
asking_gate: Arc::clone(&self.prompt_gate),
job: TitleJob {
model: crate::title::title_model_from_config(&model_scoped_config(
&self.current_model(),
process_config,
)),
input: crate::title::first_turn_input(&text_of(user.blocks()), &text_of(reply)),
sdk_session_id: self.built.session.id().clone(),
stored_id: stored.id().to_owned(),
cwd: self.storage.cwd().to_path_buf(),
},
});
}
/// The title the last prompt asked for. The prompt stores it as its last
/// step, so a prompt dropped mid-await never strands one, and the agent
/// takes it as soon as the prompt returns.
pub(in crate::app::acp) fn take_title(&mut self) -> Option<PendingTitle> {
self.pending_title.take()
}
}
fn text_of(blocks: &[ContentBlock]) -> String {
blocks
.iter()
.filter_map(|block| match block {
ContentBlock::Text(text) => Some(text.as_str()),
ContentBlock::Image(_) | ContentBlock::ToolCall(_) => None,
})
.collect::<Vec<_>>()
.join("\n")
}