1use std::collections::{BTreeMap, BTreeSet};
8
9use serde::{Deserialize, Serialize};
10
11use crate::llm::tools::text_tool_call_tag_pairs;
12use crate::orchestration::{CapabilityPolicy, ToolApprovalPolicy};
13use crate::tool_annotations::{
14 SideEffectLevel, ToolAnnotations, ToolArgSchema, ToolDependencyRangeParams, ToolKind,
15};
16use crate::value::VmValue;
17
18#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum ToolSurfaceSeverity {
21 Warning,
22 Error,
23}
24
25#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
26pub struct ToolSurfaceDiagnostic {
27 pub code: String,
28 pub severity: ToolSurfaceSeverity,
29 pub message: String,
30 #[serde(skip_serializing_if = "Option::is_none")]
31 pub tool: Option<String>,
32 #[serde(skip_serializing_if = "Option::is_none")]
33 pub field: Option<String>,
34}
35
36impl ToolSurfaceDiagnostic {
37 fn warning(code: &str, message: impl Into<String>) -> Self {
38 Self {
39 code: code.to_string(),
40 severity: ToolSurfaceSeverity::Warning,
41 message: message.into(),
42 tool: None,
43 field: None,
44 }
45 }
46
47 fn error(code: &str, message: impl Into<String>) -> Self {
48 Self {
49 code: code.to_string(),
50 severity: ToolSurfaceSeverity::Error,
51 message: message.into(),
52 tool: None,
53 field: None,
54 }
55 }
56
57 fn with_tool(mut self, tool: impl Into<String>) -> Self {
58 self.tool = Some(tool.into());
59 self
60 }
61
62 fn with_field(mut self, field: impl Into<String>) -> Self {
63 self.field = Some(field.into());
64 self
65 }
66}
67
68#[derive(Clone, Debug, Default, Serialize, Deserialize)]
69pub struct ToolSurfaceReport {
70 pub valid: bool,
71 pub diagnostics: Vec<ToolSurfaceDiagnostic>,
72}
73
74impl ToolSurfaceReport {
75 fn new(diagnostics: Vec<ToolSurfaceDiagnostic>) -> Self {
76 let valid = diagnostics
77 .iter()
78 .all(|d| d.severity != ToolSurfaceSeverity::Error);
79 Self { valid, diagnostics }
80 }
81}
82
83pub fn tool_names_from_spec(value: &serde_json::Value) -> Vec<String> {
84 match value {
85 serde_json::Value::Null => Vec::new(),
86 serde_json::Value::Array(items) => items
87 .iter()
88 .filter_map(|item| match item {
89 serde_json::Value::Object(map) => map
90 .get("name")
91 .and_then(|value| value.as_str())
92 .filter(|name| !name.is_empty())
93 .map(ToOwned::to_owned),
94 _ => None,
95 })
96 .collect(),
97 serde_json::Value::Object(map) => {
98 if map.get("_type").and_then(|value| value.as_str()) == Some("tool_registry") {
99 return map
100 .get("tools")
101 .map(tool_names_from_spec)
102 .unwrap_or_default();
103 }
104 map.get("name")
105 .and_then(|value| value.as_str())
106 .filter(|name| !name.is_empty())
107 .map(|name| vec![name.to_string()])
108 .unwrap_or_default()
109 }
110 _ => Vec::new(),
111 }
112}
113
114fn max_side_effect_level(levels: impl Iterator<Item = String>) -> Option<String> {
115 levels.max_by_key(|level| SideEffectLevel::rank_str(level))
117}
118
119fn parse_tool_kind(value: Option<&serde_json::Value>) -> ToolKind {
120 match value.and_then(|v| v.as_str()).unwrap_or("") {
121 "read" => ToolKind::Read,
122 "edit" => ToolKind::Edit,
123 "delete" => ToolKind::Delete,
124 "move" => ToolKind::Move,
125 "search" => ToolKind::Search,
126 "execute" => ToolKind::Execute,
127 "think" => ToolKind::Think,
128 "fetch" => ToolKind::Fetch,
129 _ => ToolKind::Other,
130 }
131}
132
133fn parse_tool_annotations(map: &serde_json::Map<String, serde_json::Value>) -> ToolAnnotations {
134 let policy = map
135 .get("policy")
136 .and_then(|value| value.as_object())
137 .cloned()
138 .unwrap_or_default();
139
140 let capabilities = policy
141 .get("capabilities")
142 .and_then(|value| value.as_object())
143 .map(|caps| {
144 caps.iter()
145 .map(|(capability, ops)| {
146 let values = ops
147 .as_array()
148 .map(|items| {
149 items
150 .iter()
151 .filter_map(|item| item.as_str().map(ToOwned::to_owned))
152 .collect::<Vec<_>>()
153 })
154 .unwrap_or_default();
155 (capability.clone(), values)
156 })
157 .collect::<BTreeMap<_, _>>()
158 })
159 .unwrap_or_default();
160
161 let arg_schema = if let Some(schema) = policy.get("arg_schema") {
162 serde_json::from_value::<ToolArgSchema>(schema.clone()).unwrap_or_default()
163 } else {
164 ToolArgSchema {
165 path_params: policy
166 .get("path_params")
167 .and_then(|value| value.as_array())
168 .map(|items| {
169 items
170 .iter()
171 .filter_map(|item| item.as_str().map(ToOwned::to_owned))
172 .collect::<Vec<_>>()
173 })
174 .unwrap_or_default(),
175 dependency_key_params: policy
176 .get("dependency_key_params")
177 .and_then(|value| value.as_array())
178 .map(|items| {
179 items
180 .iter()
181 .filter_map(|item| item.as_str().map(ToOwned::to_owned))
182 .collect::<Vec<_>>()
183 })
184 .unwrap_or_default(),
185 dependency_range_params: policy
186 .get("dependency_range_params")
187 .and_then(|value| {
188 serde_json::from_value::<Vec<ToolDependencyRangeParams>>(value.clone()).ok()
189 })
190 .unwrap_or_default(),
191 arg_aliases: policy
192 .get("arg_aliases")
193 .and_then(|value| value.as_object())
194 .map(|aliases| {
195 aliases
196 .iter()
197 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
198 .collect::<BTreeMap<_, _>>()
199 })
200 .unwrap_or_default(),
201 required: policy
202 .get("required")
203 .and_then(|value| value.as_array())
204 .map(|items| {
205 items
206 .iter()
207 .filter_map(|item| item.as_str().map(ToOwned::to_owned))
208 .collect::<Vec<_>>()
209 })
210 .unwrap_or_default(),
211 }
212 };
213
214 let kind = parse_tool_kind(policy.get("kind"));
215 let side_effect_level = policy
216 .get("side_effect_level")
217 .and_then(|value| value.as_str())
218 .map(SideEffectLevel::parse)
219 .unwrap_or_default();
220
221 ToolAnnotations {
222 kind,
223 side_effect_level,
224 arg_schema,
225 capabilities,
226 emits_artifacts: policy
227 .get("emits_artifacts")
228 .and_then(|value| value.as_bool())
229 .unwrap_or(false),
230 result_readers: policy
231 .get("result_readers")
232 .or_else(|| policy.get("readable_result_routes"))
233 .and_then(|value| value.as_array())
234 .map(|items| {
235 items
236 .iter()
237 .filter_map(|item| item.as_str().map(ToOwned::to_owned))
238 .collect::<Vec<_>>()
239 })
240 .unwrap_or_default(),
241 inline_result: policy
242 .get("inline_result")
243 .and_then(|value| value.as_bool())
244 .unwrap_or(false),
245 read_only_hint: map
246 .get("readOnlyHint")
247 .or_else(|| policy.get("readOnlyHint"))
248 .and_then(|value| value.as_bool()),
249 destructive_hint: map
250 .get("destructiveHint")
251 .or_else(|| policy.get("destructiveHint"))
252 .and_then(|value| value.as_bool()),
253 idempotent_hint: map
254 .get("idempotentHint")
255 .or_else(|| policy.get("idempotentHint"))
256 .and_then(|value| value.as_bool()),
257 open_world_hint: map
258 .get("openWorldHint")
259 .or_else(|| policy.get("openWorldHint"))
260 .and_then(|value| value.as_bool()),
261 }
262}
263
264pub fn tool_annotations_from_spec(value: &serde_json::Value) -> BTreeMap<String, ToolAnnotations> {
265 match value {
266 serde_json::Value::Null => std::collections::BTreeMap::new(),
267 serde_json::Value::Array(items) => items
268 .iter()
269 .filter_map(|item| match item {
270 serde_json::Value::Object(map) => map
271 .get("name")
272 .and_then(|value| value.as_str())
273 .filter(|name| !name.is_empty())
274 .map(|name| (name.to_string(), parse_tool_annotations(map))),
275 _ => None,
276 })
277 .collect(),
278 serde_json::Value::Object(map) => {
279 if map.get("_type").and_then(|value| value.as_str()) == Some("tool_registry") {
280 return map
281 .get("tools")
282 .map(tool_annotations_from_spec)
283 .unwrap_or_default();
284 }
285 map.get("name")
286 .and_then(|value| value.as_str())
287 .filter(|name| !name.is_empty())
288 .map(|name| {
289 let mut annotations = std::collections::BTreeMap::new();
290 annotations.insert(name.to_string(), parse_tool_annotations(map));
291 annotations
292 })
293 .unwrap_or_default()
294 }
295 _ => std::collections::BTreeMap::new(),
296 }
297}
298
299pub fn tool_capability_policy_from_spec(value: &serde_json::Value) -> CapabilityPolicy {
300 let tools = tool_names_from_spec(value);
301 let tool_annotations = tool_annotations_from_spec(value);
302 let mut capabilities: BTreeMap<String, Vec<String>> = std::collections::BTreeMap::new();
303 for annotations in tool_annotations.values() {
304 for (capability, ops) in &annotations.capabilities {
305 let entry = capabilities.entry(capability.clone()).or_default();
306 for op in ops {
307 if !entry.contains(op) {
308 entry.push(op.clone());
309 }
310 }
311 entry.sort();
312 }
313 }
314 if !capabilities.is_empty() {
315 let entry = capabilities.entry("llm".to_string()).or_default();
316 let op = "call".to_string();
317 if !entry.contains(&op) {
318 entry.push(op);
319 entry.sort();
320 }
321 }
322 let side_effect_levels: Vec<String> = tool_annotations
323 .values()
324 .map(|annotations| annotations.side_effect_level.as_str().to_string())
325 .filter(|level| level != "none")
326 .collect();
327 let side_effect_level = max_side_effect_level(side_effect_levels.into_iter());
328 CapabilityPolicy {
329 tools,
330 capabilities,
331 side_effect_level,
332 tool_annotations,
333 ..CapabilityPolicy::default()
334 }
335}
336
337#[derive(Clone, Debug, Default)]
338pub struct ToolSurfaceInput {
339 pub tools: Option<VmValue>,
340 pub native_tools: Option<Vec<serde_json::Value>>,
341 pub policy: Option<CapabilityPolicy>,
342 pub approval_policy: Option<ToolApprovalPolicy>,
343 pub prompt_texts: Vec<String>,
344 pub tool_search_active: bool,
345}
346
347#[derive(Clone, Debug, Default)]
348struct ToolEntry {
349 name: String,
350 parameter_keys: BTreeSet<String>,
351 has_schema: bool,
352 annotations: Option<ToolAnnotations>,
353 has_executor: bool,
354 defer_loading: bool,
355 provider_native: bool,
356}
357
358pub fn validate_tool_surface(input: &ToolSurfaceInput) -> ToolSurfaceReport {
359 ToolSurfaceReport::new(validate_tool_surface_diagnostics(input))
360}
361
362pub fn validate_tool_surface_diagnostics(input: &ToolSurfaceInput) -> Vec<ToolSurfaceDiagnostic> {
363 let entries = collect_entries(input);
364 let active_names = effective_active_names(&entries, input.policy.as_ref());
365 let mut diagnostics = Vec::new();
366
367 for entry in entries
368 .iter()
369 .filter(|entry| active_names.contains(entry.name.as_str()))
370 {
371 if !entry.has_schema {
372 diagnostics.push(
373 ToolSurfaceDiagnostic::warning(
374 "TOOL_SURFACE_MISSING_SCHEMA",
375 format!("active tool '{}' has no parameter schema", entry.name),
376 )
377 .with_tool(entry.name.clone())
378 .with_field("parameters"),
379 );
380 }
381 if entry.annotations.is_none() {
382 diagnostics.push(
383 ToolSurfaceDiagnostic::warning(
384 "TOOL_SURFACE_MISSING_ANNOTATIONS",
385 format!("active tool '{}' has no ToolAnnotations", entry.name),
386 )
387 .with_tool(entry.name.clone())
388 .with_field("annotations"),
389 );
390 }
391 if entry
392 .annotations
393 .as_ref()
394 .is_some_and(|annotations| annotations.side_effect_level == SideEffectLevel::None)
395 {
396 diagnostics.push(
397 ToolSurfaceDiagnostic::warning(
398 "TOOL_SURFACE_MISSING_SIDE_EFFECT_LEVEL",
399 format!("active tool '{}' has no side-effect level", entry.name),
400 )
401 .with_tool(entry.name.clone())
402 .with_field("side_effect_level"),
403 );
404 }
405 if !entry.has_executor && !entry.provider_native {
406 diagnostics.push(
407 ToolSurfaceDiagnostic::warning(
408 "TOOL_SURFACE_MISSING_EXECUTOR",
409 format!("active tool '{}' has no declared executor", entry.name),
410 )
411 .with_tool(entry.name.clone())
412 .with_field("executor"),
413 );
414 }
415 validate_execute_result_routes(entry, &entries, &active_names, &mut diagnostics);
416 }
417
418 validate_arg_constraints(
419 input.policy.as_ref(),
420 &entries,
421 &active_names,
422 &mut diagnostics,
423 );
424 validate_approval_patterns(
425 input.approval_policy.as_ref(),
426 &active_names,
427 &mut diagnostics,
428 );
429 validate_prompt_references(input, &entries, &active_names, &mut diagnostics);
430 validate_side_effect_ceiling(
431 input.policy.as_ref(),
432 &entries,
433 &active_names,
434 &mut diagnostics,
435 );
436
437 diagnostics
438}
439
440pub fn validate_workflow_graph(
441 graph: &crate::orchestration::WorkflowGraph,
442) -> Vec<ToolSurfaceDiagnostic> {
443 let mut diagnostics = Vec::new();
444 diagnostics.extend(
445 validate_tool_surface_diagnostics(&ToolSurfaceInput {
446 tools: None,
447 native_tools: Some(workflow_tools_as_native(
448 &graph.capability_policy,
449 &graph.nodes,
450 )),
451 policy: Some(graph.capability_policy.clone()),
452 approval_policy: Some(graph.approval_policy.clone()),
453 prompt_texts: Vec::new(),
454 tool_search_active: false,
455 })
456 .into_iter()
457 .map(|mut diagnostic| {
458 diagnostic.message = format!("workflow: {}", diagnostic.message);
459 diagnostic
460 }),
461 );
462 for (node_id, node) in &graph.nodes {
463 let prompt_texts = [node.system.clone(), node.prompt.clone()]
464 .into_iter()
465 .flatten()
466 .collect::<Vec<_>>();
467 diagnostics.extend(
468 validate_tool_surface_diagnostics(&ToolSurfaceInput {
469 tools: None,
470 native_tools: Some(workflow_node_tools_as_native(node)),
471 policy: Some(node.capability_policy.clone()),
472 approval_policy: Some(node.approval_policy.clone()),
473 prompt_texts,
474 tool_search_active: false,
475 })
476 .into_iter()
477 .map(|mut diagnostic| {
478 diagnostic.message = format!("node {node_id}: {}", diagnostic.message);
479 diagnostic
480 }),
481 );
482 }
483 diagnostics
484}
485
486pub fn surface_report_to_json(report: &ToolSurfaceReport) -> serde_json::Value {
487 serde_json::to_value(report).unwrap_or_else(|_| serde_json::json!({"valid": false}))
488}
489
490pub fn surface_input_from_vm(surface: &VmValue, options: Option<&VmValue>) -> ToolSurfaceInput {
491 let dict = surface.as_dict();
492 let options_dict = options.and_then(VmValue::as_dict);
493 let tools = dict
494 .and_then(|d| d.get("tools").cloned())
495 .or_else(|| options_dict.and_then(|d| d.get("tools").cloned()))
496 .or_else(|| Some(surface.clone()).filter(is_tool_registry_like));
497 let native_tools = dict
498 .and_then(|d| d.get("native_tools"))
499 .or_else(|| options_dict.and_then(|d| d.get("native_tools")))
500 .map(crate::llm::vm_value_to_json)
501 .and_then(|value| value.as_array().cloned());
502 let policy = dict
503 .and_then(|d| d.get("policy"))
504 .or_else(|| options_dict.and_then(|d| d.get("policy")))
505 .map(crate::llm::vm_value_to_json)
506 .and_then(|value| serde_json::from_value(value).ok());
507 let approval_policy = dict
508 .and_then(|d| d.get("approval_policy"))
509 .or_else(|| options_dict.and_then(|d| d.get("approval_policy")))
510 .map(crate::llm::vm_value_to_json)
511 .and_then(|value| serde_json::from_value(value).ok());
512 let mut prompt_texts = Vec::new();
513 for source in [dict, options_dict].into_iter().flatten() {
514 for key in ["system", "prompt"] {
515 if let Some(text) = source.get(key).map(|value| value.display()) {
516 if !text.is_empty() {
517 prompt_texts.push(text);
518 }
519 }
520 }
521 if let Some(VmValue::List(items)) = source.get("prompts") {
522 for item in items.iter() {
523 let text = item.display();
524 if !text.is_empty() {
525 prompt_texts.push(text);
526 }
527 }
528 }
529 }
530 let tool_search_active = dict
531 .and_then(|d| d.get("tool_search"))
532 .or_else(|| options_dict.and_then(|d| d.get("tool_search")))
533 .is_some_and(|value| !matches!(value, VmValue::Bool(false) | VmValue::Nil));
534 ToolSurfaceInput {
535 tools,
536 native_tools,
537 policy,
538 approval_policy,
539 prompt_texts,
540 tool_search_active,
541 }
542}
543
544fn collect_entries(input: &ToolSurfaceInput) -> Vec<ToolEntry> {
545 let mut entries = Vec::new();
546 if let Some(tools) = input.tools.as_ref() {
547 collect_vm_entries(tools, input.policy.as_ref(), &mut entries);
548 }
549 if let Some(native) = input.native_tools.as_ref() {
550 let vm_names: BTreeSet<String> = entries.iter().map(|entry| entry.name.clone()).collect();
551 let mut native_entries = Vec::new();
552 collect_native_entries(native, input.policy.as_ref(), &mut native_entries);
553 entries.extend(
554 native_entries
555 .into_iter()
556 .filter(|entry| !vm_names.contains(&entry.name)),
557 );
558 }
559 entries
560}
561
562fn collect_vm_entries(
563 tools: &VmValue,
564 policy: Option<&CapabilityPolicy>,
565 entries: &mut Vec<ToolEntry>,
566) {
567 let values: Vec<&VmValue> = match tools {
568 VmValue::List(list) => list.iter().collect(),
569 VmValue::Dict(dict) => match dict.get("tools") {
570 Some(VmValue::List(list)) => list.iter().collect(),
571 _ => vec![tools],
572 },
573 _ => Vec::new(),
574 };
575 for value in values {
576 let Some(map) = value.as_dict() else { continue };
577 let name = map
578 .get("name")
579 .map(|value| value.display())
580 .unwrap_or_default();
581 if name.is_empty() {
582 continue;
583 }
584 let (has_schema, parameter_keys) = vm_parameter_keys(map.get("parameters"));
585 let annotations = map
586 .get("annotations")
587 .map(crate::llm::vm_value_to_json)
588 .and_then(|value| serde_json::from_value::<ToolAnnotations>(value).ok())
589 .or_else(|| {
590 policy
591 .and_then(|policy| policy.tool_annotations.get(&name))
592 .cloned()
593 });
594 let executor = map.get("executor").and_then(|value| match value {
595 VmValue::String(s) => Some(s.to_string()),
596 _ => None,
597 });
598 entries.push(ToolEntry {
599 name,
600 parameter_keys,
601 has_schema,
602 annotations,
603 has_executor: executor.is_some()
604 || matches!(map.get("handler"), Some(VmValue::Closure(_)))
605 || matches!(map.get("_mcp_server"), Some(VmValue::String(_))),
606 defer_loading: matches!(map.get("defer_loading"), Some(VmValue::Bool(true))),
607 provider_native: false,
608 });
609 }
610}
611
612fn collect_native_entries(
613 native_tools: &[serde_json::Value],
614 policy: Option<&CapabilityPolicy>,
615 entries: &mut Vec<ToolEntry>,
616) {
617 for tool in native_tools {
618 let name = tool
619 .get("function")
620 .and_then(|function| function.get("name"))
621 .or_else(|| tool.get("name"))
622 .and_then(|value| value.as_str())
623 .unwrap_or("");
624 if name.is_empty() || name == "tool_search" || name.starts_with("tool_search_tool_") {
625 continue;
626 }
627 let schema = tool
628 .get("function")
629 .and_then(|function| function.get("parameters"))
630 .or_else(|| tool.get("input_schema"))
631 .or_else(|| tool.get("parameters"));
632 let (has_schema, parameter_keys) = json_parameter_keys(schema);
633 let annotations = tool
634 .get("annotations")
635 .or_else(|| {
636 tool.get("function")
637 .and_then(|function| function.get("annotations"))
638 })
639 .cloned()
640 .and_then(|value| serde_json::from_value::<ToolAnnotations>(value).ok())
641 .or_else(|| {
642 policy
643 .and_then(|policy| policy.tool_annotations.get(name))
644 .cloned()
645 });
646 entries.push(ToolEntry {
647 name: name.to_string(),
648 parameter_keys,
649 has_schema,
650 annotations,
651 has_executor: true,
652 defer_loading: tool
653 .get("defer_loading")
654 .and_then(|value| value.as_bool())
655 .or_else(|| {
656 tool.get("function")
657 .and_then(|function| function.get("defer_loading"))
658 .and_then(|value| value.as_bool())
659 })
660 .unwrap_or(false),
661 provider_native: true,
662 });
663 }
664}
665
666fn effective_active_names(
667 entries: &[ToolEntry],
668 policy: Option<&CapabilityPolicy>,
669) -> BTreeSet<String> {
670 entries
671 .iter()
672 .filter(|entry| policy.is_none_or(|policy| policy.tool_pattern_allows(&entry.name)))
673 .map(|entry| entry.name.clone())
674 .collect()
675}
676
677fn validate_execute_result_routes(
678 entry: &ToolEntry,
679 entries: &[ToolEntry],
680 active_names: &BTreeSet<String>,
681 diagnostics: &mut Vec<ToolSurfaceDiagnostic>,
682) {
683 let Some(annotations) = entry.annotations.as_ref() else {
684 return;
685 };
686 if annotations.kind != ToolKind::Execute || !annotations.emits_artifacts {
687 return;
688 }
689 if annotations.inline_result {
690 return;
691 }
692 let active_reader_declared = annotations
693 .result_readers
694 .iter()
695 .any(|reader| active_names.contains(reader));
696 let command_output_reader = active_names.contains("read_command_output");
697 let read_tool = entries.iter().any(|candidate| {
698 active_names.contains(candidate.name.as_str())
699 && candidate
700 .annotations
701 .as_ref()
702 .is_some_and(|a| a.kind == ToolKind::Read || a.kind == ToolKind::Search)
703 });
704 if !active_reader_declared && !command_output_reader && !read_tool {
705 diagnostics.push(
706 ToolSurfaceDiagnostic::error(
707 "TOOL_SURFACE_MISSING_RESULT_READER",
708 format!(
709 "execute tool '{}' can emit output artifacts but has no active result reader",
710 entry.name
711 ),
712 )
713 .with_tool(entry.name.clone())
714 .with_field("result_readers"),
715 );
716 }
717 for reader in &annotations.result_readers {
718 if !active_names.contains(reader) {
719 diagnostics.push(
720 ToolSurfaceDiagnostic::warning(
721 "TOOL_SURFACE_UNKNOWN_RESULT_READER",
722 format!(
723 "tool '{}' declares result reader '{}' that is not active",
724 entry.name, reader
725 ),
726 )
727 .with_tool(entry.name.clone())
728 .with_field("result_readers"),
729 );
730 }
731 }
732}
733
734fn validate_arg_constraints(
735 policy: Option<&CapabilityPolicy>,
736 entries: &[ToolEntry],
737 active_names: &BTreeSet<String>,
738 diagnostics: &mut Vec<ToolSurfaceDiagnostic>,
739) {
740 let Some(policy) = policy else { return };
741 for constraint in &policy.tool_arg_constraints {
742 let matched = entries
743 .iter()
744 .filter(|entry| active_names.contains(entry.name.as_str()))
745 .filter(|entry| crate::orchestration::glob_match(&constraint.tool, &entry.name))
746 .collect::<Vec<_>>();
747 if matched.is_empty() && !constraint.tool.contains('*') {
748 diagnostics.push(
749 ToolSurfaceDiagnostic::warning(
750 "TOOL_SURFACE_UNKNOWN_ARG_CONSTRAINT_TOOL",
751 format!(
752 "ToolArgConstraint references tool '{}' which is not active",
753 constraint.tool
754 ),
755 )
756 .with_tool(constraint.tool.clone())
757 .with_field("tool_arg_constraints.tool"),
758 );
759 }
760 if let Some(arg_key) = constraint.arg_key.as_ref() {
761 for entry in matched {
762 let annotation_keys = entry
763 .annotations
764 .as_ref()
765 .map(|a| {
766 a.arg_schema
767 .path_params
768 .iter()
769 .chain(a.arg_schema.required.iter())
770 .chain(a.arg_schema.arg_aliases.keys())
771 .chain(a.arg_schema.arg_aliases.values())
772 .cloned()
773 .collect::<BTreeSet<_>>()
774 })
775 .unwrap_or_default();
776 if !entry.parameter_keys.contains(arg_key) && !annotation_keys.contains(arg_key) {
777 diagnostics.push(
778 ToolSurfaceDiagnostic::warning(
779 "TOOL_SURFACE_UNKNOWN_ARG_CONSTRAINT_KEY",
780 format!(
781 "ToolArgConstraint for '{}' targets unknown argument '{}'",
782 entry.name, arg_key
783 ),
784 )
785 .with_tool(entry.name.clone())
786 .with_field(format!("tool_arg_constraints.{arg_key}")),
787 );
788 }
789 }
790 }
791 }
792}
793
794fn validate_approval_patterns(
795 approval: Option<&ToolApprovalPolicy>,
796 active_names: &BTreeSet<String>,
797 diagnostics: &mut Vec<ToolSurfaceDiagnostic>,
798) {
799 let Some(approval) = approval else { return };
800 for (field, patterns) in [
801 ("approval_policy.auto_approve", &approval.auto_approve),
802 ("approval_policy.auto_deny", &approval.auto_deny),
803 (
804 "approval_policy.require_approval",
805 &approval.require_approval,
806 ),
807 ] {
808 for pattern in patterns {
809 validate_approval_tool_pattern(pattern, field, active_names, diagnostics);
810 }
811 }
812 for (index, rule) in approval.rules.iter().enumerate() {
813 for pattern in &rule.matches.tool {
814 validate_approval_tool_pattern(
815 pattern,
816 &format!("approval_policy.rules[{index}].tool"),
817 active_names,
818 diagnostics,
819 );
820 }
821 }
822}
823
824fn validate_approval_tool_pattern(
825 pattern: &str,
826 field: &str,
827 active_names: &BTreeSet<String>,
828 diagnostics: &mut Vec<ToolSurfaceDiagnostic>,
829) {
830 if pattern.contains('*') {
831 return;
832 }
833 if !active_names
834 .iter()
835 .any(|name| crate::orchestration::glob_match(pattern, name))
836 {
837 diagnostics.push(
838 ToolSurfaceDiagnostic::warning(
839 "TOOL_SURFACE_APPROVAL_PATTERN_NO_MATCH",
840 format!("{field} pattern '{pattern}' matches no active tool"),
841 )
842 .with_field(field),
843 );
844 }
845}
846
847fn validate_prompt_references(
848 input: &ToolSurfaceInput,
849 entries: &[ToolEntry],
850 active_names: &BTreeSet<String>,
851 diagnostics: &mut Vec<ToolSurfaceDiagnostic>,
852) {
853 let deferred = entries
854 .iter()
855 .filter(|entry| entry.defer_loading)
856 .map(|entry| entry.name.clone())
857 .collect::<BTreeSet<_>>();
858 let known_names = entries
859 .iter()
860 .map(|entry| entry.name.clone())
861 .chain(active_names.iter().cloned())
862 .collect::<BTreeSet<_>>();
863 for text in &input.prompt_texts {
864 let binding_text = prompt_binding_text(text);
865 let calls = prompt_tool_calls(&binding_text);
866 for call in &calls {
867 let name = call.name;
868 if !known_names.contains(name) && looks_like_tool_name(name) {
869 diagnostics.push(
870 ToolSurfaceDiagnostic::warning(
871 "TOOL_SURFACE_UNKNOWN_PROMPT_TOOL",
872 format!("prompt references tool '{name}' which is not active"),
873 )
874 .with_tool(name.to_string())
875 .with_field("prompt"),
876 );
877 continue;
878 }
879 if known_names.contains(name) && !active_names.contains(name) {
880 diagnostics.push(
881 ToolSurfaceDiagnostic::warning(
882 "TOOL_SURFACE_PROMPT_TOOL_NOT_IN_POLICY",
883 format!("prompt references tool '{name}' outside the active policy"),
884 )
885 .with_tool(name.to_string())
886 .with_field("prompt"),
887 );
888 }
889 if deferred.contains(name) && !input.tool_search_active {
890 diagnostics.push(
891 ToolSurfaceDiagnostic::warning(
892 "TOOL_SURFACE_DEFERRED_TOOL_PROMPT_REFERENCE",
893 format!(
894 "prompt references deferred tool '{name}' but tool_search is not active"
895 ),
896 )
897 .with_tool(name.to_string())
898 .with_field("prompt"),
899 );
900 }
901 }
902 for entry in entries {
903 let Some(annotations) = entry.annotations.as_ref() else {
904 continue;
905 };
906 for (alias, canonical) in &annotations.arg_schema.arg_aliases {
907 if calls
908 .iter()
909 .any(|call| call.name == entry.name && contains_token(call.text, alias))
910 {
911 diagnostics.push(
912 ToolSurfaceDiagnostic::warning(
913 "TOOL_SURFACE_DEPRECATED_ARG_ALIAS",
914 format!(
915 "prompt mentions alias '{}' for tool '{}'; use canonical argument '{}'",
916 alias, entry.name, canonical
917 ),
918 )
919 .with_tool(entry.name.clone())
920 .with_field(format!("arg_schema.arg_aliases.{alias}")),
921 );
922 }
923 }
924 }
925 }
926}
927
928struct PromptToolCall<'a> {
929 name: &'a str,
930 text: &'a str,
931}
932
933#[expect(
934 clippy::string_slice,
935 reason = "every slice index sits on an ASCII tag/ident/paren byte or at text.len()"
936)]
937fn prompt_tool_calls(text: &str) -> Vec<PromptToolCall<'_>> {
938 let mut calls = Vec::new();
939 let bytes = text.as_bytes();
940 let mut i = 0usize;
941 while i < bytes.len() {
942 if let Some((open_tag, close_tag)) = text_tool_call_tag_pairs()
943 .into_iter()
944 .find(|(open_tag, _)| bytes[i..].starts_with(open_tag.as_bytes()))
945 {
946 let call_start = i;
947 i += open_tag.len();
948 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
949 i += 1;
950 }
951 let name_start = i;
952 while i < bytes.len() && is_ident_byte(bytes[i]) {
953 i += 1;
954 }
955 if i > name_start {
956 let call_end = text[i..]
957 .find(close_tag)
958 .map(|offset| i + offset + close_tag.len())
959 .unwrap_or(i);
960 calls.push(PromptToolCall {
961 name: &text[name_start..i],
962 text: &text[call_start..call_end],
963 });
964 i = call_end;
965 }
966 continue;
967 }
968
969 if !is_ident_start(bytes[i]) {
970 i += 1;
971 continue;
972 }
973
974 let start = i;
975 i += 1;
976 while i < bytes.len() && is_ident_byte(bytes[i]) {
977 i += 1;
978 }
979
980 let name = &text[start..i];
981 let mut j = i;
982 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
983 j += 1;
984 }
985 if j < bytes.len() && bytes[j] == b'(' && !prompt_ref_stopword(name) {
986 let end = prompt_call_end(bytes, j);
987 calls.push(PromptToolCall {
988 name,
989 text: &text[start..end],
990 });
991 i = end;
992 continue;
993 }
994 }
995 calls
996}
997
998fn prompt_call_end(bytes: &[u8], open_index: usize) -> usize {
999 let mut depth = 0usize;
1000 let mut quote = None;
1001 let mut escaped = false;
1002 let mut i = open_index;
1003 while i < bytes.len() {
1004 let byte = bytes[i];
1005 if let Some(quote_byte) = quote {
1006 if escaped {
1007 escaped = false;
1008 } else if byte == b'\\' {
1009 escaped = true;
1010 } else if byte == quote_byte {
1011 quote = None;
1012 }
1013 i += 1;
1014 continue;
1015 }
1016
1017 match byte {
1018 b'\'' | b'"' | b'`' => quote = Some(byte),
1019 b'(' => depth += 1,
1020 b')' => {
1021 depth = depth.saturating_sub(1);
1022 if depth == 0 {
1023 return i + 1;
1024 }
1025 }
1026 _ => {}
1027 }
1028 i += 1;
1029 }
1030 bytes.len()
1031}
1032
1033fn validate_side_effect_ceiling(
1034 policy: Option<&CapabilityPolicy>,
1035 entries: &[ToolEntry],
1036 active_names: &BTreeSet<String>,
1037 diagnostics: &mut Vec<ToolSurfaceDiagnostic>,
1038) {
1039 let Some(policy) = policy else { return };
1040 let Some(ceiling) = policy
1041 .side_effect_level
1042 .as_deref()
1043 .map(SideEffectLevel::parse)
1044 else {
1045 return;
1046 };
1047 for entry in entries
1048 .iter()
1049 .filter(|entry| active_names.contains(entry.name.as_str()))
1050 {
1051 let Some(level) = entry.annotations.as_ref().map(|a| a.side_effect_level) else {
1052 continue;
1053 };
1054 if level.rank() > ceiling.rank() {
1055 diagnostics.push(
1056 ToolSurfaceDiagnostic::error(
1057 "TOOL_SURFACE_SIDE_EFFECT_CEILING_EXCEEDED",
1058 format!(
1059 "tool '{}' requires side-effect level '{}' but policy ceiling is '{}'",
1060 entry.name,
1061 level.as_str(),
1062 ceiling.as_str()
1063 ),
1064 )
1065 .with_tool(entry.name.clone())
1066 .with_field("side_effect_level"),
1067 );
1068 }
1069 }
1070}
1071
1072pub fn prompt_tool_references(text: &str) -> BTreeSet<String> {
1073 let text = prompt_binding_text(text);
1074 prompt_tool_calls(&text)
1075 .into_iter()
1076 .map(|call| call.name.to_string())
1077 .collect()
1078}
1079
1080fn prompt_binding_text(text: &str) -> String {
1081 let mut out = String::new();
1082 let mut in_fence = false;
1083 let mut ignore_block = false;
1084 let mut ignore_next = false;
1085 for line in text.lines() {
1086 let trimmed = line.trim();
1087 if trimmed.starts_with("```") {
1088 in_fence = !in_fence;
1089 continue;
1090 }
1091 if trimmed.contains("harn-tool-surface: ignore-start") {
1092 ignore_block = true;
1093 continue;
1094 }
1095 if trimmed.contains("harn-tool-surface: ignore-end") {
1096 ignore_block = false;
1097 continue;
1098 }
1099 if trimmed.contains("harn-tool-surface: ignore-next-line") {
1100 ignore_next = true;
1101 continue;
1102 }
1103 if in_fence
1104 || ignore_block
1105 || trimmed.contains("harn-tool-surface: ignore-line")
1106 || trimmed.contains("tool-surface-ignore")
1107 {
1108 continue;
1109 }
1110 if ignore_next {
1111 ignore_next = false;
1112 continue;
1113 }
1114 out.push_str(line);
1115 out.push('\n');
1116 }
1117 out
1118}
1119
1120fn prompt_ref_stopword(name: &str) -> bool {
1121 matches!(
1122 name,
1123 "if" | "for"
1124 | "while"
1125 | "switch"
1126 | "return"
1127 | "function"
1128 | "fn"
1129 | "JSON"
1130 | "print"
1131 | "println"
1132 | "contains"
1133 | "len"
1134 | "render"
1135 | "render_prompt"
1136 )
1137}
1138
1139fn looks_like_tool_name(name: &str) -> bool {
1140 name.contains('_') || name.starts_with("tool") || name.starts_with("run")
1141}
1142
1143fn contains_token(text: &str, needle: &str) -> bool {
1144 let bytes = text.as_bytes();
1145 let needle_bytes = needle.as_bytes();
1146 if needle_bytes.is_empty() || bytes.len() < needle_bytes.len() {
1147 return false;
1148 }
1149 for i in 0..=bytes.len() - needle_bytes.len() {
1150 if &bytes[i..i + needle_bytes.len()] != needle_bytes {
1151 continue;
1152 }
1153 let before_ok = i == 0 || !is_ident_byte(bytes[i - 1]);
1154 let after = i + needle_bytes.len();
1155 let after_ok = after == bytes.len() || !is_ident_byte(bytes[after]);
1156 if before_ok && after_ok {
1157 return true;
1158 }
1159 }
1160 false
1161}
1162
1163fn is_ident_start(byte: u8) -> bool {
1164 byte.is_ascii_alphabetic() || byte == b'_'
1165}
1166
1167fn is_ident_byte(byte: u8) -> bool {
1168 byte.is_ascii_alphanumeric() || byte == b'_'
1169}
1170
1171fn is_tool_registry_like(value: &VmValue) -> bool {
1172 value.as_dict().is_some_and(|dict| {
1173 dict.get("_type")
1174 .is_some_and(|value| value.display() == "tool_registry")
1175 || dict.contains_key("tools")
1176 })
1177}
1178
1179fn vm_parameter_keys(value: Option<&VmValue>) -> (bool, BTreeSet<String>) {
1180 let Some(value) = value else {
1181 return (false, BTreeSet::new());
1182 };
1183 let json = crate::llm::vm_value_to_json(value);
1184 json_parameter_keys(Some(&json))
1185}
1186
1187fn json_parameter_keys(value: Option<&serde_json::Value>) -> (bool, BTreeSet<String>) {
1188 let Some(value) = value else {
1189 return (false, BTreeSet::new());
1190 };
1191 let mut keys = BTreeSet::new();
1192 if let Some(properties) = value.get("properties").and_then(|value| value.as_object()) {
1193 keys.extend(properties.keys().cloned());
1194 } else if let Some(map) = value.as_object() {
1195 for key in map.keys() {
1196 if key != "type" && key != "required" && key != "description" {
1197 keys.insert(key.clone());
1198 }
1199 }
1200 }
1201 (true, keys)
1202}
1203
1204fn workflow_node_tools_as_native(
1205 node: &crate::orchestration::WorkflowNode,
1206) -> Vec<serde_json::Value> {
1207 match &node.tools {
1208 serde_json::Value::Array(items) => items.clone(),
1209 serde_json::Value::Object(_) => vec![node.tools.clone()],
1210 _ => Vec::new(),
1211 }
1212}
1213
1214fn workflow_tools_as_native(
1215 policy: &CapabilityPolicy,
1216 nodes: &BTreeMap<String, crate::orchestration::WorkflowNode>,
1217) -> Vec<serde_json::Value> {
1218 let mut tools = Vec::new();
1219 let mut seen = BTreeSet::new();
1220 for node in nodes.values() {
1221 for tool in workflow_node_tools_as_native(node) {
1222 let name = tool
1223 .get("name")
1224 .and_then(|value| value.as_str())
1225 .unwrap_or("")
1226 .to_string();
1227 if !name.is_empty() && seen.insert(name) {
1228 tools.push(tool);
1229 }
1230 }
1231 }
1232 for (name, annotations) in &policy.tool_annotations {
1233 if seen.insert(name.clone()) {
1234 tools.push(serde_json::json!({
1235 "name": name,
1236 "parameters": {"type": "object"},
1237 "annotations": annotations,
1238 "executor": "host_bridge",
1239 }));
1240 }
1241 }
1242 tools
1243}
1244
1245#[cfg(test)]
1246mod tests {
1247 use super::*;
1248 use crate::orchestration::ToolArgConstraint;
1249 use crate::tool_annotations::ToolArgSchema;
1250
1251 fn execute_annotations() -> ToolAnnotations {
1252 ToolAnnotations {
1253 kind: ToolKind::Execute,
1254 side_effect_level: SideEffectLevel::ProcessExec,
1255 emits_artifacts: true,
1256 ..ToolAnnotations::default()
1257 }
1258 }
1259
1260 #[test]
1261 fn tool_policy_preserves_agent_loop_transport_ceiling() {
1262 let mut annotations = ToolAnnotations {
1263 kind: ToolKind::Search,
1264 side_effect_level: SideEffectLevel::ReadOnly,
1265 ..ToolAnnotations::default()
1266 };
1267 annotations
1268 .capabilities
1269 .insert("workspace".into(), vec!["read_text".into()]);
1270 let policy = tool_capability_policy_from_spec(&serde_json::json!({
1271 "_type": "tool_registry",
1272 "tools": [
1273 {
1274 "name": "look",
1275 "parameters": {"type": "object"},
1276 "policy": annotations
1277 }
1278 ]
1279 }));
1280
1281 assert_eq!(policy.tools, vec!["look".to_string()]);
1282 assert_eq!(policy.side_effect_level.as_deref(), Some("read_only"));
1283 assert!(policy
1284 .capabilities
1285 .get("llm")
1286 .is_some_and(|ops| ops.contains(&"call".to_string())));
1287 assert!(policy
1288 .capabilities
1289 .get("workspace")
1290 .is_some_and(|ops| ops.contains(&"read_text".to_string())));
1291 }
1292
1293 #[test]
1294 fn tool_policy_preserves_dependency_key_params() {
1295 let policy = tool_capability_policy_from_spec(&serde_json::json!({
1296 "_type": "tool_registry",
1297 "tools": [
1298 {
1299 "name": "edit",
1300 "parameters": {"type": "object"},
1301 "policy": {
1302 "kind": "edit",
1303 "side_effect_level": "workspace_write",
1304 "arg_schema": {
1305 "path_params": ["path"],
1306 "dependency_key_params": ["anchor"],
1307 "dependency_range_params": [{"start": "range_start", "end": "range_end"}]
1308 }
1309 }
1310 },
1311 {
1312 "name": "edit_direct",
1313 "parameters": {"type": "object"},
1314 "policy": {
1315 "kind": "edit",
1316 "side_effect_level": "workspace_write",
1317 "path_params": ["path"],
1318 "dependency_key_params": ["old_string"],
1319 "dependency_range_params": [{"start": "line"}]
1320 }
1321 }
1322 ]
1323 }));
1324
1325 let annotations = policy.tool_annotations.get("edit").unwrap();
1326 assert_eq!(annotations.arg_schema.path_params, vec!["path".to_string()]);
1327 assert_eq!(
1328 annotations.arg_schema.dependency_key_params,
1329 vec!["anchor".to_string()]
1330 );
1331 assert_eq!(annotations.arg_schema.dependency_range_params.len(), 1);
1332 assert_eq!(
1333 annotations.arg_schema.dependency_range_params[0].start,
1334 "range_start"
1335 );
1336 assert_eq!(
1337 annotations.arg_schema.dependency_range_params[0].end,
1338 "range_end"
1339 );
1340 let direct_annotations = policy.tool_annotations.get("edit_direct").unwrap();
1341 assert_eq!(
1342 direct_annotations.arg_schema.dependency_key_params,
1343 vec!["old_string".to_string()]
1344 );
1345 assert_eq!(
1346 direct_annotations.arg_schema.dependency_range_params.len(),
1347 1
1348 );
1349 assert_eq!(
1350 direct_annotations.arg_schema.dependency_range_params[0].start,
1351 "line"
1352 );
1353 assert_eq!(
1354 direct_annotations.arg_schema.dependency_range_params[0].end,
1355 ""
1356 );
1357 }
1358
1359 #[test]
1360 fn tool_policy_without_capabilities_keeps_capability_ceiling_unspecified() {
1361 let policy = tool_capability_policy_from_spec(&serde_json::json!({
1362 "_type": "tool_registry",
1363 "tools": [
1364 {
1365 "name": "look",
1366 "parameters": {"type": "object"}
1367 }
1368 ]
1369 }));
1370
1371 assert_eq!(policy.tools, vec!["look".to_string()]);
1372 assert!(policy.capabilities.is_empty());
1373 assert!(policy.side_effect_level.is_none());
1374 }
1375
1376 #[test]
1377 fn execute_artifact_tool_requires_reader() {
1378 let mut policy = CapabilityPolicy::default();
1379 policy
1380 .tool_annotations
1381 .insert("run".into(), execute_annotations());
1382 let tools = VmValue::dict(std::collections::BTreeMap::<String, VmValue>::from_iter([
1383 (
1384 "_type".into(),
1385 VmValue::String(arcstr::ArcStr::from("tool_registry")),
1386 ),
1387 (
1388 "tools".into(),
1389 VmValue::List(std::sync::Arc::new(vec![VmValue::Dict(
1390 std::sync::Arc::new(crate::value::DictMap::from_iter([
1391 (
1392 crate::value::intern_key("name"),
1393 VmValue::String(arcstr::ArcStr::from("run")),
1394 ),
1395 (
1396 crate::value::intern_key("parameters"),
1397 VmValue::dict(crate::value::DictMap::new()),
1398 ),
1399 (
1400 crate::value::intern_key("executor"),
1401 VmValue::String(arcstr::ArcStr::from("host_bridge")),
1402 ),
1403 ])),
1404 )])),
1405 ),
1406 ]));
1407 let report = validate_tool_surface(&ToolSurfaceInput {
1408 tools: Some(tools),
1409 policy: Some(policy),
1410 ..ToolSurfaceInput::default()
1411 });
1412 assert!(report.diagnostics.iter().any(|d| {
1413 d.code == "TOOL_SURFACE_MISSING_RESULT_READER"
1414 && d.severity == ToolSurfaceSeverity::Error
1415 }));
1416 assert!(!report.valid);
1417 }
1418
1419 #[test]
1420 fn execute_artifact_tool_accepts_inline_escape_hatch() {
1421 let mut annotations = execute_annotations();
1422 annotations.inline_result = true;
1423 let mut policy = CapabilityPolicy::default();
1424 policy.tool_annotations.insert("run".into(), annotations);
1425 let report = validate_tool_surface(&ToolSurfaceInput {
1426 native_tools: Some(vec![serde_json::json!({
1427 "name": "run",
1428 "parameters": {"type": "object"},
1429 })]),
1430 policy: Some(policy),
1431 ..ToolSurfaceInput::default()
1432 });
1433 assert!(!report
1434 .diagnostics
1435 .iter()
1436 .any(|d| d.code == "TOOL_SURFACE_MISSING_RESULT_READER"));
1437 }
1438
1439 #[test]
1440 fn native_tool_annotations_are_read_from_tool_json() {
1441 let mut annotations = execute_annotations();
1442 annotations.inline_result = true;
1443 let report = validate_tool_surface(&ToolSurfaceInput {
1444 native_tools: Some(vec![serde_json::json!({
1445 "name": "run",
1446 "parameters": {"type": "object"},
1447 "annotations": annotations,
1448 })]),
1449 ..ToolSurfaceInput::default()
1450 });
1451 assert!(!report
1452 .diagnostics
1453 .iter()
1454 .any(|d| d.code == "TOOL_SURFACE_MISSING_ANNOTATIONS"));
1455 assert!(!report
1456 .diagnostics
1457 .iter()
1458 .any(|d| d.code == "TOOL_SURFACE_MISSING_RESULT_READER"));
1459 }
1460
1461 #[test]
1462 fn prompt_reference_outside_policy_is_reported() {
1463 let policy = CapabilityPolicy {
1464 tools: vec!["read_file".into()],
1465 ..CapabilityPolicy::default()
1466 };
1467 let report = validate_tool_surface(&ToolSurfaceInput {
1468 native_tools: Some(vec![
1469 serde_json::json!({"name": "read_file", "parameters": {"type": "object"}}),
1470 serde_json::json!({"name": "run_command", "parameters": {"type": "object"}}),
1471 ]),
1472 policy: Some(policy),
1473 prompt_texts: vec!["Use run_command({command: \"cargo test\"})".into()],
1474 ..ToolSurfaceInput::default()
1475 });
1476 assert!(report
1477 .diagnostics
1478 .iter()
1479 .any(|d| d.code == "TOOL_SURFACE_PROMPT_TOOL_NOT_IN_POLICY"));
1480 }
1481
1482 #[test]
1483 fn approval_rule_tool_references_are_reported() {
1484 let approval_policy: ToolApprovalPolicy = serde_json::from_value(serde_json::json!({
1485 "rules": [
1486 {"ask": {"tool": "missing_tool"}, "reason": "unknown"},
1487 {"allow": {"tool": "read_*"}}
1488 ]
1489 }))
1490 .unwrap();
1491 let report = validate_tool_surface(&ToolSurfaceInput {
1492 native_tools: Some(vec![serde_json::json!({
1493 "name": "read_file",
1494 "parameters": {"type": "object"},
1495 })]),
1496 approval_policy: Some(approval_policy),
1497 ..ToolSurfaceInput::default()
1498 });
1499
1500 assert!(report.diagnostics.iter().any(|d| {
1501 d.code == "TOOL_SURFACE_APPROVAL_PATTERN_NO_MATCH"
1502 && d.field.as_deref() == Some("approval_policy.rules[0].tool")
1503 }));
1504 assert!(!report.diagnostics.iter().any(|d| {
1505 d.code == "TOOL_SURFACE_APPROVAL_PATTERN_NO_MATCH"
1506 && d.field.as_deref() == Some("approval_policy.rules[1].tool")
1507 }));
1508 }
1509
1510 #[test]
1511 fn prompt_suppression_ignores_examples() {
1512 let report = validate_tool_surface(&ToolSurfaceInput {
1513 native_tools: Some(vec![serde_json::json!({
1514 "name": "read_file",
1515 "parameters": {"type": "object"},
1516 })]),
1517 prompt_texts: vec![
1518 "```text\nrun_command({command: \"old\"})\n```\n<!-- harn-tool-surface: ignore-next-line -->\nrun_command({command: \"old\"})".into(),
1519 ],
1520 ..ToolSurfaceInput::default()
1521 });
1522 assert!(!report
1523 .diagnostics
1524 .iter()
1525 .any(|d| d.code == "TOOL_SURFACE_UNKNOWN_PROMPT_TOOL"));
1526 }
1527
1528 #[test]
1529 fn deprecated_alias_warnings_are_scoped_to_matching_tool_calls() {
1530 let mut edit_annotations = ToolAnnotations::default();
1531 edit_annotations
1532 .arg_schema
1533 .arg_aliases
1534 .insert("file".into(), "path".into());
1535 let mut look_annotations = ToolAnnotations::default();
1536 look_annotations
1537 .arg_schema
1538 .arg_aliases
1539 .insert("path".into(), "file".into());
1540
1541 let report = validate_tool_surface(&ToolSurfaceInput {
1542 native_tools: Some(vec![
1543 serde_json::json!({
1544 "name": "edit",
1545 "parameters": {"type": "object"},
1546 "annotations": edit_annotations,
1547 }),
1548 serde_json::json!({
1549 "name": "look",
1550 "parameters": {"type": "object"},
1551 "annotations": look_annotations,
1552 }),
1553 ]),
1554 prompt_texts: vec![
1555 "Use edit({ path: \"src/main.rs\", action: \"replace\" }) before look({ file: \"src/main.rs\" }).".into(),
1556 ],
1557 ..ToolSurfaceInput::default()
1558 });
1559
1560 assert!(!report
1561 .diagnostics
1562 .iter()
1563 .any(|d| d.code == "TOOL_SURFACE_DEPRECATED_ARG_ALIAS"));
1564 }
1565
1566 #[test]
1567 fn deprecated_alias_warnings_still_report_matching_multiline_calls() {
1568 let mut annotations = ToolAnnotations::default();
1569 annotations
1570 .arg_schema
1571 .arg_aliases
1572 .insert("file".into(), "path".into());
1573
1574 let report = validate_tool_surface(&ToolSurfaceInput {
1575 native_tools: Some(vec![serde_json::json!({
1576 "name": "edit",
1577 "parameters": {"type": "object"},
1578 "annotations": annotations,
1579 })]),
1580 prompt_texts: vec!["Use edit({\n file: \"src/main.rs\"\n}) once.".into()],
1581 ..ToolSurfaceInput::default()
1582 });
1583
1584 assert!(report
1585 .diagnostics
1586 .iter()
1587 .any(|d| d.code == "TOOL_SURFACE_DEPRECATED_ARG_ALIAS"));
1588 }
1589
1590 #[test]
1591 fn deprecated_alias_warnings_report_tagged_text_mode_calls() {
1592 let mut annotations = ToolAnnotations::default();
1593 annotations
1594 .arg_schema
1595 .arg_aliases
1596 .insert("file".into(), "path".into());
1597
1598 let report = validate_tool_surface(&ToolSurfaceInput {
1599 native_tools: Some(vec![serde_json::json!({
1600 "name": "edit",
1601 "parameters": {"type": "object"},
1602 "annotations": annotations,
1603 })]),
1604 prompt_texts: vec!["<tool_call>\nedit({ file: \"src/main.rs\" })\n</tool_call>".into()],
1605 ..ToolSurfaceInput::default()
1606 });
1607
1608 assert!(report
1609 .diagnostics
1610 .iter()
1611 .any(|d| d.code == "TOOL_SURFACE_DEPRECATED_ARG_ALIAS"));
1612 }
1613
1614 #[test]
1615 fn prompt_reference_scanner_tolerates_non_ascii_text() {
1616 let references = prompt_tool_references("Résumé: use run_command({command: \"test\"})");
1617 assert!(references.contains("run_command"));
1618 }
1619
1620 #[test]
1621 fn prompt_reference_scanner_reads_tagged_text_mode_calls() {
1622 let references =
1623 prompt_tool_references("<tool_call>\nrun({ command: \"cargo test\" })\n</tool_call>");
1624 assert!(references.contains("run"));
1625 }
1626
1627 #[test]
1628 fn arg_constraint_key_must_exist() {
1629 let mut annotations = ToolAnnotations {
1630 kind: ToolKind::Read,
1631 side_effect_level: SideEffectLevel::ReadOnly,
1632 arg_schema: ToolArgSchema {
1633 path_params: vec!["path".into()],
1634 ..ToolArgSchema::default()
1635 },
1636 ..ToolAnnotations::default()
1637 };
1638 annotations.arg_schema.required.push("path".into());
1639 let mut policy = CapabilityPolicy {
1640 tool_arg_constraints: vec![ToolArgConstraint {
1641 tool: "read_file".into(),
1642 arg_key: Some("missing".into()),
1643 arg_patterns: vec!["src/**".into()],
1644 }],
1645 ..CapabilityPolicy::default()
1646 };
1647 policy
1648 .tool_annotations
1649 .insert("read_file".into(), annotations);
1650 let report = validate_tool_surface(&ToolSurfaceInput {
1651 native_tools: Some(vec![serde_json::json!({
1652 "name": "read_file",
1653 "parameters": {"type": "object", "properties": {"path": {"type": "string"}}},
1654 })]),
1655 policy: Some(policy),
1656 ..ToolSurfaceInput::default()
1657 });
1658 assert!(report
1659 .diagnostics
1660 .iter()
1661 .any(|d| d.code == "TOOL_SURFACE_UNKNOWN_ARG_CONSTRAINT_KEY"));
1662 }
1663}