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
+284 -29

No files matched your search

+28 -10
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,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 {
+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"
);
}
}