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
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user