feat(persona-chat): practice mode — play the contact

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 <noreply@anthropic.com>
This commit is contained in:
Cameron CordesandClaude Opus 5.5 committed 2026-10-17 18:39:27 -04:00
1 parent 4ac574d4d2
commit 3f1bf42f1c
8 files changed
+276 -21

No files matched your search

@@ -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;
@@ -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);
+20 -2
View File
@@ -2466,6 +2466,13 @@ pub async fn run_streaming_agentic_loop_with_entry(
let mut last_prompt_eval_count: Option<i32> = None;
let mut last_eval_count: Option<i32> = 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<String> =
tools.iter().map(|t| t.function.name.clone()).collect();
for iteration in 0..max_iterations {
if !entry.is_running() {
@@ -2536,7 +2543,8 @@ pub async fn run_streaming_agentic_loop_with_entry(
arguments: tool_call.function.arguments.clone(),
})
.await;
let result = generator
let result = if offered.contains(&tool_call.function.name) {
generator
.execute_tool(
&tool_call.function.name,
&tool_call.function.arguments,
@@ -2546,7 +2554,17 @@ pub async fn run_streaming_agentic_loop_with_entry(
user_id,
active_persona,
)
.await;
.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
)
};
let (result_preview, result_truncated) = truncate_tool_result(&result);
let _ = entry
.push_event(ChatStreamEvent::ToolResult {
+36
View File
@@ -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<String, PersonaChatError> {
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");
+162 -18
View File
@@ -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<Tool>, mode: AnchorMode) -> Vec<Tool> {
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::<Vec<_>>()
};
let names = |tools: Vec<Tool>| -> Vec<String> {
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"
);
}
}
+3 -1
View File
@@ -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};
+48
View File
@@ -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<i64>,
Option<i64>,
Option<String>,
Option<String>,
);
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");
+1
View File
@@ -202,6 +202,7 @@ diesel::table! {
anchor_message_id -> Nullable<BigInt>,
anchor_ts -> Nullable<BigInt>,
anchor_context -> Nullable<Text>,
anchor_mode -> Nullable<Text>,
}
}