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 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], planning_active: bool) -> String {
138 if !planning_active {
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));
155
156 let mut lines =
157 vec!["- Planning workflow active: stay within the read-safe tool list.".to_string()];
158 if let Some(browse_guidance) =
159 browse_tool_guidance(has_search, has_file, has_list_files, has_read_file)
160 {
161 lines.push(browse_guidance);
162 }
163 if has_file {
164 lines.push(
165 "- In Planning workflow, use `unified_file` only for read-style access.".to_string(),
166 );
167 }
168 if has_exec {
169 lines.push(
170 "- In Planning workflow, 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 "- Capabilities: limited. Ask the user to enable more capabilities if file work is required.",
244 ),
245 Some(CapabilityLevel::FileReading | CapabilityLevel::FileListing) => Some(
246 "- Capabilities: read-only. Analyze and search, but do not modify files or run shell commands.",
247 ),
248 _ if !has_exec && !has_file => Some(
249 "- Capabilities: 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_capability_detection() {
284 let tools = vec!["unified_search".to_string()];
285 let guidelines = generate_tool_guidelines(&tools, None);
286 assert!(guidelines.contains("Capabilities: 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("Capabilities: 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("Capabilities: 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("Capabilities: limited"));
356 assert!(!guidelines.contains("Capabilities: read-only"));
357 }
358
359 #[test]
360 fn test_empty_tools_shows_read_only_capabilities() {
361 let tools = vec![];
362 let guidelines = generate_tool_guidelines(&tools, None);
363 assert!(guidelines.contains("Capabilities: read-only"));
364 }
365
366 #[test]
367 fn test_planning_workflow_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 planning_workflow_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("Planning workflow active"));
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}