From 3f1bf42f1c3fca88eb318e557a2105f862a091f0 Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Sat, 17 Oct 2026 18:39:27 -0400 Subject: [PATCH] =?UTF-8?q?feat(persona-chat):=20practice=20mode=20?= =?UTF-8?q?=E2=80=94=20play=20the=20contact?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An anchored conversation now has a mode. 'about' is the existing behaviour (a persona talks with the user about the thread). 'as_contact' has the model play the contact so the user can rehearse the conversation: its own prompt (stay in the contact's voice, don't invent facts, coach on 'out of character'), no persona prompt, and only the thread tools — no memory, library, calendar or other-thread access, and no knowledge writes. The shared persona/file-chat loop now refuses tool calls that weren't in the turn's catalog, so the gates hold even when a model names a tool it wasn't offered. Co-Authored-By: Claude Opus 5.5 --- .../down.sql | 1 + .../up.sql | 5 + src/ai/insight_chat.rs | 38 +++- src/ai/persona_chat.rs | 36 ++++ src/ai/sms_anchor.rs | 180 ++++++++++++++++-- src/database/mod.rs | 4 +- src/database/persona_dao.rs | 48 +++++ src/database/schema.rs | 1 + 8 files changed, 284 insertions(+), 29 deletions(-) diff --git a/migrations/2026-10-19-000000_persona_chat_sms_anchor/down.sql b/migrations/2026-10-19-000000_persona_chat_sms_anchor/down.sql index 4f3d254..7d3f23e 100644 --- a/migrations/2026-10-19-000000_persona_chat_sms_anchor/down.sql +++ b/migrations/2026-10-19-000000_persona_chat_sms_anchor/down.sql @@ -1,4 +1,5 @@ DROP INDEX IF EXISTS idx_persona_chat_anchor_contact; +ALTER TABLE persona_chat_conversations DROP COLUMN anchor_mode; ALTER TABLE persona_chat_conversations DROP COLUMN anchor_context; ALTER TABLE persona_chat_conversations DROP COLUMN anchor_ts; ALTER TABLE persona_chat_conversations DROP COLUMN anchor_message_id; diff --git a/migrations/2026-10-19-000000_persona_chat_sms_anchor/up.sql b/migrations/2026-10-19-000000_persona_chat_sms_anchor/up.sql index 2ccc5e9..ba8cd82 100644 --- a/migrations/2026-10-19-000000_persona_chat_sms_anchor/up.sql +++ b/migrations/2026-10-19-000000_persona_chat_sms_anchor/up.sql @@ -11,6 +11,10 @@ -- prefix byte-stable for KV-cache reuse and keeps the persona's view of the -- thread fixed even if SMS-API is re-imported underneath it. -- +-- `anchor_mode` is how the chat relates to the thread: 'about' (a persona +-- talks with the user about it) or 'as_contact' (the model plays the +-- contact so the user can practise the conversation). NULL reads as 'about'. +-- -- All nullable: an unanchored (open) persona chat leaves them NULL. ALTER TABLE persona_chat_conversations ADD COLUMN anchor_contact_id BIGINT; @@ -18,6 +22,7 @@ ALTER TABLE persona_chat_conversations ADD COLUMN anchor_contact_name TEXT; ALTER TABLE persona_chat_conversations ADD COLUMN anchor_message_id BIGINT; ALTER TABLE persona_chat_conversations ADD COLUMN anchor_ts BIGINT; ALTER TABLE persona_chat_conversations ADD COLUMN anchor_context TEXT; +ALTER TABLE persona_chat_conversations ADD COLUMN anchor_mode TEXT; CREATE INDEX idx_persona_chat_anchor_contact ON persona_chat_conversations (user_id, anchor_contact_id, updated_at DESC); diff --git a/src/ai/insight_chat.rs b/src/ai/insight_chat.rs index 41c4599..2cad73b 100644 --- a/src/ai/insight_chat.rs +++ b/src/ai/insight_chat.rs @@ -2466,6 +2466,13 @@ pub async fn run_streaming_agentic_loop_with_entry( let mut last_prompt_eval_count: Option = None; let mut last_eval_count: Option = None; let mut final_content = String::new(); + // Only tools in this turn's catalog may run. The catalog is how every + // gate (per-persona corrections, practice mode's narrowed set, absent + // backing tables) is expressed, and `execute_tool` dispatches on name + // alone — without this a model that names a tool it was never offered + // (one it saw in an earlier turn, or simply guessed) would still run it. + let offered: std::collections::HashSet = + tools.iter().map(|t| t.function.name.clone()).collect(); for iteration in 0..max_iterations { if !entry.is_running() { @@ -2536,17 +2543,28 @@ pub async fn run_streaming_agentic_loop_with_entry( arguments: tool_call.function.arguments.clone(), }) .await; - let result = generator - .execute_tool( - &tool_call.function.name, - &tool_call.function.arguments, - backend, - image_base64, - normalized, - user_id, - active_persona, + let result = if offered.contains(&tool_call.function.name) { + generator + .execute_tool( + &tool_call.function.name, + &tool_call.function.arguments, + backend, + image_base64, + normalized, + user_id, + active_persona, + ) + .await + } else { + log::warn!( + "refusing tool '{}': not offered this turn", + tool_call.function.name + ); + format!( + "Error: tool '{}' is not available here.", + tool_call.function.name ) - .await; + }; let (result_preview, result_truncated) = truncate_tool_result(&result); let _ = entry .push_event(ChatStreamEvent::ToolResult { diff --git a/src/ai/persona_chat.rs b/src/ai/persona_chat.rs index 38bc62b..f52c849 100644 --- a/src/ai/persona_chat.rs +++ b/src/ai/persona_chat.rs @@ -242,6 +242,9 @@ pub struct PersonaChatAnchorView { pub timestamp: i64, /// The anchor message as "Speaker: text", recovered from the snapshot. pub preview: String, + /// `about` (a persona discusses the thread) or `as_contact` (the model + /// plays the contact for practice). + pub mode: crate::database::AnchorMode, } impl From<&crate::database::SmsAnchor> for PersonaChatAnchorView { @@ -252,6 +255,7 @@ impl From<&crate::database::SmsAnchor> for PersonaChatAnchorView { message_id: a.message_id, timestamp: a.timestamp, preview: crate::ai::sms_anchor::anchor_preview(&a.context), + mode: a.mode, } } } @@ -596,6 +600,7 @@ impl PersonaChatSession { persona_id: &str, contact_id: i64, message_id: i64, + mode: crate::database::AnchorMode, ) -> Result { let persona_id = persona_id.trim(); { @@ -630,6 +635,7 @@ impl PersonaChatSession { message_id, timestamp: window.messages[window.anchor_index].timestamp, context, + mode, }; let mut dao = self.persona_dao.lock().expect("persona_dao poisoned"); @@ -935,6 +941,12 @@ impl PersonaChatSession { ); gate_opts.sms_thread_tools = row.anchor.is_some(); let tools = InsightGenerator::build_tool_definitions(gate_opts); + // Practice mode narrows the catalog to the thread itself — no + // memory, library or calendar access for a simulated contact. + let tools = match &row.anchor { + Some(anchor) => crate::ai::sms_anchor::tools_for_mode(tools, anchor.mode), + None => tools, + }; // Append the new user turn + apply the per-turn system-prompt // override. Per-persona overrides are NOT persisted (the persona @@ -1368,6 +1380,9 @@ pub struct PersonaChatCreateRequest { pub struct PersonaChatAnchorRequest { pub contact_id: i64, pub message_id: i64, + /// `about` (default) or `as_contact`. + #[serde(default)] + pub mode: crate::database::AnchorMode, } /// Query params for GET /persona_chat/conversations. @@ -1489,6 +1504,7 @@ pub async fn persona_chat_create_conversation_handler( &request.persona_id, anchor.contact_id, anchor.message_id, + anchor.mode, ) .await } @@ -2018,6 +2034,24 @@ mod tests { assert!(tracker.claim("conv-a", "t7").is_ok()); } + #[test] + fn create_request_anchor_mode_defaults_to_about() { + use crate::database::AnchorMode; + let req: PersonaChatCreateRequest = serde_json::from_str( + r#"{"persona_id":"journal","anchor":{"contact_id":42,"message_id":812}}"#, + ) + .unwrap(); + assert_eq!(req.anchor.unwrap().mode, AnchorMode::About); + let req: PersonaChatCreateRequest = serde_json::from_str( + r#"{"persona_id":"journal","anchor":{"contact_id":42,"message_id":812,"mode":"as_contact"}}"#, + ) + .unwrap(); + assert_eq!(req.anchor.unwrap().mode, AnchorMode::AsContact); + let req: PersonaChatCreateRequest = + serde_json::from_str(r#"{"persona_id":"journal"}"#).unwrap(); + assert!(req.anchor.is_none()); + } + #[test] fn persona_chat_turn_request_deserializes_snake_case_wire_body() { // Pins the mobile-client contract: snake_case keys, same as the @@ -2273,12 +2307,14 @@ mod tests { message_id: 812, timestamp: 1_700_000_000, preview: "Sam: see you at 6".to_string(), + mode: crate::database::AnchorMode::AsContact, }), }; let json = serde_json::to_value(&summary).unwrap(); assert_eq!(json["anchor"]["contact_id"], 42); assert_eq!(json["anchor"]["message_id"], 812); assert_eq!(json["anchor"]["preview"], "Sam: see you at 6"); + assert_eq!(json["anchor"]["mode"], "as_contact"); assert_eq!(json["conversation_id"], "conv-1"); assert_eq!(json["title"], "June recap"); assert_eq!(json["persona_id"], "journal"); diff --git a/src/ai/sms_anchor.rs b/src/ai/sms_anchor.rs index d1be59a..65018f3 100644 --- a/src/ai/sms_anchor.rs +++ b/src/ai/sms_anchor.rs @@ -7,7 +7,10 @@ //! - rendering a slice of the thread as a compact transcript the model can //! read (and that names message ids / attachment indices, so the thread //! tools can page further back or look at a photo), -//! - the conversational guidance appended to the persona's system prompt. +//! - the system prompt for each [`AnchorMode`]: a persona talking with the +//! user *about* the thread, or the model playing the contact so the user +//! can practise the conversation, +//! - which tools each mode is offered. //! //! The network side (`SmsApiClient::fetch_thread_window`) and the tools //! (`get_thread_messages`, `look_at_message_image`, `get_photos_near_time`, @@ -16,8 +19,17 @@ use chrono::{DateTime, TimeZone}; use chrono_tz::Tz; +use crate::ai::ollama::Tool; use crate::ai::sms_client::{ThreadMessage, ThreadWindow}; -use crate::database::SmsAnchor; +use crate::database::{AnchorMode, SmsAnchor}; + +/// Everything a simulated contact may call: the thread they were part of +/// and the photos sent in it. Nothing from the user's own life — library, +/// memories, calendar, location, other threads — which the real contact +/// would never see, and no knowledge writes: facts recorded while the model +/// is improvising someone's side of a conversation would be fiction filed +/// as memory. +const PRACTICE_TOOLS: &[&str] = &["get_thread_messages", "look_at_message_image"]; /// Messages of history before the anchor captured into the system prompt. /// Enough to pick up tone and the running topic; the model can page @@ -110,22 +122,32 @@ pub fn anchor_preview(context: &str) -> String { .unwrap_or_default() } -/// The persona's system prompt with the anchored-thread section appended. -/// -/// Re-composed every turn from the persona's current prompt and the stored -/// anchor snapshot, so editing a persona still reaches conversations already -/// in flight (as it does for open chats) while the thread snapshot itself -/// stays byte-stable. -pub fn anchored_system_prompt( - persona_prompt: &str, - anchor: &SmsAnchor, - user_name: &str, - tz: Tz, -) -> String { +/// The tool catalog narrowed to what `mode` may use. `About` keeps the full +/// persona catalog; `AsContact` keeps only [`PRACTICE_TOOLS`]. +pub fn tools_for_mode(tools: Vec, mode: AnchorMode) -> Vec { + match mode { + AnchorMode::About => tools, + AnchorMode::AsContact => tools + .into_iter() + .filter(|t| PRACTICE_TOOLS.contains(&t.function.name.as_str())) + .collect(), + } +} + +/// The values both prompts interpolate. +struct PromptParts { + contact: String, + when: String, + thread: String, + cid: i64, + mid: i64, +} + +fn prompt_parts(anchor: &SmsAnchor, tz: Tz) -> PromptParts { let contact = if anchor.contact_name.trim().is_empty() { - "someone" + "someone".to_string() } else { - anchor.contact_name.as_str() + anchor.contact_name.clone() }; let when = DateTime::from_timestamp(anchor.timestamp, 0) .map(|dt| { @@ -139,9 +161,44 @@ pub fn anchored_system_prompt( } else { anchor.context.clone() }; - let cid = anchor.contact_id; - let mid = anchor.message_id; + PromptParts { + contact, + when, + thread, + cid: anchor.contact_id, + mid: anchor.message_id, + } +} +/// The system prompt for an anchored conversation. +/// +/// Re-composed every turn from the persona's current prompt and the stored +/// anchor snapshot, so editing a persona still reaches conversations already +/// in flight (as it does for open chats) while the thread snapshot itself +/// stays byte-stable. Practice mode doesn't use the persona prompt at all: +/// the model is the contact, and a persona's voice would bleed into theirs. +pub fn anchored_system_prompt( + persona_prompt: &str, + anchor: &SmsAnchor, + user_name: &str, + tz: Tz, +) -> String { + let parts = prompt_parts(anchor, tz); + match anchor.mode { + AnchorMode::About => about_prompt(persona_prompt, &parts, user_name), + AnchorMode::AsContact => practice_prompt(&parts, user_name), + } +} + +/// A persona talking with the user about the thread. +fn about_prompt(persona_prompt: &str, parts: &PromptParts, user_name: &str) -> String { + let PromptParts { + contact, + when, + thread, + cid, + mid, + } = parts; format!( "{persona_prompt} @@ -184,6 +241,35 @@ Thread with {contact}: ) } +/// The model plays the contact so the user can rehearse the conversation. +fn practice_prompt(parts: &PromptParts, user_name: &str) -> String { + let PromptParts { + contact, + when, + thread, + cid, + mid, + } = parts; + format!( + "You are playing {contact} in a text conversation with {user_name}, so {user_name} can practise picking the conversation back up. This is a private rehearsal for {user_name}; the real {contact} is not part of it. + +Below is the real thread up to the message marked ▶ (sent {when}). Carry on from there as {contact}: {user_name}'s messages to you are their next texts, and every reply you write is {contact}'s next text. + +How to play it: +- Sound like {contact} does in the thread — their message length, slang, emoji, punctuation, humour, how fast they get to the point. Mirror them, not a generic texter. +- Write only the text itself. No narration, no stage directions, no 'As {contact}, I\'d say…'. Separate several short texts with a blank line. +- Stay true to what {contact} knows and feels in the thread. Don't invent big facts about their life — jobs, relationships, events — that the thread doesn't support; if asked about something they couldn't know, answer vaguely or deflect the way they plausibly would. +- React like a real person: it's fine to be busy, short, unconvinced, or to ask something back. Don't be more agreeable than {contact} actually is in the thread. +- It is shortly after the ▶ message unless {user_name} says time has passed. +- If {user_name} steps out of the rehearsal ('pause', 'out of character', 'how did that sound?'), drop the role and answer briefly as a coach: honest, specific feedback on how their texts are landing. Go back to being {contact} when they carry on. + +If you need to remember an earlier part of the thread, use get_thread_messages {{contact_id: {cid}, message_id: {mid}, direction: \"before\"}}; to see a photo sent in it (`image #N` on a line), use look_at_message_image {{contact_id: {cid}, message_id, media_index}}. Never mention either. + +Thread between {user_name} and {contact}: +{thread}" + ) +} + #[cfg(test)] mod tests { use super::*; @@ -278,6 +364,7 @@ mod tests { message_id: 812, timestamp: 1_700_000_000, context: "[#812 · …] Sam: see you there".to_string(), + mode: AnchorMode::About, }; let prompt = anchored_system_prompt("You are Journal.", &anchor, "Cam", Tz::UTC); assert!(prompt.starts_with("You are Journal.")); @@ -295,9 +382,66 @@ mod tests { message_id: 812, timestamp: 0, context: String::new(), + mode: AnchorMode::About, }; let prompt = anchored_system_prompt("P", &anchor, "Cam", Tz::UTC); assert!(prompt.contains("could not be loaded")); assert!(prompt.contains("conversation with someone")); } + + #[test] + fn practice_prompt_plays_the_contact_and_drops_the_persona() { + let anchor = SmsAnchor { + contact_id: 42, + contact_name: "Sam".to_string(), + message_id: 812, + timestamp: 1_700_000_000, + context: "▶ [#812 · …] Sam: see you there".to_string(), + mode: AnchorMode::AsContact, + }; + let prompt = anchored_system_prompt("You are Journal.", &anchor, "Cam", Tz::UTC); + assert!(prompt.starts_with("You are playing Sam")); + assert!( + !prompt.contains("You are Journal."), + "a persona voice would bleed into the contact's" + ); + assert!(prompt.contains("contact_id: 42")); + assert!(prompt.contains("out of character")); + assert!(prompt.ends_with("Sam: see you there")); + } + + fn tool(name: &str) -> Tool { + Tool::function(name, "", serde_json::json!({"type": "object"})) + } + + #[test] + fn practice_mode_keeps_only_the_thread_tools() { + let all = || { + [ + "get_thread_messages", + "look_at_message_image", + "get_photos_near_time", + "look_at_photo", + "recall_entities", + "store_fact", + "search_messages", + "get_calendar_events", + ] + .into_iter() + .map(tool) + .collect::>() + }; + let names = |tools: Vec| -> Vec { + tools.into_iter().map(|t| t.function.name).collect() + }; + assert_eq!( + names(tools_for_mode(all(), AnchorMode::AsContact)), + vec!["get_thread_messages", "look_at_message_image"] + ); + assert_eq!( + names(tools_for_mode(all(), AnchorMode::About)).len(), + 8, + "talking about the thread keeps the full catalog, memory tools included" + ); + } } diff --git a/src/database/mod.rs b/src/database/mod.rs index d1d9ca2..3fe7468 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -64,7 +64,9 @@ pub use knowledge_dao::{ KnowledgeDao, PersonaFilter, RecentActivity, SqliteKnowledgeDao, }; pub use location_dao::{LocationHistoryDao, SqliteLocationHistoryDao}; -pub use persona_dao::{ImportPersona, PersonaDao, PersonaPatch, SmsAnchor, SqlitePersonaDao}; +pub use persona_dao::{ + AnchorMode, ImportPersona, PersonaDao, PersonaPatch, SmsAnchor, SqlitePersonaDao, +}; pub use precomputed_reel_dao::{PrecomputedReelDao, SqlitePrecomputedReelDao}; pub use preview_dao::{PreviewDao, SqlitePreviewDao}; pub use search_dao::{SearchHistoryDao, SqliteSearchHistoryDao}; diff --git a/src/database/persona_dao.rs b/src/database/persona_dao.rs index d7c17c8..7138f03 100644 --- a/src/database/persona_dao.rs +++ b/src/database/persona_dao.rs @@ -47,6 +47,37 @@ pub struct SmsAnchor { /// Unix seconds of the anchor message. pub timestamp: i64, pub context: String, + pub mode: AnchorMode, +} + +/// How an anchored conversation relates to its SMS thread. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AnchorMode { + /// A persona talks with the user about the thread. + #[default] + About, + /// The model plays the contact so the user can practise the + /// conversation. + AsContact, +} + +impl AnchorMode { + pub fn as_str(self) -> &'static str { + match self { + AnchorMode::About => "about", + AnchorMode::AsContact => "as_contact", + } + } + + /// Stored value → mode. NULL and anything unrecognised read as `About`, + /// the behaviour rows had before the column existed. + pub fn from_column(value: Option<&str>) -> Self { + match value { + Some("as_contact") => AnchorMode::AsContact, + _ => AnchorMode::About, + } + } } /// The column tuple every persona-chat read selects. Shared by the @@ -65,6 +96,7 @@ type PersonaChatColumns = ( Option, Option, Option, + Option, ); macro_rules! persona_chat_select { @@ -82,6 +114,7 @@ macro_rules! persona_chat_select { anchor_message_id, anchor_ts, anchor_context, + anchor_mode, ) }; } @@ -101,6 +134,7 @@ fn persona_chat_row( anchor_message_id, anchor_ts, anchor_context, + anchor_mode, ): PersonaChatColumns, ) -> PersonaChatRow { // An anchor needs at least the thread and the message; the rest has a @@ -112,6 +146,7 @@ fn persona_chat_row( message_id, timestamp: anchor_ts.unwrap_or(0), context: anchor_context.unwrap_or_default(), + mode: AnchorMode::from_column(anchor_mode.as_deref()), }), _ => None, }; @@ -545,6 +580,7 @@ impl PersonaDao for SqlitePersonaDao { anchor_message_id.eq(Some(anchor.message_id)), anchor_ts.eq(Some(anchor.timestamp)), anchor_context.eq(Some(&anchor.context)), + anchor_mode.eq(Some(anchor.mode.as_str())), )) .execute(conn.deref_mut()) .map_err(|e| anyhow::anyhow!("Insert error: {}", e))?; @@ -876,6 +912,7 @@ mod tests { message_id: 9001, timestamp: 1_700_000_000, context: "[2023-11-14 22:13] Sam: see you there".to_string(), + mode: AnchorMode::AsContact, }; let anchored = dao .create_anchored_persona_chat(uid, "journal", 100, &anchor) @@ -895,6 +932,17 @@ mod tests { ); } + #[test] + fn anchor_mode_reads_null_and_unknown_as_about() { + assert_eq!(AnchorMode::from_column(None), AnchorMode::About); + assert_eq!(AnchorMode::from_column(Some("weird")), AnchorMode::About); + assert_eq!(AnchorMode::from_column(Some("about")), AnchorMode::About); + assert_eq!( + AnchorMode::from_column(Some(AnchorMode::AsContact.as_str())), + AnchorMode::AsContact + ); + } + #[test] fn persona_chat_list_is_empty_for_a_user_who_has_never_chatted() { let (mut dao, uid) = dao_with_user("p8"); diff --git a/src/database/schema.rs b/src/database/schema.rs index 08080e1..e2d4f7e 100644 --- a/src/database/schema.rs +++ b/src/database/schema.rs @@ -202,6 +202,7 @@ diesel::table! { anchor_message_id -> Nullable, anchor_ts -> Nullable, anchor_context -> Nullable, + anchor_mode -> Nullable, } }