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        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        // No action masking needed; only filter whole tools.
62        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    // Planning active: filter whole tools and mask action enums.
81    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
101/// When planning mode is active, mask the `action` enum in a multi-action
102/// tool's JSON schema to only include read-only actions. This prevents the
103/// LLM from seeing write/edit/delete actions that would be rejected at
104/// execution time.
105fn 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        // No action property to mask — just verify the tool is still present.
539        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        // All tools pass the whole-tool filter (none are Mutating-only),
571        // but action masking should still apply.
572        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}