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 plan_mode: 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        plan_mode: 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            plan_mode,
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    plan_mode: 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, plan_mode, request_user_input_enabled)
372}
373
374pub fn filter_tool_definitions_for_mode(
375    tools: Option<Arc<Vec<ToolDefinition>>>,
376    plan_mode: 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, plan_mode, request_user_input_enabled))
383    {
384        return Some(tools);
385    }
386
387    let filtered: Vec<ToolDefinition> = tools
388        .iter()
389        .filter(|tool| should_expose_tool_in_mode(tool, plan_mode, request_user_input_enabled))
390        .cloned()
391        .collect();
392    if filtered.is_empty() {
393        None
394    } else {
395        Some(Arc::new(filtered))
396    }
397}
398
399pub fn reduce_tool_result(tool_name: &str, result: Value) -> Value {
400    let canonical_tool_name =
401        tool_intent::canonical_unified_exec_tool_name(tool_name).unwrap_or(tool_name);
402    match canonical_tool_name {
403        crate::config::constants::tools::UNIFIED_SEARCH => reduce_search_result(result),
404        crate::config::constants::tools::READ_FILE => reduce_read_file_result(result),
405        crate::config::constants::tools::UNIFIED_EXEC => reduce_command_result(result),
406        _ => result,
407    }
408}
409
410fn hash_value<T: Hash>(value: &T) -> u64 {
411    let mut hasher = DefaultHasher::new();
412    value.hash(&mut hasher);
413    hasher.finish()
414}
415
416fn hash_json_value<T: Serialize + ?Sized>(value: &T) -> Option<u64> {
417    let mut hasher = DefaultHasher::new();
418    serde_json::to_writer(HasherWriter::new(&mut hasher), value)
419        .ok()
420        .map(|_| {
421            hasher.write_u8(0xff);
422            hasher.finish()
423        })
424}
425
426fn reduce_search_result(result: Value) -> Value {
427    const MAX_GREP_RESULTS: usize = 5;
428    const MAX_LIST_FILES: usize = 50;
429
430    let Some(obj) = result.as_object() else {
431        return result;
432    };
433
434    if let Some(matches) = obj.get("matches").and_then(Value::as_array) {
435        let mut deduped = Vec::with_capacity(matches.len());
436        let mut seen = HashSet::new();
437        for entry in matches {
438            let path = entry
439                .get("path")
440                .or_else(|| entry.get("file"))
441                .and_then(Value::as_str)
442                .map(str::to_owned);
443            let line = entry
444                .get("line")
445                .or_else(|| entry.get("line_number"))
446                .and_then(Value::as_i64);
447            if path.is_none() && line.is_none() {
448                deduped.push(entry.clone());
449                continue;
450            }
451            if seen.insert((path, line)) {
452                deduped.push(entry.clone());
453            }
454        }
455        let total = deduped.len();
456        if total > MAX_GREP_RESULTS {
457            return serde_json::json!({
458                "matches": deduped.into_iter().take(MAX_GREP_RESULTS).collect::<Vec<_>>(),
459                "overflow": format!("[+{} more matches]", total - MAX_GREP_RESULTS),
460                "total": total,
461                "note": "Showing top 5 unique matches (by path/line)"
462            });
463        }
464        if total != matches.len() {
465            return serde_json::json!({
466                "matches": deduped,
467                "total": total,
468                "note": "unique grep matches (collapsed by path/line)"
469            });
470        }
471        return serde_json::json!({
472            "matches": deduped,
473            "total": total,
474            "note": "grep results normalized"
475        });
476    }
477
478    let Some(files) = obj
479        .get("files")
480        .or_else(|| obj.get("items"))
481        .and_then(Value::as_array)
482    else {
483        return result;
484    };
485    if files.len() <= MAX_LIST_FILES {
486        return result;
487    }
488
489    serde_json::json!({
490        "total_files": files.len(),
491        "sample": files.iter().take(5).cloned().collect::<Vec<_>>(),
492        "note": format!("Showing 5 of {} files. Use unified_search for specific patterns.", files.len())
493    })
494}
495
496fn reduce_read_file_result(result: Value) -> Value {
497    const MAX_FILE_LINES: usize = 2000;
498
499    let Some(obj) = result.as_object() else {
500        return result;
501    };
502    let Some(content) = obj.get("content").and_then(Value::as_str) else {
503        return result;
504    };
505
506    let (content, is_truncated) = truncate_lines(content, MAX_FILE_LINES)
507        .map(|(truncated, _)| (truncated, true))
508        .unwrap_or_else(|| (content.to_string(), false));
509
510    let mut reduced = serde_json::Map::new();
511    reduced.insert("success".to_string(), Value::Bool(true));
512    reduced.insert(
513        "status".to_string(),
514        obj.get("status")
515            .cloned()
516            .unwrap_or_else(|| Value::String("success".to_string())),
517    );
518    if let Some(message) = obj.get("message") {
519        reduced.insert("message".to_string(), message.clone());
520    }
521    reduced.insert("content".to_string(), Value::String(content));
522    if let Some(path) = obj.get("path").or_else(|| obj.get("file")) {
523        reduced.insert("path".to_string(), path.clone());
524    }
525    if let Some(metadata) = obj.get("metadata") {
526        reduced.insert("metadata".to_string(), metadata.clone());
527    }
528    if is_truncated {
529        reduced.insert("is_truncated".to_string(), Value::Bool(true));
530    }
531
532    Value::Object(reduced)
533}
534
535fn reduce_command_result(result: Value) -> Value {
536    const MAX_FILE_LINES: usize = 2000;
537
538    let Some(obj) = result.as_object() else {
539        return result;
540    };
541    let stream_key = if obj.get("stdout").and_then(Value::as_str).is_some() {
542        "stdout"
543    } else {
544        "output"
545    };
546    let Some(stream) = obj.get(stream_key).and_then(Value::as_str) else {
547        return result;
548    };
549    let Some((truncated, lines_count)) = truncate_lines(stream, MAX_FILE_LINES) else {
550        return result;
551    };
552
553    let mut reduced = obj.clone();
554    reduced.insert(stream_key.to_string(), Value::String(truncated));
555    reduced.insert("is_truncated".to_string(), Value::Bool(true));
556    reduced.insert(
557        "original_lines".to_string(),
558        Value::Number(serde_json::Number::from(lines_count as u64)),
559    );
560    reduced.insert(
561        "note".to_string(),
562        Value::String("Command output truncated for context economy.".to_string()),
563    );
564    Value::Object(reduced)
565}
566
567fn truncate_lines(text: &str, max_lines: usize) -> Option<(String, usize)> {
568    if max_lines == 0 {
569        return Some((String::new(), text.lines().count()));
570    }
571
572    let mut lines = text.lines();
573    let mut total = 0usize;
574    let mut out = String::new();
575    while let Some(line) = lines.next() {
576        total += 1;
577        if total <= max_lines {
578            if total > 1 {
579                out.push('\n');
580            }
581            out.push_str(line);
582            continue;
583        }
584        total += lines.count();
585        return Some((out, total));
586    }
587    None
588}
589
590pub fn is_parallel_safe_tool_batch(calls: &[PreparedToolCall]) -> bool {
591    calls.iter().all(PreparedToolCall::can_parallelize)
592}
593
594pub fn looks_like_grep_style_command(command: &str) -> bool {
595    let lower = command.trim().to_ascii_lowercase();
596    lower.starts_with("grep ")
597        || lower.starts_with("rg ")
598        || lower.contains("/grep ")
599        || lower.contains("/rg ")
600}
601
602pub fn command_is_safe(command: &str) -> bool {
603    commands::validate_command_safety(command).is_ok()
604}
605
606pub fn low_signal_attempt_key(name: &str, args: &Value) -> String {
607    let mut hash: u64 = 0xcbf29ce484222325;
608    let mut input_len = 0usize;
609    if serde_json::to_writer(HashingWriter::new(&mut hash, &mut input_len), args).is_err() {
610        for byte in b"{}" {
611            hash ^= u64::from(*byte);
612            hash = hash.wrapping_mul(0x100000001b3);
613            input_len = input_len.saturating_add(1);
614        }
615    }
616
617    format!("{name}:len{input_len}-fnv{hash:016x}")
618}
619
620struct HashingWriter<'a> {
621    hash: &'a mut u64,
622    input_len: &'a mut usize,
623}
624
625impl<'a> HashingWriter<'a> {
626    fn new(hash: &'a mut u64, input_len: &'a mut usize) -> Self {
627        Self { hash, input_len }
628    }
629}
630
631impl std::io::Write for HashingWriter<'_> {
632    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
633        for byte in buf {
634            *self.hash ^= u64::from(*byte);
635            *self.hash = self.hash.wrapping_mul(0x100000001b3);
636            *self.input_len = self.input_len.saturating_add(1);
637        }
638        Ok(buf.len())
639    }
640
641    fn flush(&mut self) -> std::io::Result<()> {
642        Ok(())
643    }
644}
645
646struct HasherWriter<'a, H> {
647    hasher: &'a mut H,
648}
649
650impl<'a, H> HasherWriter<'a, H> {
651    fn new(hasher: &'a mut H) -> Self {
652        Self { hasher }
653    }
654}
655
656impl<H: Hasher> std::io::Write for HasherWriter<'_, H> {
657    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
658        self.hasher.write(buf);
659        Ok(buf.len())
660    }
661
662    fn flush(&mut self) -> std::io::Result<()> {
663        Ok(())
664    }
665}
666
667#[cfg(test)]
668mod tests {
669    use super::*;
670    use crate::config::constants::tools;
671
672    fn function_tool(name: &str) -> ToolDefinition {
673        ToolDefinition::function(name.to_string(), name.to_string(), serde_json::json!({}))
674    }
675
676    #[test]
677    fn request_plan_keeps_stable_prefix_hash() {
678        let plan = build_harness_request_plan(HarnessRequestPlanInput {
679            messages: vec![Message::user("hello".to_string())],
680            system_prompt: "base\n[Runtime Context]\n- turns: 1".to_string(),
681            tools: Some(Arc::new(vec![function_tool(tools::UNIFIED_SEARCH)])),
682            model: "gpt-5".to_string(),
683            max_tokens: Some(128),
684            temperature: Some(0.7),
685            stream: true,
686            tool_choice: Some(ToolChoice::auto()),
687            parallel_tool_config: None,
688            reasoning_effort: None,
689            verbosity: None,
690            metadata: None,
691            context_management: None,
692            previous_response_id: None,
693            prompt_cache_key: None,
694            prompt_cache_profile: None,
695            tool_catalog_hash: None,
696        });
697
698        assert!(plan.has_tools);
699        assert!(plan.tool_catalog_hash.is_some());
700        assert_eq!(
701            plan.stable_prefix_hash,
702            stable_system_prefix_hash("base\n[Runtime Context]\n- turns: 1")
703        );
704    }
705
706    #[test]
707    fn request_plan_drops_empty_tool_catalog() {
708        let plan = build_harness_request_plan(HarnessRequestPlanInput {
709            messages: vec![Message::user("hello".to_string())],
710            system_prompt: "base".to_string(),
711            tools: Some(Arc::new(Vec::new())),
712            model: "gpt-5".to_string(),
713            max_tokens: Some(128),
714            temperature: Some(0.7),
715            stream: true,
716            tool_choice: Some(ToolChoice::auto()),
717            parallel_tool_config: None,
718            reasoning_effort: None,
719            verbosity: None,
720            metadata: None,
721            context_management: None,
722            previous_response_id: None,
723            prompt_cache_key: None,
724            prompt_cache_profile: None,
725            tool_catalog_hash: None,
726        });
727
728        assert!(!plan.has_tools);
729        assert!(plan.request.tools.is_none());
730        assert!(plan.tool_catalog_hash.is_none());
731    }
732
733    #[test]
734    fn prepared_tool_batches_group_contiguous_parallel_reads() {
735        let batches = PreparedToolBatch::plan(
736            vec![
737                PreparedToolCall::new("read_a".to_string(), true, true, serde_json::json!({})),
738                PreparedToolCall::new("read_b".to_string(), true, true, serde_json::json!({})),
739                PreparedToolCall::new("edit".to_string(), false, false, serde_json::json!({})),
740            ],
741            true,
742        );
743
744        assert_eq!(batches.len(), 2);
745        assert_eq!(batches[0].kind, PreparedToolBatchKind::ParallelReadonly);
746        assert_eq!(batches[0].calls.len(), 2);
747        assert_eq!(batches[1].kind, PreparedToolBatchKind::Sequential);
748    }
749
750    #[test]
751    fn prepared_tool_batches_preserve_order_around_mutating_calls() {
752        let batches = PreparedToolBatch::plan(
753            vec![
754                PreparedToolCall::new("read_a".to_string(), true, true, serde_json::json!({})),
755                PreparedToolCall::new("edit".to_string(), false, false, serde_json::json!({})),
756                PreparedToolCall::new("read_b".to_string(), true, true, serde_json::json!({})),
757            ],
758            true,
759        );
760
761        assert_eq!(batches.len(), 3);
762        assert!(
763            batches
764                .iter()
765                .all(|batch| batch.kind == PreparedToolBatchKind::Sequential)
766        );
767        assert_eq!(batches[0].calls[0].canonical_name, "read_a");
768        assert_eq!(batches[1].calls[0].canonical_name, "edit");
769        assert_eq!(batches[2].calls[0].canonical_name, "read_b");
770    }
771
772    #[test]
773    fn prepared_tool_batches_split_duplicate_parallel_tool_names() {
774        let batches = PreparedToolBatch::plan(
775            vec![
776                PreparedToolCall::new("read_file".to_string(), true, true, serde_json::json!({})),
777                PreparedToolCall::new("read_file".to_string(), true, true, serde_json::json!({})),
778            ],
779            true,
780        );
781
782        assert_eq!(batches.len(), 2);
783        assert!(
784            batches
785                .iter()
786                .all(|batch| batch.kind == PreparedToolBatchKind::Sequential)
787        );
788    }
789
790    #[test]
791    fn prepared_tool_batches_serializes_all_calls_when_parallel_disabled() {
792        let batches = PreparedToolBatch::plan(
793            vec![
794                PreparedToolCall::new("read_a".to_string(), true, true, serde_json::json!({})),
795                PreparedToolCall::new("read_b".to_string(), true, true, serde_json::json!({})),
796            ],
797            false,
798        );
799
800        assert_eq!(batches.len(), 2);
801        assert!(
802            batches
803                .iter()
804                .all(|batch| batch.kind == PreparedToolBatchKind::Sequential)
805        );
806    }
807
808    #[test]
809    fn filter_tool_definitions_respects_request_user_input_toggle() {
810        let tools = Arc::new(vec![
811            function_tool(tools::UNIFIED_SEARCH),
812            function_tool(tools::REQUEST_USER_INPUT),
813        ]);
814
815        let filtered =
816            filter_tool_definitions_for_mode(Some(tools), true, false).expect("filtered tools");
817        let names: Vec<&str> = filtered.iter().map(|tool| tool.function_name()).collect();
818
819        assert!(names.contains(&tools::UNIFIED_SEARCH));
820        assert!(!names.contains(&tools::REQUEST_USER_INPUT));
821    }
822
823    #[test]
824    fn filter_tool_definitions_hides_mutating_only_tools_in_plan_mode() {
825        let tools = Arc::new(vec![
826            function_tool(tools::UNIFIED_SEARCH),
827            function_tool(tools::UNIFIED_FILE),
828            function_tool(tools::APPLY_PATCH),
829            function_tool(tools::WRITE_FILE),
830        ]);
831
832        let filtered =
833            filter_tool_definitions_for_mode(Some(tools), true, false).expect("filtered tools");
834        let names: Vec<&str> = filtered.iter().map(|tool| tool.function_name()).collect();
835
836        assert!(names.contains(&tools::UNIFIED_SEARCH));
837        assert!(names.contains(&tools::UNIFIED_FILE));
838        assert!(!names.contains(&tools::APPLY_PATCH));
839        assert!(!names.contains(&tools::WRITE_FILE));
840    }
841
842    #[test]
843    fn stable_prefix_hash_ignores_runtime_tool_sections() {
844        let base = "Base prompt\n[Harness Limits]\n- max_tool_calls_per_turn: 5";
845        let with_runtime_sections = format!(
846            "{base}\n\n## Active Tools\n- Mode: read-only.\n[Runtime Tool Catalog]\n- version: 1\n- epoch: 2\n- available_tools: 3\n- request_user_input_enabled: false"
847        );
848
849        assert_eq!(
850            stable_system_prefix_hash(base),
851            stable_system_prefix_hash(&with_runtime_sections)
852        );
853    }
854
855    #[test]
856    fn tool_catalog_hash_matches_legacy_json_string_hash() {
857        let tools = vec![
858            function_tool(tools::UNIFIED_SEARCH),
859            ToolDefinition::function(
860                "custom_tool".to_string(),
861                "Custom".to_string(),
862                serde_json::json!({
863                    "type": "object",
864                    "properties": {
865                        "path": { "type": "string" },
866                        "line": { "type": "integer" }
867                    }
868                }),
869            )
870            .with_strict(true)
871            .with_defer_loading(true),
872        ];
873
874        let expected = serde_json::to_string(&tools)
875            .ok()
876            .map(|text| hash_value(&text));
877
878        assert_eq!(hash_tool_definitions(Some(&tools)), expected);
879    }
880
881    #[test]
882    fn reduce_command_result_truncates_large_output() {
883        let stdout = (0..2200).map(|_| "a").collect::<Vec<_>>().join("\n");
884        let reduced = reduce_tool_result(
885            tools::UNIFIED_EXEC,
886            serde_json::json!({
887                "stdout": stdout
888            }),
889        );
890
891        assert_eq!(reduced.get("is_truncated"), Some(&Value::Bool(true)));
892    }
893}