Skip to main content

vtcode_core/core/agent/
harness_kernel.rs

1use std::collections::{HashSet, hash_map::DefaultHasher};
2use std::hash::{Hash, Hasher};
3use std::sync::Arc;
4use std::time::Duration;
5
6use serde::Serialize;
7use serde_json::Value;
8
9use crate::config::types::{ReasoningEffortLevel, VerbosityLevel};
10use crate::core::agent::features::FeatureSet;
11use crate::llm::provider::{LLMRequest, Message, ParallelToolConfig, ToolChoice, ToolDefinition};
12use crate::tools::tool_intent;
13use crate::tools::validation::commands;
14
15#[derive(Debug, Clone)]
16pub struct SessionToolCatalogSnapshot {
17    pub version: u64,
18    pub epoch: u64,
19    pub planning_active: bool,
20    pub request_user_input_enabled: bool,
21    pub snapshot: Option<Arc<Vec<ToolDefinition>>>,
22    pub cache_hit: bool,
23    pub tool_catalog_hash: Option<u64>,
24}
25
26impl SessionToolCatalogSnapshot {
27    pub fn new(
28        version: u64,
29        epoch: u64,
30        planning_active: bool,
31        request_user_input_enabled: bool,
32        snapshot: Option<Arc<Vec<ToolDefinition>>>,
33        cache_hit: bool,
34    ) -> Self {
35        let tool_catalog_hash = hash_tool_definitions(snapshot.as_deref().map(Vec::as_slice));
36        Self {
37            version,
38            epoch,
39            planning_active,
40            request_user_input_enabled,
41            snapshot,
42            cache_hit,
43            tool_catalog_hash,
44        }
45    }
46
47    pub fn available_tools(&self) -> usize {
48        self.snapshot.as_ref().map_or(0, |defs| defs.len())
49    }
50
51    pub fn has_tools(&self) -> bool {
52        self.snapshot.is_some()
53    }
54
55    pub fn with_cache_hit(mut self, cache_hit: bool) -> Self {
56        self.cache_hit = cache_hit;
57        self
58    }
59}
60
61#[derive(Debug, Clone)]
62pub struct FallbackRecommendation {
63    pub tool_name: String,
64    pub args: Value,
65}
66
67#[derive(Debug, Clone)]
68pub struct PreparedToolCall {
69    pub canonical_name: String,
70    pub readonly_classification: bool,
71    pub parallel_safe_after_preflight: bool,
72    pub effective_args: Value,
73    pub fallback_recommendation: Option<FallbackRecommendation>,
74    pub already_preflighted: bool,
75}
76
77impl PreparedToolCall {
78    pub fn new(
79        canonical_name: String,
80        readonly_classification: bool,
81        parallel_safe_after_preflight: bool,
82        effective_args: Value,
83    ) -> Self {
84        Self {
85            canonical_name,
86            readonly_classification,
87            parallel_safe_after_preflight,
88            effective_args,
89            fallback_recommendation: None,
90            already_preflighted: true,
91        }
92    }
93
94    pub fn with_fallback_recommendation(
95        mut self,
96        fallback_recommendation: Option<FallbackRecommendation>,
97    ) -> Self {
98        self.fallback_recommendation = fallback_recommendation;
99        self
100    }
101
102    pub fn can_parallelize(&self) -> bool {
103        self.readonly_classification && self.parallel_safe_after_preflight
104    }
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum PreparedToolBatchKind {
109    Sequential,
110    ParallelReadonly,
111}
112
113#[derive(Debug, Clone)]
114pub struct PreparedToolBatch {
115    pub kind: PreparedToolBatchKind,
116    pub calls: Vec<PreparedToolCall>,
117}
118
119impl PreparedToolBatch {
120    pub fn plan_layout(
121        parallelizable: impl IntoIterator<Item = bool>,
122        allow_parallel: bool,
123    ) -> Vec<(PreparedToolBatchKind, usize)> {
124        let mut layout = Vec::new();
125        let mut parallel_batch_len = 0usize;
126
127        for can_parallelize in parallelizable {
128            if allow_parallel && can_parallelize {
129                parallel_batch_len += 1;
130                continue;
131            }
132
133            if parallel_batch_len > 0 {
134                layout.push((PreparedToolBatchKind::ParallelReadonly, parallel_batch_len));
135                parallel_batch_len = 0;
136            }
137            layout.push((PreparedToolBatchKind::Sequential, 1));
138        }
139
140        if parallel_batch_len > 0 {
141            layout.push((PreparedToolBatchKind::ParallelReadonly, parallel_batch_len));
142        }
143
144        layout
145    }
146
147    pub fn plan_layout_with_names<'a>(
148        calls: impl IntoIterator<Item = (bool, &'a str)>,
149        allow_parallel: bool,
150    ) -> Vec<(PreparedToolBatchKind, usize)> {
151        if !allow_parallel {
152            return calls
153                .into_iter()
154                .map(|_| (PreparedToolBatchKind::Sequential, 1))
155                .collect();
156        }
157
158        let mut layout = Vec::new();
159        let mut parallel_batch_len = 0usize;
160        let mut parallel_tool_names = HashSet::new();
161
162        for (can_parallelize, tool_name) in calls {
163            if !can_parallelize {
164                push_parallel_batch_layout(&mut layout, &mut parallel_batch_len);
165                parallel_tool_names.clear();
166                layout.push((PreparedToolBatchKind::Sequential, 1));
167                continue;
168            }
169
170            if !parallel_tool_names.insert(tool_name) {
171                push_parallel_batch_layout(&mut layout, &mut parallel_batch_len);
172                parallel_tool_names.clear();
173                parallel_tool_names.insert(tool_name);
174            }
175            parallel_batch_len += 1;
176        }
177
178        push_parallel_batch_layout(&mut layout, &mut parallel_batch_len);
179        layout
180    }
181
182    pub fn plan(
183        calls: impl IntoIterator<Item = PreparedToolCall>,
184        allow_parallel: bool,
185    ) -> Vec<Self> {
186        let calls: Vec<_> = calls.into_iter().collect();
187        let layout = Self::plan_layout_with_names(
188            calls
189                .iter()
190                .map(|call| (call.can_parallelize(), call.canonical_name.as_str())),
191            allow_parallel,
192        );
193        let mut calls = calls.into_iter();
194
195        layout
196            .into_iter()
197            .map(|(kind, len)| Self {
198                kind,
199                calls: calls.by_ref().take(len).collect(),
200            })
201            .collect()
202    }
203}
204
205fn push_parallel_batch_layout(
206    layout: &mut Vec<(PreparedToolBatchKind, usize)>,
207    parallel_batch_len: &mut usize,
208) {
209    match *parallel_batch_len {
210        0 => {}
211        1 => layout.push((PreparedToolBatchKind::Sequential, 1)),
212        len => layout.push((PreparedToolBatchKind::ParallelReadonly, len)),
213    }
214    *parallel_batch_len = 0;
215}
216
217#[derive(Debug, Clone)]
218pub enum RecoveryDirective {
219    Retry { delay: Option<Duration> },
220    ToolFreeSynthesis { reason: String },
221    SurfaceHint { message: String },
222    Abort { reason: String },
223}
224
225#[derive(Debug, Clone)]
226pub struct ExecutionFailure {
227    pub category: vtcode_commons::ErrorCategory,
228    pub retryable: bool,
229    pub message: String,
230    pub retry_after: Option<Duration>,
231    pub directive: RecoveryDirective,
232}
233
234impl ExecutionFailure {
235    pub fn from_tool_error(error: &crate::tools::registry::ToolExecutionError) -> Self {
236        let retry_after = error.retry_after().or_else(|| error.retry_delay());
237        let directive = if error.retryable {
238            RecoveryDirective::Retry { delay: retry_after }
239        } else {
240            RecoveryDirective::SurfaceHint {
241                message: error.user_message(),
242            }
243        };
244        Self {
245            category: error.category,
246            retryable: error.retryable,
247            message: error.user_message(),
248            retry_after,
249            directive,
250        }
251    }
252
253    pub fn from_anyhow(error: &anyhow::Error) -> Self {
254        let category = vtcode_commons::classify_anyhow_error(error);
255        // Delegate to the canonical authority in vtcode-commons so that any new
256        // retryable category added there is automatically honoured here.
257        let retryable = category.is_retryable();
258        let retry_after = None;
259        let directive = if retryable {
260            RecoveryDirective::Retry { delay: retry_after }
261        } else {
262            RecoveryDirective::SurfaceHint {
263                message: error.to_string(),
264            }
265        };
266        Self {
267            category,
268            retryable,
269            message: error.to_string(),
270            retry_after,
271            directive,
272        }
273    }
274}
275
276#[derive(Debug, Clone)]
277pub struct HarnessRequestPlan {
278    pub request: LLMRequest,
279    pub has_tools: bool,
280    pub stable_prefix_hash: u64,
281    pub tool_catalog_hash: Option<u64>,
282}
283
284#[derive(Debug, Clone)]
285pub struct HarnessRequestPlanInput {
286    pub messages: Vec<Message>,
287    pub system_prompt: String,
288    pub tools: Option<Arc<Vec<ToolDefinition>>>,
289    pub model: String,
290    pub max_tokens: Option<u32>,
291    pub temperature: Option<f32>,
292    pub stream: bool,
293    pub tool_choice: Option<ToolChoice>,
294    pub parallel_tool_config: Option<Box<ParallelToolConfig>>,
295    pub reasoning_effort: Option<ReasoningEffortLevel>,
296    pub verbosity: Option<VerbosityLevel>,
297    pub metadata: Option<Value>,
298    pub context_management: Option<Value>,
299    pub previous_response_id: Option<String>,
300    pub prompt_cache_key: Option<String>,
301    pub prompt_cache_profile: Option<crate::llm::provider::PromptCacheProfile>,
302    pub tool_catalog_hash: Option<u64>,
303}
304
305pub fn build_harness_request_plan(input: HarnessRequestPlanInput) -> HarnessRequestPlan {
306    let tools = input.tools.filter(|tools| !tools.is_empty());
307    let stable_prefix_hash = stable_system_prefix_hash(&input.system_prompt);
308    let tool_catalog_hash = input
309        .tool_catalog_hash
310        .or_else(|| hash_tool_definitions(tools.as_deref().map(Vec::as_slice)));
311    let has_tools = tools.is_some();
312    let request = LLMRequest {
313        messages: input.messages,
314        system_prompt: Some(Arc::new(input.system_prompt)),
315        tools,
316        model: input.model,
317        max_tokens: input.max_tokens,
318        temperature: input.temperature,
319        stream: input.stream,
320        tool_choice: input.tool_choice,
321        parallel_tool_config: input.parallel_tool_config,
322        reasoning_effort: input.reasoning_effort,
323        verbosity: input.verbosity,
324        metadata: input.metadata,
325        context_management: input.context_management,
326        previous_response_id: input.previous_response_id,
327        prompt_cache_key: input.prompt_cache_key,
328        prompt_cache_profile: input.prompt_cache_profile,
329        ..Default::default()
330    };
331
332    HarnessRequestPlan {
333        request,
334        has_tools,
335        stable_prefix_hash,
336        tool_catalog_hash,
337    }
338}
339
340pub fn stable_system_prefix_hash(system_prompt: &str) -> u64 {
341    let stable_prefix = system_prompt
342        .split("\n## Active Tools\n")
343        .next()
344        .unwrap_or(system_prompt)
345        .split("\n[Runtime Tool Catalog]\n")
346        .next()
347        .unwrap_or(system_prompt)
348        .split("\n[Runtime Context]\n")
349        .next()
350        .unwrap_or(system_prompt)
351        .split("\n[Context]\n")
352        .next()
353        .unwrap_or(system_prompt)
354        .trim_end();
355    hash_value(&stable_prefix)
356}
357
358pub fn hash_tool_definitions(tools: Option<&[ToolDefinition]>) -> Option<u64> {
359    tools.and_then(hash_json_value)
360}
361
362pub fn should_expose_tool_in_mode(
363    tool: &ToolDefinition,
364    planning_active: bool,
365    request_user_input_enabled: bool,
366) -> bool {
367    let Some(name) = tool.function.as_ref().map(|func| func.name.as_str()) else {
368        return true;
369    };
370
371    FeatureSet::tool_enabled_for_mode(name, planning_active, request_user_input_enabled)
372}
373
374pub fn filter_tool_definitions_for_mode(
375    tools: Option<Arc<Vec<ToolDefinition>>>,
376    planning_active: bool,
377    request_user_input_enabled: bool,
378) -> Option<Arc<Vec<ToolDefinition>>> {
379    let tools = tools?;
380    if tools
381        .iter()
382        .all(|tool| should_expose_tool_in_mode(tool, planning_active, request_user_input_enabled))
383    {
384        return Some(tools);
385    }
386
387    let filtered: Vec<ToolDefinition> = tools
388        .iter()
389        .filter(|tool| {
390            should_expose_tool_in_mode(tool, planning_active, request_user_input_enabled)
391        })
392        .cloned()
393        .collect();
394    if filtered.is_empty() {
395        None
396    } else {
397        Some(Arc::new(filtered))
398    }
399}
400
401pub fn reduce_tool_result(tool_name: &str, result: Value) -> Value {
402    let canonical_tool_name =
403        tool_intent::canonical_unified_exec_tool_name(tool_name).unwrap_or(tool_name);
404    match canonical_tool_name {
405        crate::config::constants::tools::UNIFIED_SEARCH => reduce_search_result(result),
406        crate::config::constants::tools::READ_FILE => reduce_read_file_result(result),
407        crate::config::constants::tools::UNIFIED_EXEC => reduce_command_result(result),
408        _ => result,
409    }
410}
411
412fn hash_value<T: Hash>(value: &T) -> u64 {
413    let mut hasher = DefaultHasher::new();
414    value.hash(&mut hasher);
415    hasher.finish()
416}
417
418fn hash_json_value<T: Serialize + ?Sized>(value: &T) -> Option<u64> {
419    let mut hasher = DefaultHasher::new();
420    serde_json::to_writer(HasherWriter::new(&mut hasher), value)
421        .ok()
422        .map(|_| {
423            hasher.write_u8(0xff);
424            hasher.finish()
425        })
426}
427
428fn reduce_search_result(result: Value) -> Value {
429    const MAX_GREP_RESULTS: usize = 5;
430    const MAX_LIST_FILES: usize = 50;
431
432    let Some(obj) = result.as_object() else {
433        return result;
434    };
435
436    if let Some(matches) = obj.get("matches").and_then(Value::as_array) {
437        let mut deduped = Vec::with_capacity(matches.len());
438        let mut seen = HashSet::new();
439        for entry in matches {
440            let path = entry
441                .get("path")
442                .or_else(|| entry.get("file"))
443                .and_then(Value::as_str)
444                .map(str::to_owned);
445            let line = entry
446                .get("line")
447                .or_else(|| entry.get("line_number"))
448                .and_then(Value::as_i64);
449            if path.is_none() && line.is_none() {
450                deduped.push(entry.clone());
451                continue;
452            }
453            if seen.insert((path, line)) {
454                deduped.push(entry.clone());
455            }
456        }
457        let total = deduped.len();
458        if total > MAX_GREP_RESULTS {
459            return serde_json::json!({
460                "matches": deduped.into_iter().take(MAX_GREP_RESULTS).collect::<Vec<_>>(),
461                "overflow": format!("[+{} more matches]", total - MAX_GREP_RESULTS),
462                "total": total,
463                "note": "Showing top 5 unique matches (by path/line)"
464            });
465        }
466        if total != matches.len() {
467            return serde_json::json!({
468                "matches": deduped,
469                "total": total,
470                "note": "unique grep matches (collapsed by path/line)"
471            });
472        }
473        return serde_json::json!({
474            "matches": deduped,
475            "total": total,
476            "note": "grep results normalized"
477        });
478    }
479
480    let Some(files) = obj
481        .get("files")
482        .or_else(|| obj.get("items"))
483        .and_then(Value::as_array)
484    else {
485        return result;
486    };
487    if files.len() <= MAX_LIST_FILES {
488        return result;
489    }
490
491    serde_json::json!({
492        "total_files": files.len(),
493        "sample": files.iter().take(5).cloned().collect::<Vec<_>>(),
494        "note": format!("Showing 5 of {} files. Use unified_search for specific patterns.", files.len())
495    })
496}
497
498fn reduce_read_file_result(result: Value) -> Value {
499    const MAX_FILE_LINES: usize = 2000;
500
501    let Some(obj) = result.as_object() else {
502        return result;
503    };
504    let Some(content) = obj.get("content").and_then(Value::as_str) else {
505        return result;
506    };
507
508    let (content, is_truncated) = truncate_lines(content, MAX_FILE_LINES)
509        .map(|(truncated, _)| (truncated, true))
510        .unwrap_or_else(|| (content.to_string(), false));
511
512    let mut reduced = serde_json::Map::new();
513    reduced.insert("success".to_string(), Value::Bool(true));
514    reduced.insert(
515        "status".to_string(),
516        obj.get("status")
517            .cloned()
518            .unwrap_or_else(|| Value::String("success".to_string())),
519    );
520    if let Some(message) = obj.get("message") {
521        reduced.insert("message".to_string(), message.clone());
522    }
523    reduced.insert("content".to_string(), Value::String(content));
524    if let Some(path) = obj.get("path").or_else(|| obj.get("file")) {
525        reduced.insert("path".to_string(), path.clone());
526    }
527    if let Some(metadata) = obj.get("metadata") {
528        reduced.insert("metadata".to_string(), metadata.clone());
529    }
530    if is_truncated {
531        reduced.insert("is_truncated".to_string(), Value::Bool(true));
532    }
533
534    Value::Object(reduced)
535}
536
537fn reduce_command_result(result: Value) -> Value {
538    const MAX_FILE_LINES: usize = 2000;
539
540    let Some(obj) = result.as_object() else {
541        return result;
542    };
543    let stream_key = if obj.get("stdout").and_then(Value::as_str).is_some() {
544        "stdout"
545    } else {
546        "output"
547    };
548    let Some(stream) = obj.get(stream_key).and_then(Value::as_str) else {
549        return result;
550    };
551    let Some((truncated, lines_count)) = truncate_lines(stream, MAX_FILE_LINES) else {
552        return result;
553    };
554
555    let mut reduced = obj.clone();
556    reduced.insert(stream_key.to_string(), Value::String(truncated));
557    reduced.insert("is_truncated".to_string(), Value::Bool(true));
558    reduced.insert(
559        "original_lines".to_string(),
560        Value::Number(serde_json::Number::from(lines_count as u64)),
561    );
562    reduced.insert(
563        "note".to_string(),
564        Value::String("Command output truncated for context economy.".to_string()),
565    );
566    Value::Object(reduced)
567}
568
569fn truncate_lines(text: &str, max_lines: usize) -> Option<(String, usize)> {
570    if max_lines == 0 {
571        return Some((String::new(), text.lines().count()));
572    }
573
574    let mut lines = text.lines();
575    let mut total = 0usize;
576    let mut out = String::new();
577    while let Some(line) = lines.next() {
578        total += 1;
579        if total <= max_lines {
580            if total > 1 {
581                out.push('\n');
582            }
583            out.push_str(line);
584            continue;
585        }
586        total += lines.count();
587        return Some((out, total));
588    }
589    None
590}
591
592pub fn is_parallel_safe_tool_batch(calls: &[PreparedToolCall]) -> bool {
593    calls.iter().all(PreparedToolCall::can_parallelize)
594}
595
596pub fn looks_like_grep_style_command(command: &str) -> bool {
597    let lower = command.trim().to_ascii_lowercase();
598    lower.starts_with("grep ")
599        || lower.starts_with("rg ")
600        || lower.contains("/grep ")
601        || lower.contains("/rg ")
602}
603
604pub fn command_is_safe(command: &str) -> bool {
605    commands::validate_command_safety(command).is_ok()
606}
607
608pub fn low_signal_attempt_key(name: &str, args: &Value) -> String {
609    let mut hash: u64 = 0xcbf29ce484222325;
610    let mut input_len = 0usize;
611    if serde_json::to_writer(HashingWriter::new(&mut hash, &mut input_len), args).is_err() {
612        for byte in b"{}" {
613            hash ^= u64::from(*byte);
614            hash = hash.wrapping_mul(0x100000001b3);
615            input_len = input_len.saturating_add(1);
616        }
617    }
618
619    format!("{name}:len{input_len}-fnv{hash:016x}")
620}
621
622struct HashingWriter<'a> {
623    hash: &'a mut u64,
624    input_len: &'a mut usize,
625}
626
627impl<'a> HashingWriter<'a> {
628    fn new(hash: &'a mut u64, input_len: &'a mut usize) -> Self {
629        Self { hash, input_len }
630    }
631}
632
633impl std::io::Write for HashingWriter<'_> {
634    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
635        for byte in buf {
636            *self.hash ^= u64::from(*byte);
637            *self.hash = self.hash.wrapping_mul(0x100000001b3);
638            *self.input_len = self.input_len.saturating_add(1);
639        }
640        Ok(buf.len())
641    }
642
643    fn flush(&mut self) -> std::io::Result<()> {
644        Ok(())
645    }
646}
647
648struct HasherWriter<'a, H> {
649    hasher: &'a mut H,
650}
651
652impl<'a, H> HasherWriter<'a, H> {
653    fn new(hasher: &'a mut H) -> Self {
654        Self { hasher }
655    }
656}
657
658impl<H: Hasher> std::io::Write for HasherWriter<'_, H> {
659    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
660        self.hasher.write(buf);
661        Ok(buf.len())
662    }
663
664    fn flush(&mut self) -> std::io::Result<()> {
665        Ok(())
666    }
667}
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672    use crate::config::constants::tools;
673
674    fn function_tool(name: &str) -> ToolDefinition {
675        ToolDefinition::function(name.to_string(), name.to_string(), serde_json::json!({}))
676    }
677
678    #[test]
679    fn request_plan_keeps_stable_prefix_hash() {
680        let plan = build_harness_request_plan(HarnessRequestPlanInput {
681            messages: vec![Message::user("hello".to_string())],
682            system_prompt: "base\n[Runtime Context]\n- turns: 1".to_string(),
683            tools: Some(Arc::new(vec![function_tool(tools::UNIFIED_SEARCH)])),
684            model: "gpt-5".to_string(),
685            max_tokens: Some(128),
686            temperature: Some(0.7),
687            stream: true,
688            tool_choice: Some(ToolChoice::auto()),
689            parallel_tool_config: None,
690            reasoning_effort: None,
691            verbosity: None,
692            metadata: None,
693            context_management: None,
694            previous_response_id: None,
695            prompt_cache_key: None,
696            prompt_cache_profile: None,
697            tool_catalog_hash: None,
698        });
699
700        assert!(plan.has_tools);
701        assert!(plan.tool_catalog_hash.is_some());
702        assert_eq!(
703            plan.stable_prefix_hash,
704            stable_system_prefix_hash("base\n[Runtime Context]\n- turns: 1")
705        );
706    }
707
708    #[test]
709    fn request_plan_drops_empty_tool_catalog() {
710        let plan = build_harness_request_plan(HarnessRequestPlanInput {
711            messages: vec![Message::user("hello".to_string())],
712            system_prompt: "base".to_string(),
713            tools: Some(Arc::new(Vec::new())),
714            model: "gpt-5".to_string(),
715            max_tokens: Some(128),
716            temperature: Some(0.7),
717            stream: true,
718            tool_choice: Some(ToolChoice::auto()),
719            parallel_tool_config: None,
720            reasoning_effort: None,
721            verbosity: None,
722            metadata: None,
723            context_management: None,
724            previous_response_id: None,
725            prompt_cache_key: None,
726            prompt_cache_profile: None,
727            tool_catalog_hash: None,
728        });
729
730        assert!(!plan.has_tools);
731        assert!(plan.request.tools.is_none());
732        assert!(plan.tool_catalog_hash.is_none());
733    }
734
735    #[test]
736    fn prepared_tool_batches_group_contiguous_parallel_reads() {
737        let batches = PreparedToolBatch::plan(
738            vec![
739                PreparedToolCall::new("read_a".to_string(), true, true, serde_json::json!({})),
740                PreparedToolCall::new("read_b".to_string(), true, true, serde_json::json!({})),
741                PreparedToolCall::new("edit".to_string(), false, false, serde_json::json!({})),
742            ],
743            true,
744        );
745
746        assert_eq!(batches.len(), 2);
747        assert_eq!(batches[0].kind, PreparedToolBatchKind::ParallelReadonly);
748        assert_eq!(batches[0].calls.len(), 2);
749        assert_eq!(batches[1].kind, PreparedToolBatchKind::Sequential);
750    }
751
752    #[test]
753    fn prepared_tool_batches_preserve_order_around_mutating_calls() {
754        let batches = PreparedToolBatch::plan(
755            vec![
756                PreparedToolCall::new("read_a".to_string(), true, true, serde_json::json!({})),
757                PreparedToolCall::new("edit".to_string(), false, false, serde_json::json!({})),
758                PreparedToolCall::new("read_b".to_string(), true, true, serde_json::json!({})),
759            ],
760            true,
761        );
762
763        assert_eq!(batches.len(), 3);
764        assert!(
765            batches
766                .iter()
767                .all(|batch| batch.kind == PreparedToolBatchKind::Sequential)
768        );
769        assert_eq!(batches[0].calls[0].canonical_name, "read_a");
770        assert_eq!(batches[1].calls[0].canonical_name, "edit");
771        assert_eq!(batches[2].calls[0].canonical_name, "read_b");
772    }
773
774    #[test]
775    fn prepared_tool_batches_split_duplicate_parallel_tool_names() {
776        let batches = PreparedToolBatch::plan(
777            vec![
778                PreparedToolCall::new("read_file".to_string(), true, true, serde_json::json!({})),
779                PreparedToolCall::new("read_file".to_string(), true, true, serde_json::json!({})),
780            ],
781            true,
782        );
783
784        assert_eq!(batches.len(), 2);
785        assert!(
786            batches
787                .iter()
788                .all(|batch| batch.kind == PreparedToolBatchKind::Sequential)
789        );
790    }
791
792    #[test]
793    fn prepared_tool_batches_serializes_all_calls_when_parallel_disabled() {
794        let batches = PreparedToolBatch::plan(
795            vec![
796                PreparedToolCall::new("read_a".to_string(), true, true, serde_json::json!({})),
797                PreparedToolCall::new("read_b".to_string(), true, true, serde_json::json!({})),
798            ],
799            false,
800        );
801
802        assert_eq!(batches.len(), 2);
803        assert!(
804            batches
805                .iter()
806                .all(|batch| batch.kind == PreparedToolBatchKind::Sequential)
807        );
808    }
809
810    #[test]
811    fn filter_tool_definitions_respects_request_user_input_toggle() {
812        let tools = Arc::new(vec![
813            function_tool(tools::UNIFIED_SEARCH),
814            function_tool(tools::REQUEST_USER_INPUT),
815        ]);
816
817        let filtered =
818            filter_tool_definitions_for_mode(Some(tools), true, false).expect("filtered tools");
819        let names: Vec<&str> = filtered.iter().map(|tool| tool.function_name()).collect();
820
821        assert!(names.contains(&tools::UNIFIED_SEARCH));
822        assert!(!names.contains(&tools::REQUEST_USER_INPUT));
823    }
824
825    #[test]
826    fn filter_tool_definitions_hides_mutating_only_tools_in_planning_workflow() {
827        let tools = Arc::new(vec![
828            function_tool(tools::UNIFIED_SEARCH),
829            function_tool(tools::UNIFIED_FILE),
830            function_tool(tools::APPLY_PATCH),
831            function_tool(tools::WRITE_FILE),
832        ]);
833
834        let filtered =
835            filter_tool_definitions_for_mode(Some(tools), true, false).expect("filtered tools");
836        let names: Vec<&str> = filtered.iter().map(|tool| tool.function_name()).collect();
837
838        assert!(names.contains(&tools::UNIFIED_SEARCH));
839        assert!(names.contains(&tools::UNIFIED_FILE));
840        assert!(!names.contains(&tools::APPLY_PATCH));
841        assert!(!names.contains(&tools::WRITE_FILE));
842    }
843
844    #[test]
845    fn stable_prefix_hash_ignores_runtime_tool_sections() {
846        let base = "Base prompt\n[Harness Limits]\n- max_tool_calls_per_turn: 5";
847        let with_runtime_sections = format!(
848            "{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"
849        );
850
851        assert_eq!(
852            stable_system_prefix_hash(base),
853            stable_system_prefix_hash(&with_runtime_sections)
854        );
855    }
856
857    #[test]
858    fn tool_catalog_hash_matches_legacy_json_string_hash() {
859        let tools = vec![
860            function_tool(tools::UNIFIED_SEARCH),
861            ToolDefinition::function(
862                "custom_tool".to_string(),
863                "Custom".to_string(),
864                serde_json::json!({
865                    "type": "object",
866                    "properties": {
867                        "path": { "type": "string" },
868                        "line": { "type": "integer" }
869                    }
870                }),
871            )
872            .with_strict(true)
873            .with_defer_loading(true),
874        ];
875
876        let expected = serde_json::to_string(&tools)
877            .ok()
878            .map(|text| hash_value(&text));
879
880        assert_eq!(hash_tool_definitions(Some(&tools)), expected);
881    }
882
883    #[test]
884    fn reduce_command_result_truncates_large_output() {
885        let stdout = (0..2200).map(|_| "a").collect::<Vec<_>>().join("\n");
886        let reduced = reduce_tool_result(
887            tools::UNIFIED_EXEC,
888            serde_json::json!({
889                "stdout": stdout
890            }),
891        );
892
893        assert_eq!(reduced.get("is_truncated"), Some(&Value::Bool(true)));
894    }
895}