Skip to main content

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}