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 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}