feat(ai): iteration budget in prompt + preserve photo-knowledge links

- Inject the max-iterations budget into the agentic system prompt for
  both insight generation and chat turns. Chat does this per-turn by
  appending a note to the replayed system message and restoring it
  before persistence so the note doesn't accumulate across turns.
- Stop deleting entity_photo_links at the start of agentic insight
  generation. The clear made recall_facts_for_photo always return
  empty, wasting a tool call and discarding knowledge from prior runs.
  Re-linking the same entity is already an INSERT OR IGNORE no-op.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cameron
2026-04-23 16:28:48 -04:00
parent 6831f50993
commit aa651d1c7b
2 changed files with 59 additions and 12 deletions

View File

@@ -382,6 +382,12 @@ impl InsightChatService {
// 7. Append the new user turn. // 7. Append the new user turn.
messages.push(ChatMessage::user(req.user_message.clone())); 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); let insight_cx = parent_cx.with_span(span);
// 8. Agentic loop — same shape as insight_generator's, but capped // 8. Agentic loop — same shape as insight_generator's, but capped
@@ -468,6 +474,10 @@ impl InsightChatService {
loop_cx.span().set_status(Status::Ok); 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 // 9. Persist. Append mode rewrites the JSON blob in place; amend
// mode regenerates the title and inserts a new insight row, // mode regenerates the title and inserts a new insight row,
// relying on store_insight to flip prior rows' is_current=false. // 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())); 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 tool_calls_made = 0usize;
let mut iterations_used = 0usize; let mut iterations_used = 0usize;
let mut last_prompt_eval_count: Option<i32> = None; let mut last_prompt_eval_count: Option<i32> = None;
@@ -917,6 +929,10 @@ impl InsightChatService {
messages.push(final_response); 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. // Persist.
let json = serde_json::to_string(&messages) let json = serde_json::to_string(&messages)
.map_err(|e| anyhow!("failed to serialize chat history: {}", e))?; .map_err(|e| anyhow!("failed to serialize chat history: {}", e))?;
@@ -1080,6 +1096,40 @@ fn env_max_iterations() -> usize {
.max(1) .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<String> {
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<String>) {
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. /// View returned to clients for chat-UI rendering.
#[derive(Debug)] #[derive(Debug)]
pub struct HistoryView { pub struct HistoryView {

View File

@@ -2892,8 +2892,11 @@ Return ONLY the summary, nothing else."#,
.collect() .collect()
}; };
// 6. Clear existing entity-photo links for this file so the run starts fresh, // 6. Ensure the owner entity exists so the agent can reference it.
// and 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_name = user_display_name();
let owner_entity_id: Option<i32> = { let owner_entity_id: Option<i32> = {
let mut kdao = self let mut kdao = self
@@ -2901,14 +2904,6 @@ Return ONLY the summary, nothing else."#,
.lock() .lock()
.expect("Unable to lock KnowledgeDao"); .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. // Upsert the owner entity so the agent always has a stable entity ID to reference.
let owner = crate::database::models::InsertEntity { let owner = crate::database::models::InsertEntity {
name: owner_name.clone(), 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\ 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\ 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\ 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_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 { let system_content = if let Some(ref custom) = custom_system_prompt {
format!("{}\n\n{}", custom, base_system) format!("{}\n\n{}", custom, base_system)