diff --git a/src/ai/insight_chat.rs b/src/ai/insight_chat.rs index a4d21ea..01dd4de 100644 --- a/src/ai/insight_chat.rs +++ b/src/ai/insight_chat.rs @@ -382,6 +382,12 @@ impl InsightChatService { // 7. Append the new user turn. messages.push(ChatMessage::user(req.user_message.clone())); + // Temporarily annotate the system message with this turn's iteration + // budget so the model knows how many tool-calling rounds it has. We + // restore the original content before persistence so the note doesn't + // accumulate across turns. + let original_system_content = annotate_system_with_budget(&mut messages, max_iterations); + let insight_cx = parent_cx.with_span(span); // 8. Agentic loop — same shape as insight_generator's, but capped @@ -468,6 +474,10 @@ impl InsightChatService { loop_cx.span().set_status(Status::Ok); + // Drop the per-turn iteration-budget note from the system message + // before we persist so it doesn't snowball on each subsequent turn. + restore_system_content(&mut messages, original_system_content); + // 9. Persist. Append mode rewrites the JSON blob in place; amend // mode regenerates the title and inserts a new insight row, // relying on store_insight to flip prior rows' is_current=false. @@ -789,6 +799,8 @@ impl InsightChatService { messages.push(ChatMessage::user(req.user_message.clone())); + let original_system_content = annotate_system_with_budget(&mut messages, max_iterations); + let mut tool_calls_made = 0usize; let mut iterations_used = 0usize; let mut last_prompt_eval_count: Option = None; @@ -917,6 +929,10 @@ impl InsightChatService { messages.push(final_response); } + // Drop the per-turn iteration-budget note from the system message + // before we persist so it doesn't snowball on each subsequent turn. + restore_system_content(&mut messages, original_system_content); + // Persist. let json = serde_json::to_string(&messages) .map_err(|e| anyhow!("failed to serialize chat history: {}", e))?; @@ -1080,6 +1096,40 @@ fn env_max_iterations() -> usize { .max(1) } +/// Append a per-turn iteration-budget reminder to the replayed system +/// message so the model knows how many tool-calling rounds this turn gets. +/// Returns the original `content` so the caller can restore it before +/// persistence — otherwise the note would accumulate across turns. +/// +/// No-op (returns `None`) when `messages` has no leading system message. +fn annotate_system_with_budget( + messages: &mut [ChatMessage], + max_iterations: usize, +) -> Option { + let first = messages.first_mut()?; + if first.role != "system" { + return None; + } + let original = first.content.clone(); + first.content = format!( + "{}\n\n(Budget for this chat turn: up to {} tool-calling iterations. Produce your final reply before the budget is exhausted.)", + first.content, max_iterations + ); + Some(original) +} + +/// Restore a system-message content previously captured by +/// [`annotate_system_with_budget`]. No-op when `original` is `None` or the +/// first message isn't a system message. +fn restore_system_content(messages: &mut [ChatMessage], original: Option) { + let Some(original) = original else { return }; + if let Some(first) = messages.first_mut() + && first.role == "system" + { + first.content = original; + } +} + /// View returned to clients for chat-UI rendering. #[derive(Debug)] pub struct HistoryView { diff --git a/src/ai/insight_generator.rs b/src/ai/insight_generator.rs index bffd141..8a0ed11 100644 --- a/src/ai/insight_generator.rs +++ b/src/ai/insight_generator.rs @@ -2892,8 +2892,11 @@ Return ONLY the summary, nothing else."#, .collect() }; - // 6. Clear existing entity-photo links for this file so the run starts fresh, - // and ensure the owner entity exists so the agent can reference it. + // 6. Ensure the owner entity exists so the agent can reference it. + // Prior entity_photo_links for this file are intentionally preserved + // across regenerations — clearing them made `recall_facts_for_photo` + // always return empty and discarded hard-won knowledge. Re-linking + // the same entity is a no-op (INSERT OR IGNORE). let owner_name = user_display_name(); let owner_entity_id: Option = { let mut kdao = self @@ -2901,14 +2904,6 @@ Return ONLY the summary, nothing else."#, .lock() .expect("Unable to lock KnowledgeDao"); - if let Err(e) = kdao.delete_photo_links_for_file(&insight_cx, &file_path) { - log::warn!( - "Failed to clear entity_photo_links for {}: {:?}", - file_path, - e - ); - } - // Upsert the owner entity so the agent always has a stable entity ID to reference. let owner = crate::database::models::InsertEntity { name: owner_name.clone(), @@ -3000,9 +2995,11 @@ Return ONLY the summary, nothing else."#, 4. Use recall_entities to look up known people, places, or things that appear in this photo.\n\ 5. When you identify people, places, events, or notable things in this photo: use store_entity to record them and store_fact to record key facts (relationships, roles, attributes). This builds a persistent memory for future insights.\n\ 6. Only produce your final insight AFTER you have gathered context from at least 5 tool calls.\n\ - 7. If a tool returns no results, that is useful information — continue calling the remaining tools anyway.", + 7. If a tool returns no results, that is useful information — continue calling the remaining tools anyway.\n\ + 8. You have a hard budget of {max_iterations} tool-calling iterations before the loop ends. Plan your context gathering so you can write a complete final insight within that budget.", owner_id_note = owner_id_note, - owner_name = owner_name + owner_name = owner_name, + max_iterations = max_iterations ); let system_content = if let Some(ref custom) = custom_system_prompt { format!("{}\n\n{}", custom, base_system)