1use std::collections::BTreeSet;
2use std::fmt::Write as _;
3
4use crate::config::constants::tools;
5use crate::config::types::CapabilityLevel;
6use crate::core::agent::harness_kernel::SessionToolCatalogSnapshot;
7use crate::prompts::sections::SectionBoundaryMode;
8
9const TOOL_UNIFIED_EXEC: &str = tools::UNIFIED_EXEC;
10const TOOL_UNIFIED_FILE: &str = tools::UNIFIED_FILE;
11const TOOL_UNIFIED_SEARCH: &str = tools::UNIFIED_SEARCH;
12const TOOL_READ_FILE: &str = tools::READ_FILE;
13const TOOL_LIST_FILES: &str = tools::LIST_FILES;
14const TOOL_APPLY_PATCH: &str = tools::APPLY_PATCH;
15const TOOL_REQUEST_USER_INPUT: &str = tools::REQUEST_USER_INPUT;
16const TOOL_TASK_TRACKER: &str = tools::TASK_TRACKER;
17const TOOL_PLAN_TASK_TRACKER: &str = tools::PLAN_TASK_TRACKER;
18
19pub fn generate_tool_guidelines(
21 available_tools: &[String],
22 capability_level: Option<CapabilityLevel>,
23) -> String {
24 let has_exec = available_tools.iter().any(|tool| tool == TOOL_UNIFIED_EXEC);
25 let has_file = available_tools.iter().any(|tool| tool == TOOL_UNIFIED_FILE);
26 let has_search = available_tools
27 .iter()
28 .any(|tool| tool == TOOL_UNIFIED_SEARCH);
29 let has_read_file = available_tools.iter().any(|tool| tool == TOOL_READ_FILE);
30 let has_list_files = available_tools.iter().any(|tool| tool == TOOL_LIST_FILES);
31 let has_apply_patch = available_tools.iter().any(|tool| tool == TOOL_APPLY_PATCH);
32
33 let mut lines = Vec::new();
34 if let Some(mode_line) = capability_mode_line(capability_level, has_exec, has_file) {
35 lines.push(mode_line.to_string());
36 }
37 if let Some(browse_guidance) =
38 browse_tool_guidance(has_search, has_file, has_list_files, has_read_file)
39 {
40 lines.push(browse_guidance);
41 }
42 if has_file || has_apply_patch {
43 lines.push("- Read before edit and keep patches small.".to_string());
44 }
45 if has_exec {
46 lines.push(
47 "- Use `unified_exec` for verification, `git diff -- <path>`, and commands the public tools cannot express."
48 .to_string(),
49 );
50 }
51 if has_exec || has_file || has_apply_patch {
52 lines.push(
53 "- Completion is a checkpoint: keep `task_tracker` current and verification resolved."
54 .to_string(),
55 );
56 }
57 if has_search && has_exec {
58 lines.push("- Prefer search over shell for exploration.".to_string());
59 }
60 if has_file || has_apply_patch || has_exec {
61 lines.push(
62 "- If calls repeat without progress, re-plan instead of retrying identically."
63 .to_string(),
64 );
65 }
66 if has_search || has_file || has_exec {
67 lines.push(
68 "- When calling multiple tools with no dependencies, run them in parallel (e.g., read files or run independent commands at once)."
69 .to_string(),
70 );
71 }
72
73 if lines.is_empty() {
74 return String::new();
75 }
76
77 format!("\n\n## Active Tools\n{}", lines.join("\n"))
78}
79
80pub fn append_runtime_tool_prompt_sections(
81 prompt: &mut String,
82 tool_snapshot: &SessionToolCatalogSnapshot,
83 include_catalog_metadata: bool,
84) {
85 remove_prompt_section(prompt, "## Active Tools");
86 remove_prompt_section(prompt, "[Runtime Tool Catalog]");
87 while prompt.ends_with('\n') {
88 prompt.pop();
89 }
90
91 let available_tools = snapshot_tool_names(tool_snapshot);
92 let guidelines = generate_runtime_tool_guidelines(&available_tools, tool_snapshot.plan_mode);
93 if !guidelines.is_empty() {
94 append_prompt_block(prompt, guidelines.trim_start_matches('\n'));
95 }
96
97 if include_catalog_metadata && tool_snapshot.snapshot.is_some() {
98 let catalog_metadata = format!(
99 "[Runtime Tool Catalog]\n- version: {}\n- epoch: {}\n- available_tools: {}\n- request_user_input_enabled: {}",
100 tool_snapshot.version,
101 tool_snapshot.epoch,
102 tool_snapshot.available_tools(),
103 tool_snapshot.request_user_input_enabled,
104 );
105 append_prompt_block(prompt, &catalog_metadata);
106 }
107}
108
109fn append_prompt_block(prompt: &mut String, block: &str) {
110 if block.is_empty() {
111 return;
112 }
113
114 if prompt.is_empty() {
115 prompt.push_str(block);
116 } else {
117 let _ = write!(prompt, "\n\n{block}");
118 }
119}
120
121fn remove_prompt_section(prompt: &mut String, section_header: &str) {
122 while let Some((section_start, section_end)) =
123 find_prompt_section_bounds(prompt, section_header)
124 {
125 prompt.replace_range(section_start..section_end, "");
126 }
127}
128
129fn find_prompt_section_bounds(prompt: &str, section_header: &str) -> Option<(usize, usize)> {
130 crate::prompts::sections::find_prompt_section_bounds(
131 prompt,
132 section_header,
133 SectionBoundaryMode::BracketOrMarkdown,
134 )
135}
136
137fn generate_runtime_tool_guidelines(available_tools: &[String], plan_mode: bool) -> String {
138 if !plan_mode {
139 return generate_tool_guidelines(available_tools, None);
140 }
141
142 let has_exec = available_tools.iter().any(|tool| tool == TOOL_UNIFIED_EXEC);
143 let has_file = available_tools.iter().any(|tool| tool == TOOL_UNIFIED_FILE);
144 let has_search = available_tools
145 .iter()
146 .any(|tool| tool == TOOL_UNIFIED_SEARCH);
147 let has_read_file = available_tools.iter().any(|tool| tool == TOOL_READ_FILE);
148 let has_list_files = available_tools.iter().any(|tool| tool == TOOL_LIST_FILES);
149 let has_request_user_input = available_tools
150 .iter()
151 .any(|tool| tool == TOOL_REQUEST_USER_INPUT);
152 let has_task_tracker = available_tools
153 .iter()
154 .any(|tool| matches!(tool.as_str(), TOOL_TASK_TRACKER | TOOL_PLAN_TASK_TRACKER));
155
156 let mut lines = vec![
157 "- Mode: read-only. Stay within the plan-mode tool list and use only read-safe actions."
158 .to_string(),
159 ];
160 if let Some(browse_guidance) =
161 browse_tool_guidance(has_search, has_file, has_list_files, has_read_file)
162 {
163 lines.push(browse_guidance);
164 }
165 if has_file {
166 lines.push("- In Plan Mode, use `unified_file` only for read-style access.".to_string());
167 }
168 if has_exec {
169 lines.push(
170 "- In Plan Mode, use `unified_exec` only for read-only verification, poll, or inspect actions."
171 .to_string(),
172 );
173 }
174 if has_task_tracker {
175 lines.push("- Keep `task_tracker` updated as you refine the plan.".to_string());
176 lines.push(
177 "- Keep blockers and verification open in `task_tracker` until resolved.".to_string(),
178 );
179 }
180 if has_request_user_input {
181 lines.push(
182 "- Use `request_user_input` only for material blockers that remain after repository exploration."
183 .to_string(),
184 );
185 }
186 if has_search || has_file || has_exec {
187 lines.push(
188 "- If calls repeat without progress, tighten the plan instead of retrying identically."
189 .to_string(),
190 );
191 }
192
193 format!("\n\n## Active Tools\n{}", lines.join("\n"))
194}
195
196fn snapshot_tool_names(tool_snapshot: &SessionToolCatalogSnapshot) -> Vec<String> {
197 let Some(snapshot) = tool_snapshot.snapshot.as_ref() else {
198 return Vec::new();
199 };
200
201 snapshot
202 .iter()
203 .map(|tool| tool.function_name().to_string())
204 .collect::<BTreeSet<_>>()
205 .into_iter()
206 .collect()
207}
208
209fn browse_tool_guidance(
210 has_search: bool,
211 has_file: bool,
212 has_list_files: bool,
213 has_read_file: bool,
214) -> Option<String> {
215 let mut tool_names = Vec::new();
216 if has_search {
217 tool_names.push("`unified_search`");
218 } else if has_list_files {
219 tool_names.push("`list_files`");
220 }
221 if has_file {
222 tool_names.push("`unified_file`");
223 } else if has_read_file {
224 tool_names.push("`read_file`");
225 }
226 if tool_names.is_empty() {
227 return None;
228 }
229
230 Some(format!(
231 "- Prefer {} over shell browsing.",
232 tool_names.join(" and ")
233 ))
234}
235
236fn capability_mode_line(
237 capability_level: Option<CapabilityLevel>,
238 has_exec: bool,
239 has_file: bool,
240) -> Option<&'static str> {
241 match capability_level {
242 Some(CapabilityLevel::Basic) => Some(
243 "- Mode: limited. Ask the user to enable more capabilities if file work is required.",
244 ),
245 Some(CapabilityLevel::FileReading | CapabilityLevel::FileListing) => Some(
246 "- Mode: read-only. Analyze and search, but do not modify files or run shell commands.",
247 ),
248 _ if !has_exec && !has_file => Some(
249 "- Mode: read-only. Analyze and search, but do not modify files or run shell commands.",
250 ),
251 _ => None,
252 }
253}
254
255pub fn infer_capability_level(available_tools: &[String]) -> CapabilityLevel {
257 let has_search = available_tools.iter().any(|t| t == TOOL_UNIFIED_SEARCH);
258 let has_edit = available_tools.iter().any(|t| t == TOOL_UNIFIED_FILE);
259 let has_read = has_edit || available_tools.iter().any(|t| t == TOOL_READ_FILE);
260 let has_list = has_search || available_tools.iter().any(|t| t == TOOL_LIST_FILES);
261 let has_exec = available_tools.iter().any(|t| t == TOOL_UNIFIED_EXEC);
262
263 if has_search {
264 CapabilityLevel::CodeSearch
265 } else if has_edit {
266 CapabilityLevel::Editing
267 } else if has_exec {
268 CapabilityLevel::Bash
269 } else if has_list {
270 CapabilityLevel::FileListing
271 } else if has_read {
272 CapabilityLevel::FileReading
273 } else {
274 CapabilityLevel::Basic
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281
282 #[test]
283 fn test_read_only_mode_detection() {
284 let tools = vec!["unified_search".to_string()];
285 let guidelines = generate_tool_guidelines(&tools, None);
286 assert!(guidelines.contains("Mode: read-only"));
287 assert!(guidelines.contains("do not modify files"));
288 }
289
290 #[test]
291 fn test_tool_preference_guidance() {
292 let tools = vec!["unified_exec".to_string(), "unified_search".to_string()];
293 let guidelines = generate_tool_guidelines(&tools, None);
294 assert!(guidelines.contains("Prefer search over shell"));
295 assert!(guidelines.contains("git diff -- <path>"));
296 assert!(guidelines.contains("Completion is a checkpoint"));
297 }
298
299 #[test]
300 fn test_edit_workflow_guidance() {
301 let tools = vec!["unified_file".to_string()];
302 let guidelines = generate_tool_guidelines(&tools, None);
303 assert!(guidelines.contains("Read before edit"));
304 assert!(guidelines.contains("patches small"));
305 assert!(guidelines.contains("verification resolved"));
306 }
307
308 #[test]
309 fn test_harness_browse_tool_guidance() {
310 let tools = vec![TOOL_LIST_FILES.to_string(), TOOL_READ_FILE.to_string()];
311 let guidelines = generate_tool_guidelines(&tools, None);
312 assert!(guidelines.contains("Prefer `list_files` and `read_file`"));
313 assert!(!guidelines.contains("offset"));
314 assert!(!guidelines.contains("per_page"));
315 }
316
317 #[test]
318 fn test_canonical_browse_tool_guidance_prefers_public_tools() {
319 let tools = vec![
320 "unified_search".to_string(),
321 "unified_file".to_string(),
322 TOOL_LIST_FILES.to_string(),
323 "read_file".to_string(),
324 ];
325 let guidelines = generate_tool_guidelines(&tools, None);
326 assert!(guidelines.contains("Prefer `unified_search` and `unified_file`"));
327 assert!(!guidelines.contains("Prefer `list_files` and `read_file`"));
328 }
329
330 #[test]
331 fn test_capability_basic_guidance() {
332 let tools = vec![];
333 let guidelines = generate_tool_guidelines(&tools, Some(CapabilityLevel::Basic));
334 assert!(guidelines.contains("Mode: limited"));
335 assert!(guidelines.contains("enable more capabilities"));
336 }
337
338 #[test]
339 fn test_capability_file_reading_guidance() {
340 let tools = vec!["unified_file".to_string()];
341 let guidelines = generate_tool_guidelines(&tools, Some(CapabilityLevel::FileReading));
342 assert!(guidelines.contains("Mode: read-only"));
343 assert!(guidelines.contains("do not modify"));
344 }
345
346 #[test]
347 fn test_full_capabilities_no_special_guidance() {
348 let tools = vec![
349 "unified_file".to_string(),
350 "unified_exec".to_string(),
351 "unified_search".to_string(),
352 ];
353 let guidelines = generate_tool_guidelines(&tools, Some(CapabilityLevel::Editing));
354
355 assert!(!guidelines.contains("Mode: limited"));
356 assert!(!guidelines.contains("Mode: read-only"));
357 }
358
359 #[test]
360 fn test_empty_tools_shows_read_only_mode() {
361 let tools = vec![];
362 let guidelines = generate_tool_guidelines(&tools, None);
363 assert!(guidelines.contains("Mode: read-only"));
364 }
365
366 #[test]
367 fn test_plan_mode_guidance_keeps_verification_open() {
368 let tools = vec![
369 TOOL_UNIFIED_EXEC.to_string(),
370 TOOL_TASK_TRACKER.to_string(),
371 TOOL_UNIFIED_SEARCH.to_string(),
372 ];
373 let guidelines = generate_runtime_tool_guidelines(&tools, true);
374 assert!(guidelines.contains("Keep `task_tracker` updated"));
375 assert!(guidelines.contains("blockers and verification open"));
376 }
377
378 #[test]
379 fn test_capability_inference_precedence() {
380 let tools = vec!["unified_file".to_string(), "unified_search".to_string()];
381 assert_eq!(infer_capability_level(&tools), CapabilityLevel::CodeSearch);
382
383 let tools = vec!["unified_exec".to_string(), "unified_file".to_string()];
384 assert_eq!(infer_capability_level(&tools), CapabilityLevel::Editing);
385 }
386
387 #[test]
388 fn test_capability_inference_variants() {
389 let tools = vec!["unified_file".to_string()];
390 assert_eq!(infer_capability_level(&tools), CapabilityLevel::Editing);
391
392 let tools = vec!["unified_exec".to_string()];
393 assert_eq!(infer_capability_level(&tools), CapabilityLevel::Bash);
394
395 let tools = vec!["unified_search".to_string()];
396 assert_eq!(infer_capability_level(&tools), CapabilityLevel::CodeSearch);
397
398 let tools = vec![TOOL_LIST_FILES.to_string()];
399 assert_eq!(infer_capability_level(&tools), CapabilityLevel::FileListing);
400
401 let tools = vec!["read_file".to_string()];
402 assert_eq!(infer_capability_level(&tools), CapabilityLevel::FileReading);
403
404 let tools = vec!["unknown_tool".to_string()];
405 assert_eq!(infer_capability_level(&tools), CapabilityLevel::Basic);
406 }
407
408 #[test]
409 fn test_guidelines_stay_compact() {
410 let tools = vec![
411 "unified_exec".to_string(),
412 "unified_search".to_string(),
413 "unified_file".to_string(),
414 "read_file".to_string(),
415 TOOL_LIST_FILES.to_string(),
416 "apply_patch".to_string(),
417 ];
418 let guidelines = generate_tool_guidelines(&tools, None);
419 let approx_tokens = guidelines.len() / 4;
420 assert!(approx_tokens < 145, "got ~{} tokens", approx_tokens);
421 }
422
423 #[test]
424 fn test_parallel_tool_call_guidance() {
425 let tools = vec![
426 "unified_exec".to_string(),
427 "unified_search".to_string(),
428 "unified_file".to_string(),
429 ];
430 let guidelines = generate_tool_guidelines(&tools, None);
431 assert!(
432 guidelines.contains("parallel"),
433 "Should include parallel tool call guidance"
434 );
435 assert!(
436 guidelines.contains("read files"),
437 "Should mention reading files in parallel"
438 );
439 }
440
441 #[test]
442 fn plan_mode_runtime_guidance_keeps_unified_file_read_only() {
443 let tools = vec![
444 TOOL_UNIFIED_FILE.to_string(),
445 TOOL_UNIFIED_EXEC.to_string(),
446 TOOL_UNIFIED_SEARCH.to_string(),
447 ];
448 let guidelines = generate_runtime_tool_guidelines(&tools, true);
449
450 assert!(guidelines.contains("Mode: read-only"));
451 assert!(guidelines.contains("`unified_file` only for read-style access"));
452 assert!(guidelines.contains("`unified_exec` only for read-only verification"));
453 assert!(!guidelines.contains("Read before edit"));
454 }
455
456 #[test]
457 fn runtime_tool_prompt_sections_include_catalog_metadata() {
458 let mut prompt = "Base prompt".to_string();
459 let snapshot = SessionToolCatalogSnapshot::new(
460 7,
461 9,
462 true,
463 false,
464 Some(std::sync::Arc::new(vec![
465 crate::llm::provider::ToolDefinition::function(
466 TOOL_UNIFIED_SEARCH.to_string(),
467 "Search".to_string(),
468 serde_json::json!({"type": "object"}),
469 ),
470 crate::llm::provider::ToolDefinition::function(
471 TOOL_UNIFIED_FILE.to_string(),
472 "File".to_string(),
473 serde_json::json!({"type": "object"}),
474 ),
475 ])),
476 false,
477 );
478
479 append_runtime_tool_prompt_sections(&mut prompt, &snapshot, true);
480
481 assert!(prompt.contains("## Active Tools"));
482 assert!(prompt.contains("[Runtime Tool Catalog]"));
483 assert!(prompt.contains("request_user_input_enabled: false"));
484 }
485
486 #[test]
487 fn runtime_tool_prompt_sections_replace_existing_runtime_sections() {
488 let mut prompt = "Base prompt".to_string();
489 let first = SessionToolCatalogSnapshot::new(
490 1,
491 2,
492 false,
493 false,
494 Some(std::sync::Arc::new(vec![
495 crate::llm::provider::ToolDefinition::function(
496 TOOL_UNIFIED_SEARCH.to_string(),
497 "Search".to_string(),
498 serde_json::json!({"type": "object"}),
499 ),
500 ])),
501 false,
502 );
503 let second = SessionToolCatalogSnapshot::new(
504 7,
505 9,
506 true,
507 true,
508 Some(std::sync::Arc::new(vec![
509 crate::llm::provider::ToolDefinition::function(
510 TOOL_UNIFIED_FILE.to_string(),
511 "File".to_string(),
512 serde_json::json!({"type": "object"}),
513 ),
514 ])),
515 false,
516 );
517
518 append_runtime_tool_prompt_sections(&mut prompt, &first, true);
519 append_runtime_tool_prompt_sections(&mut prompt, &second, true);
520
521 assert_eq!(prompt.matches("## Active Tools").count(), 1);
522 assert_eq!(prompt.matches("[Runtime Tool Catalog]").count(), 1);
523 assert!(prompt.contains("version: 7"));
524 assert!(!prompt.contains("version: 1"));
525 assert!(prompt.contains("request_user_input_enabled: true"));
526 assert!(!prompt.contains("request_user_input_enabled: false"));
527 }
528}