diff --git a/prompts/en/channel.md.j2 b/prompts/en/channel.md.j2 index ca982c529..bb553b8a7 100644 --- a/prompts/en/channel.md.j2 +++ b/prompts/en/channel.md.j2 @@ -32,10 +32,12 @@ You have a soul, an identity, and a personality. These are loaded separately and ## How You Work -Every turn, you receive the user's message along with a live status block showing active workers, branches, and recently completed work. Use this to stay aware of what's happening without asking. +Every turn, you receive the user's message along with a live status block showing active workers and branches. Use this to stay aware of what's happening without asking. The status block includes a current date/time line with timezone and UTC. Treat that as the source of truth for words like "today", "tomorrow", "yesterday", "now", and "later today". -When a background process (branch or worker) completes, you will receive a system message containing the full result text, tagged with the process type and ID. The user has NOT seen any of it — you must relay the substance to them using the reply tool. Include actual content and details, not just a summary teaser. Do not mention internal processes (branch, worker, process IDs). If a result is background work the user didn't ask about, incorporate it silently. +When a background process (branch or worker) completes, you will receive a **system message** containing the full result text. The user has NOT seen any of it — you must relay the substance to them using the reply tool. Include actual content and details, not just a summary teaser. Do not mention internal processes (branch, worker, process IDs). If a result is background work the user didn't ask about, incorporate it silently. + +**Important:** Once you have relayed a result, it is done. The status block may still show a "Recently Completed" section for unrelayed work — but if you already relayed a result in a previous turn, do NOT repeat, re-summarise, or reference it again unless the user explicitly asks about it. Treat relayed results the same as any other past conversation — they are in the history, the user saw them, move on. When work produces a user-facing file artifact (PDF, DOCX, CSV, ZIP, image, etc.), deliver the file with `send_file`. Do not paste local filesystem paths as the main handoff. Local paths are machine-local and usually useless to the user. diff --git a/src/agent/channel.rs b/src/agent/channel.rs index fbc0d23cb..00e9e9e9f 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -1573,6 +1573,24 @@ impl Channel { content: OneOrMany::one(rig::message::AssistantContent::text(record)), }); } + + // Mark the completed items as relayed in the status block so their + // full result summaries stop appearing on subsequent turns. This + // prevents the LLM from re-summarising stale worker/branch results. + if replied + && let Some(ids) = message + .metadata + .get("retrigger_process_ids") + .and_then(|v| serde_json::from_value::>(v.clone()).ok()) + { + let mut status = self.state.status_block.write().await; + status.mark_relayed(&ids); + tracing::debug!( + channel_id = %self.id, + count = ids.len(), + "marked retrigger results as relayed in status block" + ); + } } // Check context size and trigger compaction if needed @@ -2426,11 +2444,23 @@ impl Channel { .collect::>() .join("\n"); + // Collect the process IDs so we can mark them as relayed in the + // status block after the retrigger turn completes successfully. + let retrigger_process_ids: Vec = self + .pending_results + .iter() + .map(|r| r.process_id.clone()) + .collect(); + let mut metadata = self.pending_retrigger_metadata.clone(); metadata.insert( "retrigger_result_summary".to_string(), serde_json::Value::String(result_summary), ); + metadata.insert( + "retrigger_process_ids".to_string(), + serde_json::json!(retrigger_process_ids), + ); let synthetic = InboundMessage { id: uuid::Uuid::new_v4().to_string(), diff --git a/src/agent/status.rs b/src/agent/status.rs index 745766b28..a5515ade3 100644 --- a/src/agent/status.rs +++ b/src/agent/status.rs @@ -43,6 +43,10 @@ pub struct CompletedItem { pub description: String, pub completed_at: DateTime, pub result_summary: String, + /// Whether this item's result has been relayed to the user via retrigger. + /// Once relayed, the result summary is excluded from the status block to + /// prevent the LLM from re-summarising stale results. + pub relayed: bool, } /// Status of an active link conversation. @@ -94,6 +98,7 @@ impl StatusBlock { description: worker.task, completed_at: Utc::now(), result_summary: result.clone(), + relayed: false, }); } } @@ -120,19 +125,42 @@ impl StatusBlock { description: branch.description, completed_at: Utc::now(), result_summary: conclusion.clone(), + relayed: false, }); } - - // Keep only last 10 completed items - if self.completed_items.len() > 10 { - self.completed_items.remove(0); - } } ProcessEvent::AgentMessageSent { to_agent_id, .. } => { self.track_link_conversation(to_agent_id.as_ref()); } _ => {} } + + // Prune completed items: drop relayed items older than 5 minutes, + // then cap at 10 to bound status block size. + self.prune_completed_items(); + } + + /// Mark completed items as relayed so the status block stops showing + /// their full result summaries. Called after a retrigger turn succeeds. + pub fn mark_relayed(&mut self, process_ids: &[String]) { + for item in &mut self.completed_items { + if process_ids.contains(&item.id) { + item.relayed = true; + } + } + } + + /// Remove stale completed items: relayed items older than 5 minutes are + /// dropped entirely, then total count is capped at 10. + fn prune_completed_items(&mut self) { + let cutoff = Utc::now() - chrono::Duration::minutes(5); + self.completed_items + .retain(|item| !(item.relayed && item.completed_at < cutoff)); + + // Hard cap: keep the 10 most recent. + while self.completed_items.len() > 10 { + self.completed_items.remove(0); + } } /// Add a new active branch. @@ -246,10 +274,19 @@ impl StatusBlock { output.push('\n'); } - // Recently completed - if !self.completed_items.is_empty() { + // Recently completed — only show items not yet relayed to the user. + // Relayed items already appeared in conversation via the retrigger flow; + // keeping their full summaries here causes the LLM to re-summarise them. + let unrelayed: Vec<_> = self + .completed_items + .iter() + .rev() + .filter(|item| !item.relayed) + .take(5) + .collect(); + if !unrelayed.is_empty() { output.push_str("## Recently Completed\n"); - for item in self.completed_items.iter().rev().take(5) { + for item in &unrelayed { let type_str = match item.item_type { CompletedItemType::Branch => "branch", CompletedItemType::Worker => "worker",