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