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