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