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