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:
1 parent
4ac574d4d2
commit
3f1bf42f1c
8 files changed
+284
-29
No files matched your search
+28
-10
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user