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