Skip to main content

vtcode_core/core/agent/
harness_kernel.rs

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
22/// Whether a tool is on the wire in a cache-stable catalog (union of planning
23/// and execution exposure). Used when `prompt_cache.stable_tool_catalog_across_modes`
24/// is enabled so planning toggles do not rewrite the tool array.
25pub 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    // Cache-stable catalog: union of planning/execution exposure and no action
46    // masking, so planning toggles cannot rewrite the wire tool array.
47    if stable_catalog {
48        // Fast path first, mirroring the arms below: when every tool
49        // survives, reuse the Arc instead of deep-cloning the catalog (each
50        // definition carries its description + JSON schema). No-op filters
51        // must not copy shared state.
52        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        // No action masking needed; only filter whole tools.
72        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    // Planning active: filter whole tools and mask action enums.
91    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
111/// When planning mode is active, mask the `action` enum in a multi-action
112/// tool's JSON schema to only include read-only actions. This prevents the
113/// LLM from seeing write/edit/delete actions that would be rejected at
114/// execution time.
115fn 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        // Asymmetric triple: all-exposed input keeps its Arc (no deep clone
407        // of descriptions + schemas); input with a mode-hidden tool gets a
408        // new Arc without it; fully-hidden input stays None.
409        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        // No action property to mask — just verify the tool is still present.
582        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        // All tools pass the whole-tool filter (none are Mutating-only),
614        // but action masking should still apply.
615        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}