synwire_agent/experience_sampling.rs
1//! Experience pool summary generation via sampling.
2//!
3//! Post-edit, generates a summary of the diff and affected files for storage
4//! in the experience pool. Falls back to listing raw file associations when
5//! sampling is unavailable.
6
7use synwire_core::{SamplingProvider, SamplingRequest};
8
9/// Maximum number of diff characters included in the sampling prompt.
10///
11/// Diffs longer than this are truncated to keep the request within typical
12/// context-window budgets.
13const MAX_DIFF_CHARS: usize = 4000;
14
15/// Maximum tokens the model may generate for the summary.
16const SUMMARY_MAX_TOKENS: u32 = 256;
17
18/// Sampling temperature — low for deterministic, factual summaries.
19const SUMMARY_TEMPERATURE: f32 = 0.3;
20
21/// System prompt instructing the model to produce concise edit summaries.
22const SYSTEM_PROMPT: &str = "You are a code review assistant. Generate a concise \
23 one-paragraph summary of the following code change. Focus on what was changed and why.";
24
25/// Generate a summary of an edit event for the experience pool.
26///
27/// When a [`SamplingProvider`] is available, builds a structured prompt from
28/// the diff and affected file list and requests a natural-language summary
29/// via the provider. The diff is truncated to `MAX_DIFF_CHARS` characters
30/// to stay within context-window limits.
31///
32/// Falls back gracefully to listing affected files when sampling is
33/// unavailable or when the sampling call fails for any reason.
34///
35/// # Blocking behaviour
36///
37/// This function synchronously blocks on the async [`SamplingProvider::sample`]
38/// call using [`tokio::task::block_in_place`]. It must therefore be called
39/// from within a multi-threaded tokio runtime (the default for
40/// `#[tokio::main]`).
41///
42/// # Examples
43///
44/// ```no_run
45/// use synwire_core::NoopSamplingProvider;
46/// use synwire_agent::experience_sampling::summarize_edit;
47/// let p = NoopSamplingProvider;
48/// let summary = summarize_edit("- old line\n+ new line", &["src/main.rs"], &p);
49/// assert!(summary.contains("src/main.rs"));
50/// ```
51pub fn summarize_edit(
52 diff: &str,
53 affected_files: &[&str],
54 sampling: &dyn SamplingProvider,
55) -> String {
56 let fallback = || format!("Files: {}", affected_files.join(", "));
57
58 if !sampling.is_available() {
59 return fallback();
60 }
61
62 // Truncate the diff to stay within context-window budgets.
63 let truncated_diff = if diff.len() > MAX_DIFF_CHARS {
64 let mut end = MAX_DIFF_CHARS;
65 // Avoid splitting a multi-byte UTF-8 sequence.
66 while !diff.is_char_boundary(end) {
67 end -= 1;
68 }
69 &diff[..end]
70 } else {
71 diff
72 };
73
74 let file_list = affected_files.join(", ");
75 let prompt = format!("Affected files: {file_list}\n\nDiff:\n```\n{truncated_diff}\n```");
76
77 let request = SamplingRequest::new(prompt)
78 .with_system(SYSTEM_PROMPT)
79 .with_max_tokens(SUMMARY_MAX_TOKENS)
80 .with_temperature(SUMMARY_TEMPERATURE);
81
82 // Block synchronously on the async sampling call using `block_in_place`,
83 // which moves the current worker thread out of the scheduler so we can
84 // call `block_on` without deadlocking. `block_in_place` panics on
85 // current-thread runtimes, so we detect that flavour and fall back.
86 let result = match tokio::runtime::Handle::try_current() {
87 Ok(handle) => {
88 if handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::CurrentThread {
89 return fallback();
90 }
91 tokio::task::block_in_place(|| handle.block_on(sampling.sample(request)))
92 }
93 Err(_) => return fallback(),
94 };
95
96 match result {
97 Ok(response) => response.text,
98 Err(_) => fallback(),
99 }
100}