Skip to main content

lean_ctx/core/
task_briefing.rs

1use crate::core::intent_engine::{TaskClassification, TaskType, classify};
2
3#[derive(Debug)]
4pub struct TaskBriefing {
5    pub classification: TaskClassification,
6    pub completeness_signal: CompletenessSignal,
7    pub output_instruction: &'static str,
8    pub context_hints: Vec<String>,
9    /// Lab-only: thinking instruction for direct LLM API calls.
10    /// NEVER inject into MCP tool outputs — would override user's model thinking behavior.
11    pub lab_thinking_instruction: &'static str,
12}
13
14#[derive(Debug, Clone, Copy)]
15pub enum CompletenessSignal {
16    SingleFile,
17    MultiFile,
18    CrossModule,
19    Unknown,
20}
21
22impl CompletenessSignal {
23    pub fn as_str(&self) -> &'static str {
24        match self {
25            Self::SingleFile => "SCOPE:single-file",
26            Self::MultiFile => "SCOPE:multi-file",
27            Self::CrossModule => "SCOPE:cross-module",
28            Self::Unknown => "SCOPE:unknown",
29        }
30    }
31}
32
33pub fn build_briefing(task: &str, file_context: &[(String, usize)]) -> TaskBriefing {
34    let classification = classify(task);
35
36    let completeness = estimate_completeness(&classification, file_context);
37    let context_hints = build_context_hints(&classification, file_context);
38
39    let output_instruction = classification.task_type.output_format().instruction();
40    let lab_thinking_instruction = classification.task_type.thinking_budget().instruction();
41
42    TaskBriefing {
43        classification,
44        completeness_signal: completeness,
45        output_instruction,
46        context_hints,
47        lab_thinking_instruction,
48    }
49}
50
51fn estimate_completeness(
52    classification: &TaskClassification,
53    file_context: &[(String, usize)],
54) -> CompletenessSignal {
55    if file_context.is_empty() {
56        return CompletenessSignal::Unknown;
57    }
58
59    let unique_dirs: std::collections::HashSet<&str> = file_context
60        .iter()
61        .filter_map(|(path, _)| std::path::Path::new(path).parent().and_then(|p| p.to_str()))
62        .collect();
63
64    if classification.targets.len() <= 1 && unique_dirs.len() <= 1 {
65        CompletenessSignal::SingleFile
66    } else if unique_dirs.len() <= 3 {
67        CompletenessSignal::MultiFile
68    } else {
69        CompletenessSignal::CrossModule
70    }
71}
72
73fn build_context_hints(
74    classification: &TaskClassification,
75    file_context: &[(String, usize)],
76) -> Vec<String> {
77    let mut hints = Vec::new();
78
79    match classification.task_type {
80        TaskType::Generate => {
81            hints.push("Pattern: match existing code style in context".to_string());
82            if !classification.targets.is_empty() {
83                hints.push(format!(
84                    "Insert near: {}",
85                    classification.targets.join(", ")
86                ));
87            }
88        }
89        TaskType::FixBug => {
90            hints.push("Focus: identify root cause, minimal fix".to_string());
91            if let Some(largest) = file_context.iter().max_by_key(|(_, lines)| *lines) {
92                hints.push(format!("Primary file: {} ({}L)", largest.0, largest.1));
93            }
94        }
95        TaskType::Refactor => {
96            hints.push("Preserve: all public APIs and behavior".to_string());
97            hints.push(format!("Files in scope: {}", file_context.len()));
98        }
99        TaskType::Explore => {
100            hints.push("Depth: signatures + key logic, skip boilerplate".to_string());
101        }
102        TaskType::Test => {
103            hints.push("Pattern: follow existing test patterns in codebase".to_string());
104        }
105        TaskType::Debug => {
106            hints.push("Trace: follow data flow through call chain".to_string());
107        }
108        _ => {}
109    }
110
111    hints
112}
113
114pub fn format_briefing(briefing: &TaskBriefing) -> String {
115    format_briefing_with(briefing, true)
116}
117
118/// Render a briefing, optionally without its `OUTPUT-HINT:` line (#1763).
119///
120/// The hint tells the assistant how to *format its answer*. That is a
121/// behaviour nudge, not retrieval output, so callers that surface a briefing
122/// inside a tool result gate it on the same `behavior_nudges` config key the
123/// nudge system honours — `off` must silence every answer-shaping directive,
124/// not just the ones emitted post-dispatch.
125pub fn format_briefing_with(briefing: &TaskBriefing, include_output_hint: bool) -> String {
126    let mut parts = Vec::new();
127
128    parts.push(format!(
129        "[TASK:{} {}]",
130        briefing.classification.task_type.as_str(),
131        briefing.completeness_signal.as_str(),
132    ));
133
134    if include_output_hint {
135        parts.push(briefing.output_instruction.to_string());
136    }
137
138    if !briefing.context_hints.is_empty() {
139        for hint in &briefing.context_hints {
140            parts.push(format!("• {hint}"));
141        }
142    }
143
144    parts.join("\n")
145}
146
147pub fn inject_into_instructions(base_instructions: &str, task: &str) -> String {
148    if task.trim().is_empty() {
149        return base_instructions.to_string();
150    }
151
152    let file_context: Vec<(String, usize)> = Vec::new();
153    let briefing = build_briefing(task, &file_context);
154    let briefing_block = format_briefing(&briefing);
155
156    format!("{base_instructions}\n\n{briefing_block}")
157}
158
159#[cfg(test)]
160pub mod tests {
161    use super::*;
162
163    #[test]
164    fn briefing_for_generate_task() {
165        let files = vec![("src/core/entropy.rs".to_string(), 120)];
166        let briefing = build_briefing("add normalized_token_entropy to entropy.rs", &files);
167        assert_eq!(briefing.classification.task_type, TaskType::Generate);
168        assert!(briefing.output_instruction.contains("code blocks"));
169        assert!(briefing.lab_thinking_instruction.contains("Skip analysis"));
170    }
171
172    #[test]
173    fn briefing_for_fix_bug() {
174        let files = vec![
175            ("src/core/entropy.rs".to_string(), 200),
176            ("src/core/tokens.rs".to_string(), 50),
177        ];
178        let briefing = build_briefing("fix the NaN bug in token_entropy", &files);
179        assert_eq!(briefing.classification.task_type, TaskType::FixBug);
180        assert!(briefing.output_instruction.contains("changed lines"));
181        assert!(
182            !briefing.lab_thinking_instruction.is_empty(),
183            "`briefing.lab_thinking_instruction` is empty"
184        );
185    }
186
187    #[test]
188    fn completeness_single_file() {
189        let files = vec![("src/core/entropy.rs".to_string(), 200)];
190        let briefing = build_briefing("add a function", &files);
191        matches!(briefing.completeness_signal, CompletenessSignal::SingleFile);
192    }
193
194    #[test]
195    fn completeness_cross_module() {
196        let files = vec![
197            ("src/core/a.rs".to_string(), 100),
198            ("src/tools/b.rs".to_string(), 100),
199            ("src/server.rs".to_string(), 100),
200            ("tests/integration.rs".to_string(), 100),
201        ];
202        let briefing = build_briefing("refactor compression pipeline", &files);
203        matches!(
204            briefing.completeness_signal,
205            CompletenessSignal::CrossModule
206        );
207    }
208
209    #[test]
210    fn format_briefing_includes_all_sections() {
211        let files = vec![("src/core/entropy.rs".to_string(), 120)];
212        let briefing = build_briefing("fix bug in entropy.rs", &files);
213        let formatted = format_briefing(&briefing);
214        assert!(formatted.contains("[TASK:"));
215        assert!(formatted.contains("OUTPUT-HINT:"));
216        assert!(formatted.contains("SCOPE:"));
217    }
218
219    #[test]
220    fn format_briefing_without_output_hint_keeps_classification_and_hints() {
221        // #1763: with nudges off the answer-shaping directive is dropped, but
222        // the task classification and the context hints still render.
223        let files = vec![("src/core/entropy.rs".to_string(), 120)];
224        let briefing = build_briefing("how does the entropy scorer work?", &files);
225        let quiet = format_briefing_with(&briefing, false);
226        assert!(quiet.contains("[TASK:"));
227        assert!(quiet.contains("SCOPE:"));
228        assert!(!quiet.contains("OUTPUT-HINT:"), "{quiet}");
229        assert!(
230            !briefing.context_hints.is_empty(),
231            "explore tasks carry hints"
232        );
233        for hint in &briefing.context_hints {
234            assert!(quiet.contains(hint), "hint '{hint}' must survive");
235        }
236        // The default rendering is unchanged.
237        assert_eq!(
238            format_briefing(&briefing),
239            format_briefing_with(&briefing, true)
240        );
241    }
242
243    #[test]
244    fn inject_empty_task_unchanged() {
245        let base = "some instructions";
246        let result = inject_into_instructions(base, "");
247        assert_eq!(result, base);
248    }
249
250    #[test]
251    fn briefing_covers_all_task_types() {
252        let scenarios: &[(&str, &str)] = &[
253            ("add a new function to entropy.rs", "generate"),
254            ("fix the bug in token_optimizer.rs", "fix_bug"),
255            ("how does the session cache work?", "explore"),
256            ("refactor compression pipeline", "refactor"),
257            ("write unit tests for entropy", "test"),
258            ("debug why compression ratio drops", "debug"),
259        ];
260        for &(task, expected_type) in scenarios {
261            let briefing = build_briefing(task, &[("src/main.rs".to_string(), 100)]);
262            assert_eq!(
263                briefing.classification.task_type.as_str(),
264                expected_type,
265                "Task '{task}' should be classified as '{expected_type}'",
266            );
267            let formatted = format_briefing(&briefing);
268            assert!(formatted.contains("[TASK:"));
269            assert!(formatted.contains("OUTPUT-HINT:"));
270        }
271    }
272}