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 {