1use std::sync::Arc;
2
3use serde_json::Value;
4
5use crate::core::agent::features::FeatureSet;
6use crate::llm::provider::ToolDefinition;
7use crate::tools::tool_intent;
8use crate::tools::validation::commands;
9
10pub fn should_expose_tool_in_mode(
11 tool: &ToolDefinition,
12 planning_active: bool,
13 request_user_input_enabled: bool,
14) -> bool {
15 let Some(name) = tool.function.as_ref().map(|func| func.name.as_str()) else {
16 return true;
17 };
18
19 FeatureSet::tool_enabled_for_mode(name, planning_active, request_user_input_enabled)
20}
21
22pub fn should_expose_tool_in_stable_catalog(tool: &ToolDefinition, request_user_input_enabled: bool) -> bool {
26 should_expose_tool_in_mode(tool, false, request_user_input_enabled)
27 || should_expose_tool_in_mode(tool, true, request_user_input_enabled)
28}
29
30pub fn filter_tool_definitions_for_mode(
31 tools: Option<Arc<Vec<ToolDefinition>>>,
32 planning_active: bool,
33 request_user_input_enabled: bool,
34) -> Option<Arc<Vec<ToolDefinition>>> {
35 filter_tool_definitions_for_mode_with_stability(tools, planning_active, request_user_input_enabled, false)
36}
37
38pub fn filter_tool_definitions_for_mode_with_stability(
39 tools: Option<Arc<Vec<ToolDefinition>>>,
40 planning_active: bool,
41 request_user_input_enabled: bool,
42 stable_catalog: bool,
43) -> Option<Arc<Vec<ToolDefinition>>> {
44 let tools = tools?;
45 if stable_catalog {
48 if tools
53 .iter()
54 .all(|tool| should_expose_tool_in_stable_catalog(tool, request_user_input_enabled))
55 {
56 return Some(tools);
57 }
58 let filtered: Vec<ToolDefinition> = tools
59 .iter()
60 .filter(|tool| should_expose_tool_in_stable_catalog(tool, request_user_input_enabled))
61 .cloned()
62 .collect();
63 return if filtered.is_empty() {
64 None
65 } else {
66 Some(Arc::new(filtered))
67 };
68 }
69
70 if !planning_active {
71 if tools
73 .iter()
74 .all(|tool| should_expose_tool_in_mode(tool, false, request_user_input_enabled))
75 {
76 return Some(tools);
77 }
78 let filtered: Vec<ToolDefinition> = tools
79 .iter()
80 .filter(|tool| should_expose_tool_in_mode(tool, false, request_user_input_enabled))
81 .cloned()
82 .collect();
83 return if filtered.is_empty() {
84 None
85 } else {
86 Some(Arc::new(filtered))
87 };
88 }
89
90 if !tools.is_empty()
92 && tools.iter().all(|tool| {
93 should_expose_tool_in_mode(tool, true, request_user_input_enabled) && planning_action_mask(tool).is_none()
94 })
95 {
96 return Some(tools);
97 }
98
99 let filtered: Vec<ToolDefinition> = tools
100 .iter()
101 .filter(|tool| should_expose_tool_in_mode(tool, true, request_user_input_enabled))
102 .map(|tool| mask_tool_actions_for_mode(tool, true))
103 .collect();
104 if filtered.is_empty() {
105 None
106 } else {
107 Some(Arc::new(filtered))
108 }
109}
110
111fn planning_action_mask(tool: &ToolDefinition) -> Option<&'static [&'static str]> {
116 let function = tool.function.as_ref()?;
117 let allowed = tool_intent::planning_allowed_actions(function.name.as_str())?;
118 let action_enum = function.parameters.get("properties")?.get("action")?.get("enum")?;
119 action_enum.is_array().then_some(allowed)
120}
121
122fn mask_tool_actions_for_mode(tool: &ToolDefinition, planning_active: bool) -> ToolDefinition {
123 if !planning_active {
124 return tool.clone();
125 }
126 let Some(allowed) = planning_action_mask(tool) else {
127 return tool.clone();
128 };
129
130 let mut masked = tool.clone();
131 if let Some(func) = masked.function.as_mut()
132 && let Some(action_prop) = func.parameters.get_mut("properties").and_then(|p| p.get_mut("action"))
133 && let Some(obj) = action_prop.as_object_mut()
134 {
135 obj.insert("enum".to_string(), Value::Array(allowed.iter().map(|a| Value::String((*a).to_string())).collect()));
136 }
137 masked
138}
139
140pub use crate::core::agent::hash_utils::{
141 hash_tool_definitions, hash_value, low_signal_attempt_key, stable_system_prefix_hash,
142};
143pub use crate::core::agent::request_plan::{HarnessRequestPlan, HarnessRequestPlanInput, build_harness_request_plan};
144pub use crate::core::agent::result_reducers::{
145 project_model_tool_result, reduce_tool_result, strip_tui_display_fields,
146};
147pub use crate::core::agent::tool_batching::{
148 FallbackRecommendation, FallbackStep, PreparedToolBatch, PreparedToolBatchKind, PreparedToolCall,
149 is_parallel_safe_tool_batch,
150};
151pub use crate::core::agent::tool_catalog::SessionToolCatalogSnapshot;
152
153pub fn looks_like_grep_style_command(command: &str) -> bool {
154 let lower = command.trim().to_ascii_lowercase();
155 lower.starts_with("grep ") || lower.starts_with("rg ") || lower.contains("/grep ") || lower.contains("/rg ")
156}
157
158pub fn command_is_safe(command: &str) -> bool {
159 commands::validate_command_safety(command).is_ok()
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use crate::config::constants::tools;
166 use crate::llm::provider::{Message, ToolChoice};
167
168 fn function_tool(name: &str) -> ToolDefinition {
169 ToolDefinition::function(name.to_string(), name.to_string(), serde_json::json!({}))
170 }
171
172 #[test]
173 fn request_plan_keeps_stable_prefix_hash() {
174 let plan = build_harness_request_plan(HarnessRequestPlanInput {
175 messages: Arc::new(vec![Message::user("hello".to_string())]),
176 system_prompt: Arc::from("base\n[Runtime Context]\n- turns: 1"),
177 tools: Some(Arc::new(vec![function_tool(tools::CODE_SEARCH)])),
178 model: "gpt-5".to_string(),
179 max_tokens: Some(128),
180 temperature: Some(0.7),
181 top_p: None,
182 top_k: None,
183 presence_penalty: None,
184 frequency_penalty: None,
185 stream: true,
186 tool_choice: Some(ToolChoice::auto()),
187 parallel_tool_config: None,
188 reasoning_effort: None,
189 verbosity: None,
190 metadata: None,
191 context_management: None,
192 previous_response_id: None,
193 prompt_cache_key: None,
194 prompt_cache_profile: None,
195 tool_catalog_hash: None,
196 system_prompt_prefix_hash: None,
197 });
198
199 assert!(plan.has_tools);
200 assert!(plan.tool_catalog_hash.is_some());
201 assert_eq!(plan.stable_prefix_hash, stable_system_prefix_hash("base\n[Runtime Context]\n- turns: 1"));
202 }
203
204 #[test]
205 fn request_plan_drops_empty_tool_catalog() {
206 let plan = build_harness_request_plan(HarnessRequestPlanInput {
207 messages: Arc::new(vec![Message::user("hello".to_string())]),
208 system_prompt: Arc::from("base"),
209 tools: Some(Arc::new(Vec::new())),
210 model: "gpt-5".to_string(),
211 max_tokens: Some(128),
212 temperature: Some(0.7),
213 top_p: None,
214 top_k: None,
215 presence_penalty: None,
216 frequency_penalty: None,
217 stream: true,
218 tool_choice: Some(ToolChoice::auto()),
219 parallel_tool_config: None,
220 reasoning_effort: None,
221 verbosity: None,
222 metadata: None,
223 context_management: None,
224 previous_response_id: None,
225 prompt_cache_key: None,
226 prompt_cache_profile: None,
227 tool_catalog_hash: None,
228 system_prompt_prefix_hash: None,
229 });
230
231 assert!(!plan.has_tools);
232 assert!(plan.request.tools.is_none());
233 assert!(plan.tool_catalog_hash.is_none());
234 }
235
236 #[test]
237 fn request_plan_carries_sampling_overrides_into_request() {
238 let plan = build_harness_request_plan(HarnessRequestPlanInput {
239 messages: Arc::new(vec![Message::user("hello".to_string())]),
240 system_prompt: Arc::from("base"),
241 tools: None,
242 model: "gpt-5".to_string(),
243 max_tokens: Some(4_096),
244 temperature: Some(0.2),
245 top_p: Some(0.9),
246 top_k: Some(40),
247 presence_penalty: Some(0.1),
248 frequency_penalty: Some(-0.5),
249 stream: true,
250 tool_choice: None,
251 parallel_tool_config: None,
252 reasoning_effort: None,
253 verbosity: None,
254 metadata: None,
255 context_management: None,
256 previous_response_id: None,
257 prompt_cache_key: None,
258 prompt_cache_profile: None,
259 tool_catalog_hash: None,
260 system_prompt_prefix_hash: None,
261 });
262
263 assert_eq!(plan.request.temperature, Some(0.2));
264 assert_eq!(plan.request.top_p, Some(0.9));
265 assert_eq!(plan.request.top_k, Some(40));
266 assert_eq!(plan.request.presence_penalty, Some(0.1));
267 assert_eq!(plan.request.frequency_penalty, Some(-0.5));
268 assert_eq!(plan.request.max_tokens, Some(4_096));
269 }
270
271 #[test]
272 fn prepared_tool_batches_group_contiguous_parallel_reads() {
273 let batches = PreparedToolBatch::plan(
274 vec![
275 PreparedToolCall::new("read_a".to_string(), true, true, serde_json::json!({})),
276 PreparedToolCall::new("read_b".to_string(), true, true, serde_json::json!({})),
277 PreparedToolCall::new("edit".to_string(), false, false, serde_json::json!({})),
278 ],
279 true,
280 );
281
282 assert_eq!(batches.len(), 2);
283 assert_eq!(batches[0].kind, PreparedToolBatchKind::ParallelReadonly);
284 assert_eq!(batches[0].calls.len(), 2);
285 assert_eq!(batches[1].kind, PreparedToolBatchKind::Sequential);
286 }
287
288 #[test]
289 fn prepared_tool_batches_preserve_order_around_mutating_calls() {
290 let batches = PreparedToolBatch::plan(
291 vec![
292 PreparedToolCall::new("read_a".to_string(), true, true, serde_json::json!({})),
293 PreparedToolCall::new("edit".to_string(), false, false, serde_json::json!({})),
294 PreparedToolCall::new("read_b".to_string(), true, true, serde_json::json!({})),
295 ],
296 true,
297 );
298
299 assert_eq!(batches.len(), 3);
300 assert!(batches.iter().all(|batch| batch.kind == PreparedToolBatchKind::Sequential));
301 assert_eq!(batches[0].calls[0].canonical_name, "read_a");
302 assert_eq!(batches[1].calls[0].canonical_name, "edit");
303 assert_eq!(batches[2].calls[0].canonical_name, "read_b");
304 }
305
306 #[test]
307 fn prepared_tool_batches_group_duplicate_parallel_tool_names() {
308 let batches = PreparedToolBatch::plan(
309 vec![
310 PreparedToolCall::new("read_file".to_string(), true, true, serde_json::json!({})),
311 PreparedToolCall::new("read_file".to_string(), true, true, serde_json::json!({})),
312 ],
313 true,
314 );
315
316 assert_eq!(batches.len(), 1);
317 assert_eq!(batches[0].kind, PreparedToolBatchKind::ParallelReadonly);
318 assert_eq!(batches[0].calls.len(), 2);
319 }
320
321 #[test]
322 fn prepared_tool_batches_keep_duplicate_nonparallel_calls_sequential() {
323 let batches = PreparedToolBatch::plan(
324 vec![
325 PreparedToolCall::new("exec_command".to_string(), false, false, serde_json::json!({})),
326 PreparedToolCall::new("exec_command".to_string(), false, false, serde_json::json!({})),
327 ],
328 true,
329 );
330
331 assert_eq!(batches.len(), 2);
332 assert!(batches.iter().all(|batch| batch.kind == PreparedToolBatchKind::Sequential));
333 assert!(batches.iter().all(|batch| batch.calls.len() == 1));
334 }
335
336 #[test]
337 fn prepared_tool_batches_serializes_all_calls_when_parallel_disabled() {
338 let batches = PreparedToolBatch::plan(
339 vec![
340 PreparedToolCall::new("read_a".to_string(), true, true, serde_json::json!({})),
341 PreparedToolCall::new("read_b".to_string(), true, true, serde_json::json!({})),
342 ],
343 false,
344 );
345
346 assert_eq!(batches.len(), 2);
347 assert!(batches.iter().all(|batch| batch.kind == PreparedToolBatchKind::Sequential));
348 }
349
350 #[test]
351 fn filter_tool_definitions_respects_request_user_input_toggle() {
352 let tools = Arc::new(vec![
353 function_tool(tools::CODE_SEARCH),
354 function_tool(tools::REQUEST_USER_INPUT),
355 ]);
356
357 let filtered = filter_tool_definitions_for_mode(Some(tools), true, false).expect("filtered tools");
358 let names: Vec<&str> = filtered.iter().map(|tool| tool.function_name()).collect();
359
360 assert!(names.contains(&tools::CODE_SEARCH));
361 assert!(!names.contains(&tools::REQUEST_USER_INPUT));
362 }
363
364 #[test]
365 fn filter_tool_definitions_hides_mutating_only_tools_in_planning_workflow() {
366 let tools = Arc::new(vec![
367 function_tool(tools::CODE_SEARCH),
368 function_tool(tools::UNIFIED_FILE),
369 function_tool(tools::APPLY_PATCH),
370 function_tool(tools::WRITE_STDIN),
371 function_tool(tools::WRITE_FILE),
372 ]);
373
374 let filtered = filter_tool_definitions_for_mode(Some(tools), true, false).expect("filtered tools");
375 let names: Vec<&str> = filtered.iter().map(|tool| tool.function_name()).collect();
376
377 assert!(names.contains(&tools::CODE_SEARCH));
378 assert!(names.contains(&tools::UNIFIED_FILE));
379 assert!(!names.contains(&tools::APPLY_PATCH));
380 assert!(!names.contains(&tools::WRITE_STDIN));
381 assert!(!names.contains(&tools::WRITE_FILE));
382 }
383
384 #[test]
385 fn stable_catalog_keeps_mutating_tools_across_planning_toggles() {
386 let tools = Arc::new(vec![
387 function_tool(tools::CODE_SEARCH),
388 function_tool(tools::UNIFIED_FILE),
389 function_tool(tools::APPLY_PATCH),
390 function_tool(tools::WRITE_FILE),
391 ]);
392
393 let planning = filter_tool_definitions_for_mode_with_stability(Some(tools.clone()), true, false, true)
394 .expect("stable planning catalog");
395 let execution = filter_tool_definitions_for_mode_with_stability(Some(tools), false, false, true)
396 .expect("stable execution catalog");
397 let planning_names: Vec<&str> = planning.iter().map(|tool| tool.function_name()).collect();
398 let execution_names: Vec<&str> = execution.iter().map(|tool| tool.function_name()).collect();
399 assert_eq!(planning_names, execution_names, "stable catalog must match across modes");
400 assert!(planning_names.contains(&tools::APPLY_PATCH));
401 assert!(planning_names.contains(&tools::WRITE_FILE));
402 }
403
404 #[test]
405 fn stable_catalog_reuses_arc_when_nothing_is_filtered() {
406 let tools = Arc::new(vec![function_tool(tools::CODE_SEARCH), function_tool(tools::APPLY_PATCH)]);
410
411 let filtered = filter_tool_definitions_for_mode_with_stability(Some(tools.clone()), true, false, true)
412 .expect("stable catalog");
413 assert!(Arc::ptr_eq(&tools, &filtered), "no-op stable filter must reuse the Arc");
414
415 let execution = filter_tool_definitions_for_mode_with_stability(Some(tools.clone()), false, false, true)
416 .expect("stable catalog");
417 assert!(Arc::ptr_eq(&filtered, &execution), "stable catalog must be identical across toggles");
418
419 let mixed = Arc::new(vec![
420 function_tool(tools::CODE_SEARCH),
421 function_tool(tools::REQUEST_USER_INPUT),
422 ]);
423 let pruned = filter_tool_definitions_for_mode_with_stability(Some(mixed.clone()), true, false, true)
424 .expect("stable catalog");
425 assert!(!Arc::ptr_eq(&mixed, &pruned));
426 let names: Vec<&str> = pruned.iter().map(|tool| tool.function_name()).collect();
427 assert!(names.contains(&tools::CODE_SEARCH));
428 assert!(!names.contains(&tools::REQUEST_USER_INPUT));
429
430 let hidden = Arc::new(vec![function_tool(tools::REQUEST_USER_INPUT)]);
431 assert!(
432 filter_tool_definitions_for_mode_with_stability(Some(hidden), false, false, true).is_none(),
433 "fully-hidden stable catalog stays None"
434 );
435 }
436
437 #[test]
438 fn stable_prefix_hash_ignores_runtime_tool_sections() {
439 let base = "Base prompt\n[Harness Limits]\n- max_tool_calls_per_turn: 5";
440 let with_runtime_sections = format!(
441 "{base}\n\n## Active Tools\n- Capabilities: read-only.\n[Runtime Tool Catalog]\n- version: 1\n- epoch: 2\n- available_tools: 3\n- request_user_input_enabled: false\n\n# PLANNING WORKFLOW (READ-ONLY)\nread-only\n\n# FULL-AUTO: Complete task autonomously until done or blocked.\n- autonomous"
442 );
443
444 assert_eq!(stable_system_prefix_hash(base), stable_system_prefix_hash(&with_runtime_sections));
445 }
446
447 #[test]
448 fn tool_catalog_hash_matches_legacy_json_string_hash() {
449 let tools = vec![
450 function_tool(tools::CODE_SEARCH),
451 ToolDefinition::function(
452 "custom_tool".to_string(),
453 "Custom".to_string(),
454 serde_json::json!({
455 "type": "object",
456 "properties": {
457 "path": { "type": "string" },
458 "line": { "type": "integer" }
459 }
460 }),
461 )
462 .with_strict(true)
463 .with_defer_loading(true),
464 ];
465
466 let expected = serde_json::to_string(&tools).ok().map(|text| hash_value(&text));
467
468 assert_eq!(hash_tool_definitions(Some(&tools)), expected);
469 }
470
471 #[test]
472 fn reduce_command_result_truncates_large_output() {
473 let stdout = (0..2200).map(|_| "a").collect::<Vec<_>>().join("\n");
474 let reduced = reduce_tool_result(
475 tools::UNIFIED_EXEC,
476 serde_json::json!({
477 "stdout": stdout
478 }),
479 );
480
481 assert_eq!(reduced.get("is_truncated"), Some(&Value::Bool(true)));
482 }
483
484 fn tool_with_action_enum(name: &str, actions: &[&str]) -> ToolDefinition {
485 ToolDefinition::function(
486 name.to_string(),
487 name.to_string(),
488 serde_json::json!({
489 "type": "object",
490 "properties": {
491 "action": {
492 "type": "string",
493 "enum": actions
494 }
495 }
496 }),
497 )
498 }
499
500 #[test]
501 fn filter_tool_definitions_masks_unified_file_actions_in_planning() {
502 let tools = Arc::new(vec![tool_with_action_enum(
503 tools::UNIFIED_FILE,
504 &["read", "write", "edit", "patch", "delete", "move", "copy"],
505 )]);
506
507 let filtered = filter_tool_definitions_for_mode(Some(tools), true, false).expect("filtered tools");
508 assert_eq!(filtered.len(), 1);
509
510 let action_enum = filtered[0]
511 .function
512 .as_ref()
513 .unwrap()
514 .parameters
515 .get("properties")
516 .and_then(|p| p.get("action"))
517 .and_then(|a| a.get("enum"))
518 .and_then(|e| e.as_array())
519 .expect("action enum should exist");
520
521 let action_strings: Vec<&str> = action_enum.iter().filter_map(|v| v.as_str()).collect();
522 assert_eq!(action_strings, vec!["read"]);
523 }
524
525 #[test]
526 fn filter_tool_definitions_masks_unified_exec_actions_in_planning() {
527 let tools = Arc::new(vec![tool_with_action_enum(
528 tools::UNIFIED_EXEC,
529 &["run", "write", "poll", "continue", "inspect", "list", "close", "code"],
530 )]);
531
532 let filtered = filter_tool_definitions_for_mode(Some(tools), true, false).expect("filtered tools");
533 assert_eq!(filtered.len(), 1);
534
535 let action_enum = filtered[0]
536 .function
537 .as_ref()
538 .unwrap()
539 .parameters
540 .get("properties")
541 .and_then(|p| p.get("action"))
542 .and_then(|a| a.get("enum"))
543 .and_then(|e| e.as_array())
544 .expect("action enum should exist");
545
546 let action_strings: Vec<&str> = action_enum.iter().filter_map(|v| v.as_str()).collect();
547 assert_eq!(action_strings, vec!["run", "poll", "wait", "list", "inspect", "continue"]);
548 }
549
550 #[test]
551 fn filter_tool_definitions_preserves_actions_when_planning_inactive() {
552 let tools = Arc::new(vec![tool_with_action_enum(
553 tools::UNIFIED_FILE,
554 &["read", "write", "edit", "patch", "delete", "move", "copy"],
555 )]);
556
557 let filtered = filter_tool_definitions_for_mode(Some(tools), false, false).expect("filtered tools");
558 assert_eq!(filtered.len(), 1);
559
560 let action_enum = filtered[0]
561 .function
562 .as_ref()
563 .unwrap()
564 .parameters
565 .get("properties")
566 .and_then(|p| p.get("action"))
567 .and_then(|a| a.get("enum"))
568 .and_then(|e| e.as_array())
569 .expect("action enum should exist");
570
571 let action_strings: Vec<&str> = action_enum.iter().filter_map(|v| v.as_str()).collect();
572 assert_eq!(action_strings, vec!["read", "write", "edit", "patch", "delete", "move", "copy"]);
573 }
574
575 #[test]
576 fn filter_tool_definitions_skips_masking_for_non_multi_action_tools() {
577 let tools = Arc::new(vec![function_tool(tools::CODE_SEARCH)]);
578
579 let filtered = filter_tool_definitions_for_mode(Some(Arc::clone(&tools)), true, false).expect("filtered tools");
580 assert_eq!(filtered.len(), 1);
581 assert_eq!(filtered[0].function_name(), tools::CODE_SEARCH);
583 assert!(Arc::ptr_eq(&tools, &filtered));
584 }
585
586 #[test]
587 fn filter_tool_definitions_preserves_action_schema_without_enum() {
588 let tools = Arc::new(vec![ToolDefinition::function(
589 tools::UNIFIED_FILE.to_string(),
590 "Unified file operation".to_string(),
591 serde_json::json!({
592 "type": "object",
593 "properties": {
594 "action": { "type": "string" }
595 }
596 }),
597 )]);
598
599 let filtered = filter_tool_definitions_for_mode(Some(tools), true, false).expect("filtered tools");
600 let action = filtered[0]
601 .function
602 .as_ref()
603 .and_then(|function| function.parameters.get("properties"))
604 .and_then(|properties| properties.get("action"))
605 .expect("action schema should remain");
606
607 assert_eq!(action.get("type"), Some(&Value::String("string".to_string())));
608 assert!(action.get("enum").is_none());
609 }
610
611 #[test]
612 fn filter_tool_definitions_masks_actions_even_when_no_whole_tool_filter() {
613 let tools = Arc::new(vec![
616 function_tool(tools::CODE_SEARCH),
617 tool_with_action_enum(tools::UNIFIED_FILE, &["read", "write", "edit"]),
618 ]);
619
620 let filtered = filter_tool_definitions_for_mode(Some(tools), true, false).expect("filtered tools");
621 assert_eq!(filtered.len(), 2);
622
623 let unified_file = filtered
624 .iter()
625 .find(|t| t.function_name() == tools::UNIFIED_FILE)
626 .expect("unified_file should be present");
627
628 let action_enum = unified_file
629 .function
630 .as_ref()
631 .unwrap()
632 .parameters
633 .get("properties")
634 .and_then(|p| p.get("action"))
635 .and_then(|a| a.get("enum"))
636 .and_then(|e| e.as_array())
637 .expect("action enum should exist");
638
639 let action_strings: Vec<&str> = action_enum.iter().filter_map(|v| v.as_str()).collect();
640 assert_eq!(action_strings, vec!["read"]);
641 }
642}