feat: add /persona_chat endpoints for open chat with persona
- PersonaChatSession dispatches turns through the shared agent loop, persisting transcripts keyed on (user_id, persona_id) in the new persona_chat table (migration included). - GET /persona_chat/history returns a rendered transcript (tool invocations folded, is_initial flag) matching the file-chat shape. - POST /persona_chat/turn returns 202 with a turn_id; SSE replay and cancel reuse turn_replay_impl/cancel_turn_impl, extracted from the actix-attributed handlers so both route families share the logic. - POST /persona_chat/reset clears the persona transcript. - Concurrent dispatches for the same (user, persona) are rejected with 409 via an in-flight gate (InFlightPersonaTurns) whose RAII guard drops with the spawned turn task, freeing the slot on completion, error, or abort.
This commit is contained in:
+202
-167
@@ -20,7 +20,7 @@ use crate::utils::{normalize_path, retry_with_backoff};
|
||||
use futures::stream::{BoxStream, StreamExt};
|
||||
use uuid::Uuid;
|
||||
|
||||
const DEFAULT_MAX_ITERATIONS: usize = 6;
|
||||
pub const DEFAULT_MAX_ITERATIONS: usize = 6;
|
||||
/// Assumed context window when the request doesn't specify `num_ctx`.
|
||||
/// The llama-swap chat slots serve 20k-131k contexts and real conversations
|
||||
/// rarely pass ~16k tokens, so 32k keeps the truncation pass from gutting
|
||||
@@ -1303,7 +1303,12 @@ impl InsightChatService {
|
||||
}
|
||||
|
||||
/// Agentic loop variant that pushes events to a `TurnEntry` buffer.
|
||||
async fn run_streaming_agentic_loop_with_entry(
|
||||
/// Same as `run_streaming_agentic_loop` but emits events to a
|
||||
/// `TurnEntry` for SSE replay. Thin wrapper around the free function
|
||||
/// `run_streaming_agentic_loop_with_entry` so the persona chat
|
||||
/// (and any future chat-without-file surface) can reuse the loop
|
||||
/// body without instantiating an `InsightChatService`.
|
||||
pub async fn run_streaming_agentic_loop_with_entry(
|
||||
&self,
|
||||
backend: &ResolvedBackend,
|
||||
messages: &mut Vec<ChatMessage>,
|
||||
@@ -1315,160 +1320,19 @@ impl InsightChatService {
|
||||
max_iterations: usize,
|
||||
entry: &Arc<TurnEntry>,
|
||||
) -> Result<AgenticLoopOutcome> {
|
||||
let mut tool_calls_made = 0usize;
|
||||
let mut iterations_used = 0usize;
|
||||
let mut last_prompt_eval_count: Option<i32> = None;
|
||||
let mut last_eval_count: Option<i32> = None;
|
||||
let mut final_content = String::new();
|
||||
|
||||
for iteration in 0..max_iterations {
|
||||
// Cooperative cancellation: a DELETE flips status out of Running
|
||||
// (and aborts this task). Check at the iteration boundary so an
|
||||
// in-flight tool round finishes cleanly rather than mid-write.
|
||||
if !entry.is_running() {
|
||||
return Ok(AgenticLoopOutcome {
|
||||
tool_calls_made,
|
||||
iterations_used,
|
||||
last_prompt_eval_count,
|
||||
last_eval_count,
|
||||
final_content,
|
||||
cancelled: true,
|
||||
});
|
||||
}
|
||||
|
||||
iterations_used = iteration + 1;
|
||||
let _ = entry
|
||||
.push_event(ChatStreamEvent::IterationStart {
|
||||
n: iterations_used,
|
||||
max: max_iterations,
|
||||
})
|
||||
.await;
|
||||
|
||||
let mut stream = backend
|
||||
.chat()
|
||||
.chat_with_tools_stream(messages.clone(), tools.clone())
|
||||
.await?;
|
||||
|
||||
let mut final_message: Option<ChatMessage> = None;
|
||||
while let Some(ev) = stream.next().await {
|
||||
let ev = ev?;
|
||||
match ev {
|
||||
LlmStreamEvent::TextDelta(delta) => {
|
||||
let _ = entry.push_event(ChatStreamEvent::TextDelta(delta)).await;
|
||||
}
|
||||
LlmStreamEvent::Done {
|
||||
message,
|
||||
prompt_eval_count,
|
||||
eval_count,
|
||||
} => {
|
||||
last_prompt_eval_count = prompt_eval_count;
|
||||
last_eval_count = eval_count;
|
||||
final_message = Some(message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut response =
|
||||
final_message.ok_or_else(|| anyhow!("stream ended without a Done event"))?;
|
||||
|
||||
if let Some(ref mut tcs) = response.tool_calls {
|
||||
for tc in tcs.iter_mut() {
|
||||
if !tc.function.arguments.is_object() {
|
||||
tc.function.arguments = serde_json::Value::Object(Default::default());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
messages.push(response.clone());
|
||||
|
||||
if let Some(ref tool_calls) = response.tool_calls
|
||||
&& !tool_calls.is_empty()
|
||||
{
|
||||
for tool_call in tool_calls {
|
||||
tool_calls_made += 1;
|
||||
let call_index = tool_calls_made - 1;
|
||||
let _ = entry
|
||||
.push_event(ChatStreamEvent::ToolCall {
|
||||
index: call_index,
|
||||
name: tool_call.function.name.clone(),
|
||||
arguments: tool_call.function.arguments.clone(),
|
||||
})
|
||||
.await;
|
||||
let cx = opentelemetry::Context::new();
|
||||
let result = self
|
||||
.generator
|
||||
.execute_tool(
|
||||
&tool_call.function.name,
|
||||
&tool_call.function.arguments,
|
||||
backend,
|
||||
image_base64,
|
||||
normalized,
|
||||
user_id,
|
||||
active_persona,
|
||||
&cx,
|
||||
)
|
||||
.await;
|
||||
let (result_preview, result_truncated) = truncate_tool_result(&result);
|
||||
let _ = entry
|
||||
.push_event(ChatStreamEvent::ToolResult {
|
||||
index: call_index,
|
||||
name: tool_call.function.name.clone(),
|
||||
result: result_preview,
|
||||
result_truncated,
|
||||
})
|
||||
.await;
|
||||
messages.push(ChatMessage::tool_result(result));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
final_content = response.content;
|
||||
break;
|
||||
}
|
||||
|
||||
// No-tools fallback
|
||||
if final_content.is_empty() {
|
||||
let synthetic_idx = push_synthetic_final_prompt(messages);
|
||||
let mut stream = backend
|
||||
.chat()
|
||||
.chat_with_tools_stream(messages.clone(), vec![])
|
||||
.await?;
|
||||
let mut final_message: Option<ChatMessage> = None;
|
||||
while let Some(ev) = stream.next().await {
|
||||
let ev = ev?;
|
||||
match ev {
|
||||
LlmStreamEvent::TextDelta(delta) => {
|
||||
let _ = entry.push_event(ChatStreamEvent::TextDelta(delta)).await;
|
||||
}
|
||||
LlmStreamEvent::Done {
|
||||
message,
|
||||
prompt_eval_count,
|
||||
eval_count,
|
||||
} => {
|
||||
last_prompt_eval_count = prompt_eval_count;
|
||||
last_eval_count = eval_count;
|
||||
final_message = Some(message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let final_response =
|
||||
final_message.ok_or_else(|| anyhow!("final stream ended without a Done event"))?;
|
||||
final_content = final_response.content.clone();
|
||||
messages.push(final_response);
|
||||
remove_synthetic_final_prompt(messages, synthetic_idx);
|
||||
}
|
||||
|
||||
Ok(AgenticLoopOutcome {
|
||||
tool_calls_made,
|
||||
iterations_used,
|
||||
last_prompt_eval_count,
|
||||
last_eval_count,
|
||||
// Strip any leaked <think> reasoning block from the content the
|
||||
// caller persists as title/summary (the raw transcript keeps it).
|
||||
final_content: crate::ai::llm_client::strip_think_blocks(&final_content),
|
||||
cancelled: false,
|
||||
})
|
||||
crate::ai::insight_chat::run_streaming_agentic_loop_with_entry(
|
||||
&self.generator,
|
||||
backend,
|
||||
messages,
|
||||
tools,
|
||||
image_base64,
|
||||
normalized,
|
||||
user_id,
|
||||
active_persona,
|
||||
max_iterations,
|
||||
entry,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn run_streaming_turn(
|
||||
@@ -2212,17 +2076,20 @@ fn resolve_bootstrap_backend(supplied: Option<&str>) -> Result<String> {
|
||||
}
|
||||
|
||||
/// Outcome of one streaming agentic loop pass. Shared between bootstrap
|
||||
/// and continuation.
|
||||
struct AgenticLoopOutcome {
|
||||
tool_calls_made: usize,
|
||||
iterations_used: usize,
|
||||
last_prompt_eval_count: Option<i32>,
|
||||
last_eval_count: Option<i32>,
|
||||
final_content: String,
|
||||
/// and continuation. `pub` so the persona chat surface (and any future
|
||||
/// chat-without-file surface) can read the result of
|
||||
/// `run_streaming_agentic_loop_with_entry` without reaching back into
|
||||
/// private fields.
|
||||
pub struct AgenticLoopOutcome {
|
||||
pub tool_calls_made: usize,
|
||||
pub iterations_used: usize,
|
||||
pub last_prompt_eval_count: Option<i32>,
|
||||
pub last_eval_count: Option<i32>,
|
||||
pub final_content: String,
|
||||
/// True when the loop exited early because the turn was cancelled
|
||||
/// (status flipped out of `Running`). Callers skip persistence and the
|
||||
/// terminal `Done` push — the cancel handler owns the terminal event.
|
||||
cancelled: bool,
|
||||
pub cancelled: bool,
|
||||
}
|
||||
|
||||
/// Events emitted by `chat_turn_stream`. One stream per turn; ends after
|
||||
@@ -2341,7 +2208,7 @@ pub(crate) fn find_raw_cut(
|
||||
/// Read AGENTIC_CHAT_MAX_ITERATIONS once per call. Cheap; keeps the code
|
||||
/// free of static globals and lets the operator change the cap by env without
|
||||
/// a restart in test harnesses (the running server still caches via Default).
|
||||
fn env_max_iterations() -> usize {
|
||||
pub fn env_max_iterations() -> usize {
|
||||
std::env::var("AGENTIC_CHAT_MAX_ITERATIONS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<usize>().ok())
|
||||
@@ -2395,6 +2262,174 @@ fn restore_system_content(messages: &mut [ChatMessage], original: Option<String>
|
||||
}
|
||||
|
||||
/// Append the synthetic "write your final answer" user prompt, returning the
|
||||
/// Free-function form of `InsightChatService::run_streaming_agentic_loop_with_entry`.
|
||||
/// Persona chat (and any future chat-without-file surface) doesn't need an
|
||||
/// `InsightChatService` — it just needs the agent loop. This is the same
|
||||
/// body lifted out of the `InsightChatService` impl, taking
|
||||
/// `&InsightGenerator` so any caller in the crate can drive it.
|
||||
///
|
||||
/// Public so the persona chat session can call into it directly.
|
||||
pub async fn run_streaming_agentic_loop_with_entry(
|
||||
generator: &crate::ai::insight_generator::InsightGenerator,
|
||||
backend: &ResolvedBackend,
|
||||
messages: &mut Vec<ChatMessage>,
|
||||
tools: Vec<Tool>,
|
||||
image_base64: &Option<String>,
|
||||
normalized: &str,
|
||||
user_id: i32,
|
||||
active_persona: &str,
|
||||
max_iterations: usize,
|
||||
entry: &Arc<TurnEntry>,
|
||||
) -> Result<AgenticLoopOutcome> {
|
||||
let mut tool_calls_made = 0usize;
|
||||
let mut iterations_used = 0usize;
|
||||
let mut last_prompt_eval_count: Option<i32> = None;
|
||||
let mut last_eval_count: Option<i32> = None;
|
||||
let mut final_content = String::new();
|
||||
|
||||
for iteration in 0..max_iterations {
|
||||
if !entry.is_running() {
|
||||
return Ok(AgenticLoopOutcome {
|
||||
tool_calls_made,
|
||||
iterations_used,
|
||||
last_prompt_eval_count,
|
||||
last_eval_count,
|
||||
final_content,
|
||||
cancelled: true,
|
||||
});
|
||||
}
|
||||
|
||||
iterations_used = iteration + 1;
|
||||
let _ = entry
|
||||
.push_event(ChatStreamEvent::IterationStart {
|
||||
n: iterations_used,
|
||||
max: max_iterations,
|
||||
})
|
||||
.await;
|
||||
|
||||
let mut stream = backend
|
||||
.chat()
|
||||
.chat_with_tools_stream(messages.clone(), tools.clone())
|
||||
.await?;
|
||||
|
||||
let mut final_message: Option<ChatMessage> = None;
|
||||
while let Some(ev) = stream.next().await {
|
||||
let ev = ev?;
|
||||
match ev {
|
||||
LlmStreamEvent::TextDelta(delta) => {
|
||||
let _ = entry.push_event(ChatStreamEvent::TextDelta(delta)).await;
|
||||
}
|
||||
LlmStreamEvent::Done {
|
||||
message,
|
||||
prompt_eval_count,
|
||||
eval_count,
|
||||
} => {
|
||||
last_prompt_eval_count = prompt_eval_count;
|
||||
last_eval_count = eval_count;
|
||||
final_message = Some(message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut response =
|
||||
final_message.ok_or_else(|| anyhow!("stream ended without a Done event"))?;
|
||||
|
||||
if let Some(ref mut tcs) = response.tool_calls {
|
||||
for tc in tcs.iter_mut() {
|
||||
if !tc.function.arguments.is_object() {
|
||||
tc.function.arguments = serde_json::Value::Object(Default::default());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
messages.push(response.clone());
|
||||
|
||||
if let Some(ref tool_calls) = response.tool_calls
|
||||
&& !tool_calls.is_empty()
|
||||
{
|
||||
for tool_call in tool_calls {
|
||||
tool_calls_made += 1;
|
||||
let call_index = tool_calls_made - 1;
|
||||
let _ = entry
|
||||
.push_event(ChatStreamEvent::ToolCall {
|
||||
index: call_index,
|
||||
name: tool_call.function.name.clone(),
|
||||
arguments: tool_call.function.arguments.clone(),
|
||||
})
|
||||
.await;
|
||||
let cx = opentelemetry::Context::new();
|
||||
let result = generator
|
||||
.execute_tool(
|
||||
&tool_call.function.name,
|
||||
&tool_call.function.arguments,
|
||||
backend,
|
||||
image_base64,
|
||||
normalized,
|
||||
user_id,
|
||||
active_persona,
|
||||
&cx,
|
||||
)
|
||||
.await;
|
||||
let (result_preview, result_truncated) = truncate_tool_result(&result);
|
||||
let _ = entry
|
||||
.push_event(ChatStreamEvent::ToolResult {
|
||||
index: call_index,
|
||||
name: tool_call.function.name.clone(),
|
||||
result: result_preview,
|
||||
result_truncated,
|
||||
})
|
||||
.await;
|
||||
messages.push(ChatMessage::tool_result(result));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
final_content = response.content;
|
||||
break;
|
||||
}
|
||||
|
||||
if final_content.is_empty() {
|
||||
let synthetic_idx = push_synthetic_final_prompt(messages);
|
||||
let mut stream = backend
|
||||
.chat()
|
||||
.chat_with_tools_stream(messages.clone(), vec![])
|
||||
.await?;
|
||||
let mut final_message: Option<ChatMessage> = None;
|
||||
while let Some(ev) = stream.next().await {
|
||||
let ev = ev?;
|
||||
match ev {
|
||||
LlmStreamEvent::TextDelta(delta) => {
|
||||
let _ = entry.push_event(ChatStreamEvent::TextDelta(delta)).await;
|
||||
}
|
||||
LlmStreamEvent::Done {
|
||||
message,
|
||||
prompt_eval_count,
|
||||
eval_count,
|
||||
} => {
|
||||
last_prompt_eval_count = prompt_eval_count;
|
||||
last_eval_count = eval_count;
|
||||
final_message = Some(message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let final_response =
|
||||
final_message.ok_or_else(|| anyhow!("final stream ended without a Done event"))?;
|
||||
final_content = final_response.content.clone();
|
||||
messages.push(final_response);
|
||||
remove_synthetic_final_prompt(messages, synthetic_idx);
|
||||
}
|
||||
|
||||
Ok(AgenticLoopOutcome {
|
||||
tool_calls_made,
|
||||
iterations_used,
|
||||
last_prompt_eval_count,
|
||||
last_eval_count,
|
||||
final_content: crate::ai::llm_client::strip_think_blocks(&final_content),
|
||||
cancelled: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// index the caller must later hand to [`remove_synthetic_final_prompt`].
|
||||
/// Used when the agentic loop exhausts its budget: the model gets one more
|
||||
/// (tool-free) request, but the nudge itself must never persist — it would
|
||||
@@ -2638,9 +2673,9 @@ pub struct ToolInvocation {
|
||||
/// Soft cap for tool-result bodies returned via the history API. Keeps
|
||||
/// payloads small for the mobile client — verbose SMS / geocoding responses
|
||||
/// don't need to ship in full for inspection.
|
||||
const TOOL_RESULT_PREVIEW_MAX: usize = 2000;
|
||||
pub(crate) const TOOL_RESULT_PREVIEW_MAX: usize = 2000;
|
||||
|
||||
fn truncate_tool_result(s: &str) -> (String, bool) {
|
||||
pub(crate) fn truncate_tool_result(s: &str) -> (String, bool) {
|
||||
if s.len() <= TOOL_RESULT_PREVIEW_MAX {
|
||||
(s.to_string(), false)
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user