use crate::error::Result;
use crate::llm::LlmClient;
use crate::memory::store::Store;
use crate::trace::{Run, RunEvent, RunStore};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
const MEMORY_REVIEW_PROMPT: &str = "\
Review the conversation above and consider saving to memory if appropriate.
Focus on:
1. Has the user revealed things about themselves — their persona, desires, \
preferences, or personal details worth remembering?
2. Has the user expressed expectations about how you should behave, their work \
style, or ways they want you to operate?
If something stands out, save it using the memory tool.
If nothing is worth saving, just say 'Nothing to save.' and stop.";
const SKILL_REVIEW_PROMPT: &str = "\
Review the conversation above and identify reusable patterns for the skill library.
Signals to look for:
• User corrected your style, tone, format, verbosity, or approach. \
Frustration signals like 'stop doing X', 'this is too verbose', 'don't format \
like this' are FIRST-CLASS skill signals.
• User corrected your workflow, approach, or sequence of steps.
• Non-trivial technique, fix, workaround, or debugging path emerged.
• A skill that was loaded turned out to be wrong, missing, or outdated.
Do NOT capture:
• Environment-dependent failures (missing binaries, uninstalled packages).
• Negative claims about tools ('X tool is broken').
• Session-specific transient errors that resolved.
• One-off task narratives.
If nothing stands out, say 'Nothing to save.' and stop. Otherwise, describe \
the skill update you recommend in structured format.";
const COMBINED_REVIEW_PROMPT: &str = "\
Review the conversation above and update two things:
**Memory**: who the user is. Did the user reveal persona, desires, preferences, \
personal details, or expectations about how you should behave? Save facts about \
the user and durable preferences with the memory tool.
**Skills**: how to do this class of task. Most sessions produce at least one \
skill update. Look for user corrections, non-trivial techniques, or outdated skills.
If genuinely nothing stands out on either dimension, say 'Nothing to save.' \
and stop — but don't reach for that conclusion as a default.";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackgroundReviewConfig {
pub enabled: bool,
pub max_iterations: usize,
pub review_memory: bool,
pub review_skills: bool,
}
impl Default for BackgroundReviewConfig {
fn default() -> Self {
Self {
enabled: true,
max_iterations: 8,
review_memory: true,
review_skills: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReviewOutcome {
pub run_id: String,
pub actions: Vec<String>,
pub nothing_to_save: bool,
pub error: Option<String>,
}
pub struct BackgroundReviewer {
config: BackgroundReviewConfig,
llm_client: Arc<dyn LlmClient>,
memory_store: Option<Arc<dyn Store>>,
run_store: Option<Arc<dyn RunStore>>,
}
impl BackgroundReviewer {
pub fn new(
config: BackgroundReviewConfig,
llm_client: Arc<dyn LlmClient>,
memory_store: Option<Arc<dyn Store>>,
run_store: Option<Arc<dyn RunStore>>,
) -> Self {
Self {
config,
llm_client,
memory_store,
run_store,
}
}
fn review_prompt(&self) -> &str {
match (self.config.review_memory, self.config.review_skills) {
(true, true) => COMBINED_REVIEW_PROMPT,
(true, false) => MEMORY_REVIEW_PROMPT,
(false, true) => SKILL_REVIEW_PROMPT,
_ => COMBINED_REVIEW_PROMPT,
}
}
fn build_transcript(run: &Run) -> String {
let mut lines = Vec::new();
lines.push(format!("User: {}", run.input));
for event in &run.events {
match event {
RunEvent::ToolCall { name, args, .. } => {
let args_str = args
.as_ref()
.map(|v| serde_json::to_string(v).unwrap_or_default())
.unwrap_or_default();
lines.push(format!("Assistant [tool call]: {name}({args_str})"));
}
RunEvent::ToolResult {
name,
success,
output_preview,
..
} => {
let status = if *success { "OK" } else { "FAILED" };
let output = output_preview.as_deref().unwrap_or("(no output)");
lines.push(format!("Tool [{status}] {name}: {output}"));
}
RunEvent::ToolError { name, message, .. } => {
lines.push(format!("Tool [ERROR] {name}: {message}"));
}
_ => {}
}
}
if let Some(ref output) = run.final_output {
lines.push(format!("Assistant: {output}"));
}
lines.join("\n")
}
pub async fn review(&self, run: &Run) -> Result<ReviewOutcome> {
if !self.config.enabled {
return Ok(ReviewOutcome {
run_id: run.run_id.clone(),
actions: vec![],
nothing_to_save: true,
error: None,
});
}
let transcript = Self::build_transcript(run);
let prompt = self.review_prompt().to_string();
let run_id = run.run_id.clone();
let llm_client = self.llm_client.clone();
let memory_store = self.memory_store.clone();
let review_message = format!(
"{transcript}\n\n---\n\n{prompt}\n\nYou can only call memory management tools. \
Other tools will be denied at runtime — do not attempt them."
);
let run_id_clone = run_id.clone();
let outcome = tokio::spawn(async move {
Self::run_review(llm_client, memory_store, run_id, review_message).await
})
.await
.unwrap_or_else(|e| ReviewOutcome {
run_id: run_id_clone,
actions: vec![],
nothing_to_save: true,
error: Some(format!("Review task panicked: {e}")),
});
Ok(outcome)
}
pub async fn review_by_run_id(&self, run_id: &str) -> Result<ReviewOutcome> {
let store = match &self.run_store {
Some(s) => s,
None => {
return Ok(ReviewOutcome {
run_id: run_id.to_string(),
actions: vec![],
nothing_to_save: true,
error: Some("No run store configured".into()),
});
}
};
match store.load(run_id).await? {
Some(run) => self.review(&run).await,
None => Ok(ReviewOutcome {
run_id: run_id.to_string(),
actions: vec![],
nothing_to_save: true,
error: Some(format!("Run {run_id} not found")),
}),
}
}
async fn run_review(
llm_client: Arc<dyn LlmClient>,
memory_store: Option<Arc<dyn Store>>,
run_id: String,
message: String,
) -> ReviewOutcome {
let messages = vec![
crate::llm::types::Message::system(
"You are a background review agent. Analyze conversations and extract \
reusable knowledge. Be concise. Only call tools when there's real signal."
.to_string(),
),
crate::llm::types::Message::user(message),
];
let request = crate::llm::ChatRequest {
messages,
temperature: Some(0.3),
max_tokens: Some(2048),
..Default::default()
};
let response = match llm_client.chat(request).await {
Ok(r) => r,
Err(e) => {
return ReviewOutcome {
run_id,
actions: vec![],
nothing_to_save: true,
error: Some(format!("LLM call failed: {e}")),
};
}
};
let content = response.content().unwrap_or_default();
let nothing_to_save = content.to_lowercase().contains("nothing to save");
let mut actions = Vec::new();
if !nothing_to_save {
let lower = content.to_lowercase();
if lower.contains("memory")
&& (lower.contains("save")
|| lower.contains("update")
|| lower.contains("remember"))
{
actions.push("Memory reviewed".to_string());
}
if lower.contains("skill")
&& (lower.contains("create") || lower.contains("update") || lower.contains("patch"))
{
actions.push("Skill update recommended".to_string());
}
if let Some(ref store) = memory_store
&& !content.contains("Nothing to save")
{
let key = format!("review_{}", &run_id);
let value = serde_json::json!({
"review": content,
"run_id": &run_id,
"timestamp": chrono::Utc::now().to_rfc3339(),
});
if store
.put(&["background_reviews"], &key, value)
.await
.is_ok()
&& actions.is_empty()
{
actions.push("Review saved to memory".to_string());
}
}
}
ReviewOutcome {
run_id,
actions,
nothing_to_save,
error: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::trace::{Run, RunEvent, RunStatus, RunTimings, TokenUsage};
use chrono::Utc;
fn make_test_run() -> Run {
Run {
run_id: "test-review-1".into(),
parent_run_id: None,
session_id: "sess-1".into(),
status: RunStatus::Completed,
input: "Fix the bug in auth.rs".into(),
events: vec![
RunEvent::ToolCall {
call_id: "c1".into(),
name: "read_file".into(),
args: Some(serde_json::json!({"path": "auth.rs"})),
risk: None,
duration_ms: 50,
},
RunEvent::ToolResult {
call_id: "c1".into(),
name: "read_file".into(),
success: true,
output_preview: Some(
"fn authenticate(token: &str) -> Result<User> { ... }".into(),
),
output_truncated: false,
duration_ms: 50,
},
],
final_output: Some("Fixed the auth bug by adding null check on token.".into()),
error: None,
token_usage: TokenUsage {
prompt_tokens: 200,
completion_tokens: 100,
total_tokens: 300,
},
timings: RunTimings {
total_duration_ms: 1000,
llm_duration_ms: 800,
tool_duration_ms: 50,
},
started_at: Utc::now(),
finished_at: Some(Utc::now()),
}
}
#[test]
fn test_build_transcript() {
let run = make_test_run();
let transcript = BackgroundReviewer::build_transcript(&run);
assert!(transcript.contains("User: Fix the bug in auth.rs"));
assert!(transcript.contains("read_file"));
assert!(transcript.contains("Fixed the auth bug"));
}
#[test]
fn test_review_prompt_selection() {
let config = BackgroundReviewConfig {
review_memory: true,
review_skills: true,
..Default::default()
};
let prompt = match (config.review_memory, config.review_skills) {
(true, true) => COMBINED_REVIEW_PROMPT,
(true, false) => MEMORY_REVIEW_PROMPT,
(false, true) => SKILL_REVIEW_PROMPT,
_ => COMBINED_REVIEW_PROMPT,
};
assert!(prompt.contains("Memory"));
assert!(prompt.contains("Skills"));
}
}