feat(persona-chat): continue an SMS thread with a persona
Persona conversations can now be anchored on a message in an SMS thread. Creating one snapshots the thread up to that message (via SMS-API) into the row; every turn appends it to the persona prompt with guidance to talk like a text thread rather than write an insight. Anchored turns get four tools: get_thread_messages (page the thread), look_at_message_image (local vision on an MMS photo), get_photos_near_time (library photos with tags and people) and look_at_photo (local vision on a library photo). The conversation list filters by contact_id. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fdbcc75391
commit
4ac574d4d2
11 files changed
+1389
-40
No files matched your search
+393
-1
@@ -286,6 +286,12 @@ pub struct ToolGateOpts {
|
||||
/// tool name still 404s, and the agent doesn't waste iterations
|
||||
/// trying corrections it isn't allowed to do.
|
||||
pub allow_agent_corrections: bool,
|
||||
/// Persona chats anchored on an SMS thread ("Continue with persona")
|
||||
/// get the thread tools: page the thread, look at an MMS photo, find
|
||||
/// library photos around a moment and look at one. Off everywhere else
|
||||
/// — the file chat already has its photo, and an open chat has no
|
||||
/// thread to page.
|
||||
pub sms_thread_tools: bool,
|
||||
}
|
||||
|
||||
impl InsightGenerator {
|
||||
@@ -421,6 +427,12 @@ impl InsightGenerator {
|
||||
/// tool-definition gating (no point offering `get_personal_place_at`
|
||||
/// when Apollo is unreachable) — exposed publicly so `insight_chat`
|
||||
/// can call `build_tool_definitions` with the same flag.
|
||||
/// The SMS-API client, for callers outside the tool loop (the anchored
|
||||
/// persona chat snapshots a thread with it at creation).
|
||||
pub fn sms_client(&self) -> &SmsApiClient {
|
||||
&self.sms_client
|
||||
}
|
||||
|
||||
pub fn apollo_enabled(&self) -> bool {
|
||||
self.apollo_client.is_enabled()
|
||||
}
|
||||
@@ -485,6 +497,7 @@ impl InsightGenerator {
|
||||
location_history_present,
|
||||
faces_present,
|
||||
allow_agent_corrections,
|
||||
sms_thread_tools: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1925,6 +1938,13 @@ Return ONLY the summary, nothing else."#,
|
||||
.await
|
||||
}
|
||||
"get_current_datetime" => Self::tool_get_current_datetime(),
|
||||
"get_thread_messages" => self.tool_get_thread_messages(arguments).await,
|
||||
"look_at_message_image" => {
|
||||
self.tool_look_at_message_image(arguments, backend.local())
|
||||
.await
|
||||
}
|
||||
"get_photos_near_time" => self.tool_get_photos_near_time(arguments).await,
|
||||
"look_at_photo" => self.tool_look_at_photo(arguments, backend.local()).await,
|
||||
unknown => format!("Unknown tool: {}", unknown),
|
||||
};
|
||||
if result.starts_with("Error") || result.starts_with("No ") {
|
||||
@@ -2386,6 +2406,256 @@ Return ONLY the summary, nothing else."#,
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool: get_thread_messages — page the anchored SMS thread from a
|
||||
/// message id. Lines are rendered exactly like the anchor snapshot so
|
||||
/// the model can keep paging from any id it sees.
|
||||
async fn tool_get_thread_messages(&self, args: &serde_json::Value) -> String {
|
||||
let Some(contact_id) = args.get("contact_id").and_then(|v| v.as_i64()) else {
|
||||
return "Error: missing required parameter 'contact_id'".to_string();
|
||||
};
|
||||
let Some(message_id) = args.get("message_id").and_then(|v| v.as_i64()) else {
|
||||
return "Error: missing required parameter 'message_id'".to_string();
|
||||
};
|
||||
let limit = args
|
||||
.get("limit")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(30)
|
||||
.clamp(1, 80) as usize;
|
||||
let (before, after) = match args.get("direction").and_then(|v| v.as_str()) {
|
||||
Some("after") => (0, limit),
|
||||
Some("around") => (limit / 2, limit - limit / 2),
|
||||
_ => (limit, 0),
|
||||
};
|
||||
|
||||
match self
|
||||
.sms_client
|
||||
.fetch_thread_window(contact_id, message_id, before, after)
|
||||
.await
|
||||
{
|
||||
Ok(window) if window.messages.len() > 1 => {
|
||||
let rendered = crate::ai::sms_anchor::render_thread_window(
|
||||
&window,
|
||||
&user_display_name(),
|
||||
self.display_zone(),
|
||||
);
|
||||
format!(
|
||||
"Found {} messages with {} (▶ marks #{}):\n{}",
|
||||
window.messages.len(),
|
||||
window.contact.display_name(),
|
||||
message_id,
|
||||
rendered
|
||||
)
|
||||
}
|
||||
Ok(_) => "No more messages in that direction.".to_string(),
|
||||
Err(e) => {
|
||||
log::warn!("tool_get_thread_messages failed: {}", e);
|
||||
format!("Error fetching thread messages: {}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool: look_at_message_image — download an MMS photo from SMS-API and
|
||||
/// describe it with the local vision model.
|
||||
async fn tool_look_at_message_image(
|
||||
&self,
|
||||
args: &serde_json::Value,
|
||||
local: &dyn LlmClient,
|
||||
) -> String {
|
||||
let Some(contact_id) = args.get("contact_id").and_then(|v| v.as_i64()) else {
|
||||
return "Error: missing required parameter 'contact_id'".to_string();
|
||||
};
|
||||
let Some(message_id) = args.get("message_id").and_then(|v| v.as_i64()) else {
|
||||
return "Error: missing required parameter 'message_id'".to_string();
|
||||
};
|
||||
let media_index = args
|
||||
.get("media_index")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as usize;
|
||||
|
||||
let bytes = match self
|
||||
.sms_client
|
||||
.fetch_mms_media(contact_id, message_id, media_index)
|
||||
.await
|
||||
{
|
||||
Ok(b) => b,
|
||||
Err(e) => return format!("Error fetching attachment: {}", e),
|
||||
};
|
||||
let image_base64 = match Self::encode_image_bytes_for_vision(&bytes) {
|
||||
Ok(b64) => b64,
|
||||
Err(_) => {
|
||||
return "Error: that attachment isn't a viewable image (videos, audio and some \
|
||||
formats can't be looked at)."
|
||||
.to_string();
|
||||
}
|
||||
};
|
||||
match local.describe_image(&image_base64).await {
|
||||
Ok(desc) => desc,
|
||||
Err(e) => format!("Error describing attachment: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool: get_photos_near_time — library photos within ±hours of a
|
||||
/// moment, closest first, with tags and recognised people.
|
||||
async fn tool_get_photos_near_time(&self, args: &serde_json::Value) -> String {
|
||||
let zone = self.display_zone();
|
||||
let server_zone = crate::camera_tz::server_zone();
|
||||
let Some(center) = Self::parse_moment(args, zone) else {
|
||||
return "Error: pass 'timestamp' (unix seconds) or 'datetime' (YYYY-MM-DD HH:MM)"
|
||||
.to_string();
|
||||
};
|
||||
let hours = args
|
||||
.get("hours_radius")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(3)
|
||||
.clamp(1, 72);
|
||||
let limit = args
|
||||
.get("limit")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(15)
|
||||
.clamp(1, 40) as usize;
|
||||
|
||||
let range = crate::date_view::InstantRange::new(
|
||||
Some(center - hours * 3600),
|
||||
Some(center + hours * 3600),
|
||||
zone,
|
||||
server_zone,
|
||||
);
|
||||
let rows = {
|
||||
let mut dao = self.exif_dao.lock().expect("Unable to lock ExifDao");
|
||||
match dao.query_by_exif(None, None, None, None, None, range) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return format!("Error querying photos: {:?}", e),
|
||||
}
|
||||
};
|
||||
|
||||
// Closest first; the same bytes in two libraries list once.
|
||||
let mut dated: Vec<(i64, crate::database::models::ImageExif)> = rows
|
||||
.into_iter()
|
||||
.filter_map(|r| {
|
||||
crate::date_view::row_true_instant(&r, zone, server_zone).map(|t| (t, r))
|
||||
})
|
||||
.collect();
|
||||
dated.sort_by_key(|(t, _)| (t - center).abs());
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
dated.retain(|(_, r)| match &r.content_hash {
|
||||
Some(h) => seen.insert(h.clone()),
|
||||
None => true,
|
||||
});
|
||||
let total = dated.len();
|
||||
if total == 0 {
|
||||
return format!("No photos within ±{} hours of that moment.", hours);
|
||||
}
|
||||
|
||||
let mut lines = Vec::new();
|
||||
for (_, row) in dated.into_iter().take(limit) {
|
||||
let when = crate::date_view::row_display_wall(&row, zone, server_zone)
|
||||
.map(|w| w.format("%Y-%m-%d %H:%M").to_string())
|
||||
.unwrap_or_else(|| "unknown time".to_string());
|
||||
let tags: Vec<String> = {
|
||||
let mut dao = self.tag_dao.lock().expect("Unable to lock TagDao");
|
||||
dao.get_tags_for_path(&row.file_path)
|
||||
.map(|t| t.into_iter().map(|t| t.name).collect())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let people: Vec<String> = match &row.content_hash {
|
||||
Some(hash) => {
|
||||
let mut dao = self.face_dao.lock().expect("Unable to lock FaceDao");
|
||||
let mut names: Vec<String> = dao
|
||||
.list_for_content_hash(hash)
|
||||
.map(|faces| faces.into_iter().filter_map(|f| f.person_name).collect())
|
||||
.unwrap_or_default();
|
||||
names.sort();
|
||||
names.dedup();
|
||||
names
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
let mut line = format!("- {} — {}", row.file_path, when);
|
||||
if !tags.is_empty() {
|
||||
line.push_str(&format!(" · tags: {}", tags.join(", ")));
|
||||
}
|
||||
if !people.is_empty() {
|
||||
line.push_str(&format!(" · people: {}", people.join(", ")));
|
||||
}
|
||||
if crate::file_types::is_video_file(std::path::Path::new(&row.file_path)) {
|
||||
line.push_str(" · video");
|
||||
}
|
||||
lines.push(line);
|
||||
}
|
||||
format!(
|
||||
"{}\n{}",
|
||||
Self::found_header(total, lines.len(), "photos"),
|
||||
lines.join("\n")
|
||||
)
|
||||
}
|
||||
|
||||
/// Tool: look_at_photo — describe a library photo by path with the
|
||||
/// local vision model.
|
||||
async fn tool_look_at_photo(&self, args: &serde_json::Value, local: &dyn LlmClient) -> String {
|
||||
let file_path = match args.get("file_path").and_then(|v| v.as_str()) {
|
||||
Some(p) if Self::photo_path_is_safe(p.trim()) => p.trim().to_string(),
|
||||
Some(_) => return "Error: file_path must be a library-relative path".to_string(),
|
||||
None => return "Error: missing required parameter 'file_path'".to_string(),
|
||||
};
|
||||
if crate::file_types::is_video_file(std::path::Path::new(&file_path)) {
|
||||
return "Error: that's a video — only photos can be looked at.".to_string();
|
||||
}
|
||||
let image_base64 = match self.load_image_as_base64(&file_path) {
|
||||
Ok(b) => b,
|
||||
Err(e) => return format!("Error loading photo: {}", e),
|
||||
};
|
||||
match local.describe_image(&image_base64).await {
|
||||
Ok(desc) => desc,
|
||||
Err(e) => format!("Error describing photo: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
/// A model-supplied path is joined onto each library root, so refuse
|
||||
/// anything that could climb out of it.
|
||||
fn photo_path_is_safe(path: &str) -> bool {
|
||||
use std::path::{Component, Path};
|
||||
!path.is_empty()
|
||||
&& Path::new(path)
|
||||
.components()
|
||||
.all(|c| matches!(c, Component::Normal(_) | Component::CurDir))
|
||||
}
|
||||
|
||||
/// A tool's centre moment: `timestamp` (unix seconds) wins, else
|
||||
/// `datetime` read as a wall clock in the owner's display zone. A bare
|
||||
/// date centres on midday.
|
||||
fn parse_moment(args: &serde_json::Value, zone: chrono_tz::Tz) -> Option<i64> {
|
||||
if let Some(ts) = args.get("timestamp").and_then(|v| v.as_i64()) {
|
||||
return Some(ts);
|
||||
}
|
||||
let raw = args.get("datetime").and_then(|v| v.as_str())?.trim();
|
||||
let naive = chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%d %H:%M")
|
||||
.or_else(|_| chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M"))
|
||||
.or_else(|_| chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S"))
|
||||
.ok()
|
||||
.or_else(|| {
|
||||
NaiveDate::parse_from_str(raw, "%Y-%m-%d")
|
||||
.ok()
|
||||
.and_then(|d| d.and_hms_opt(12, 0, 0))
|
||||
})?;
|
||||
zone.from_local_datetime(&naive)
|
||||
.earliest()
|
||||
.map(|dt| dt.timestamp())
|
||||
}
|
||||
|
||||
/// Decode arbitrary image bytes (an MMS attachment), shrink to the
|
||||
/// vision models' 1024px working size and re-encode as base64 JPEG —
|
||||
/// the same shape `load_image_as_base64` produces for library files.
|
||||
fn encode_image_bytes_for_vision(bytes: &[u8]) -> Result<String> {
|
||||
use image::imageops::FilterType;
|
||||
let img = image::load_from_memory(bytes)?;
|
||||
// JPEG has no alpha channel; a PNG screenshot would fail to encode.
|
||||
let resized =
|
||||
image::DynamicImage::ImageRgb8(img.resize(1024, 1024, FilterType::Lanczos3).to_rgb8());
|
||||
let mut buffer = Vec::new();
|
||||
resized.write_to(&mut Cursor::new(&mut buffer), ImageFormat::Jpeg)?;
|
||||
Ok(base64::engine::general_purpose::STANDARD.encode(&buffer))
|
||||
}
|
||||
|
||||
/// Tool: get_calendar_events — fetch calendar events near a date
|
||||
async fn tool_get_calendar_events(&self, args: &serde_json::Value) -> String {
|
||||
let date_str = match args.get("date").and_then(|v| v.as_str()) {
|
||||
@@ -3781,6 +4051,77 @@ Return ONLY the summary, nothing else."#,
|
||||
}),
|
||||
));
|
||||
|
||||
if opts.sms_thread_tools {
|
||||
tools.push(Tool::function(
|
||||
"get_thread_messages",
|
||||
"Read more of the SMS thread this chat was opened from. Pages from a message id in the \
|
||||
thread (the #numbers in the transcript) — `before` for older messages, `after` for what \
|
||||
came next, `around` for both sides. Lines carry their own #id, so call again from the \
|
||||
oldest/newest id to keep paging. \
|
||||
Example: `{contact_id: 42, message_id: 812, direction: \"before\", limit: 30}`.",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"required": ["contact_id", "message_id"],
|
||||
"properties": {
|
||||
"contact_id": { "type": "integer", "description": "The thread's contact_id (given in the system prompt)." },
|
||||
"message_id": { "type": "integer", "description": "Message id to page from." },
|
||||
"direction": { "type": "string", "enum": ["before", "after", "around"], "description": "Which side of message_id to read (default before)." },
|
||||
"limit": { "type": "integer", "description": "Max messages (default 30, max 80)." }
|
||||
}
|
||||
}),
|
||||
));
|
||||
|
||||
tools.push(Tool::function(
|
||||
"look_at_message_image",
|
||||
"Look at a photo that was sent in the SMS thread, using the local vision model. Returns a \
|
||||
visual description. Attachments appear in the transcript as `image #N` on a message line — \
|
||||
pass that message's id and N as media_index. Videos and audio can't be viewed. \
|
||||
Example: `{contact_id: 42, message_id: 815, media_index: 0}`.",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"required": ["contact_id", "message_id"],
|
||||
"properties": {
|
||||
"contact_id": { "type": "integer", "description": "The thread's contact_id." },
|
||||
"message_id": { "type": "integer", "description": "Id of the message carrying the photo." },
|
||||
"media_index": { "type": "integer", "description": "Which attachment on that message (default 0)." }
|
||||
}
|
||||
}),
|
||||
));
|
||||
|
||||
tools.push(Tool::function(
|
||||
"get_photos_near_time",
|
||||
"List photos and videos from the user's library taken around a moment, closest first, with \
|
||||
their user-applied tags and the names of people recognised in them. Use to see what was \
|
||||
happening around a message (pass its time). Follow up with `look_at_photo` to see one. \
|
||||
Pass either `timestamp` (unix seconds) or `datetime` (local, \"YYYY-MM-DD HH:MM\" or \
|
||||
\"YYYY-MM-DD\"). \
|
||||
Example: `{datetime: \"2019-06-12 18:30\", hours_radius: 4}`.",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"timestamp": { "type": "integer", "description": "Centre moment, unix seconds." },
|
||||
"datetime": { "type": "string", "description": "Centre moment in local time, YYYY-MM-DD HH:MM (or YYYY-MM-DD for the whole day)." },
|
||||
"hours_radius": { "type": "integer", "description": "Hours before and after to include (default 3, max 72)." },
|
||||
"limit": { "type": "integer", "description": "Max photos (default 15, max 40)." }
|
||||
}
|
||||
}),
|
||||
));
|
||||
|
||||
tools.push(Tool::function(
|
||||
"look_at_photo",
|
||||
"Look at one photo from the user's library with the local vision model and return a visual \
|
||||
description — people, place, activity, mood. Pass a file_path from `get_photos_near_time`. \
|
||||
Example: `{file_path: \"2019/06/IMG_4242.jpg\"}`.",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"required": ["file_path"],
|
||||
"properties": {
|
||||
"file_path": { "type": "string", "description": "Library-relative path of the photo." }
|
||||
}
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
if opts.has_vision {
|
||||
tools.push(Tool::function(
|
||||
"describe_photo",
|
||||
@@ -3915,7 +4256,8 @@ Return ONLY the summary, nothing else."#,
|
||||
&& let Ok(n) = n_str.parse::<usize>()
|
||||
{
|
||||
let kind = match tool_name {
|
||||
"search_messages" | "get_sms_messages" => "messages",
|
||||
"search_messages" | "get_sms_messages" | "get_thread_messages" => "messages",
|
||||
"get_photos_near_time" => "photos",
|
||||
"get_calendar_events" => "events",
|
||||
"get_location_history" => "location records",
|
||||
_ => "results",
|
||||
@@ -3964,6 +4306,10 @@ Return ONLY the summary, nothing else."#,
|
||||
.map(|id| format!("stored id={}", id.trim_end_matches(',')))
|
||||
.unwrap_or_else(|| "stored".to_string()),
|
||||
"get_current_datetime" => "time noted".to_string(),
|
||||
"look_at_message_image" | "look_at_photo" => {
|
||||
let short: String = raw.chars().take(80).collect();
|
||||
format!("looked: \"{}...\"", short)
|
||||
}
|
||||
_ => format!("{} chars", raw.len()),
|
||||
}
|
||||
}
|
||||
@@ -4882,6 +5228,7 @@ mod tests {
|
||||
location_history_present: false,
|
||||
faces_present: false,
|
||||
allow_agent_corrections: false,
|
||||
sms_thread_tools: false,
|
||||
};
|
||||
let tools = InsightGenerator::build_tool_definitions(opts);
|
||||
let names: Vec<&str> = tools.iter().map(|t| t.function.name.as_str()).collect();
|
||||
@@ -4908,6 +5255,11 @@ mod tests {
|
||||
// Agent-correction tools are absent without the gate.
|
||||
assert!(!names.contains(&"update_fact"));
|
||||
assert!(!names.contains(&"supersede_fact"));
|
||||
// SMS-thread tools only exist for an anchored persona chat.
|
||||
assert!(!names.contains(&"get_thread_messages"));
|
||||
assert!(!names.contains(&"look_at_message_image"));
|
||||
assert!(!names.contains(&"get_photos_near_time"));
|
||||
assert!(!names.contains(&"look_at_photo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4920,6 +5272,7 @@ mod tests {
|
||||
location_history_present: true,
|
||||
faces_present: true,
|
||||
allow_agent_corrections: true,
|
||||
sms_thread_tools: true,
|
||||
};
|
||||
let tools = InsightGenerator::build_tool_definitions(opts);
|
||||
let names: Vec<&str> = tools.iter().map(|t| t.function.name.as_str()).collect();
|
||||
@@ -4931,6 +5284,45 @@ mod tests {
|
||||
assert!(names.contains(&"get_faces_in_photo"));
|
||||
assert!(names.contains(&"update_fact"));
|
||||
assert!(names.contains(&"supersede_fact"));
|
||||
assert!(names.contains(&"get_thread_messages"));
|
||||
assert!(names.contains(&"look_at_message_image"));
|
||||
assert!(names.contains(&"get_photos_near_time"));
|
||||
assert!(names.contains(&"look_at_photo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn photo_path_is_safe_rejects_escapes() {
|
||||
assert!(InsightGenerator::photo_path_is_safe("2019/06/IMG_1.jpg"));
|
||||
assert!(!InsightGenerator::photo_path_is_safe("../etc/passwd"));
|
||||
assert!(!InsightGenerator::photo_path_is_safe("2019/../../x.jpg"));
|
||||
assert!(!InsightGenerator::photo_path_is_safe("/etc/passwd"));
|
||||
assert!(!InsightGenerator::photo_path_is_safe(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_moment_accepts_unix_and_local_wall_clock() {
|
||||
let tz = chrono_tz::Tz::America__New_York;
|
||||
assert_eq!(
|
||||
InsightGenerator::parse_moment(&serde_json::json!({"timestamp": 1_700_000_000}), tz),
|
||||
Some(1_700_000_000)
|
||||
);
|
||||
// 2023-11-14 17:13 in New York (EST) = 22:13 UTC.
|
||||
assert_eq!(
|
||||
InsightGenerator::parse_moment(
|
||||
&serde_json::json!({"datetime": "2023-11-14 17:13"}),
|
||||
tz
|
||||
),
|
||||
Some(1_699_999_980)
|
||||
);
|
||||
// A bare date means midday, the least-wrong centre for a whole day.
|
||||
assert_eq!(
|
||||
InsightGenerator::parse_moment(&serde_json::json!({"datetime": "2023-11-14"}), tz),
|
||||
Some(1_699_981_200)
|
||||
);
|
||||
assert_eq!(
|
||||
InsightGenerator::parse_moment(&serde_json::json!({}), tz),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
fn place(name: &str, description: &str) -> ApolloPlace {
|
||||
|
||||
Reference in new issue
Block a user