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
933fn prompt_tool_calls(text: &str) -> Vec<PromptToolCall<'_>> {
934 let mut calls = Vec::new();
935 let bytes = text.as_bytes();
936 let mut i = 0usize;
937 while i < bytes.len() {
938 if let Some((open_tag, close_tag)) = text_tool_call_tag_pairs()
939 .into_iter()
940 .find(|(open_tag, _)| bytes[i..].starts_with(open_tag.as_bytes()))
941 {
942 let call_start = i;
943 i += open_tag.len();
944 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
945 i += 1;
946 }
947 let name_start = i;
948 while i < bytes.len() && is_ident_byte(bytes[i]) {
949 i += 1;
950 }
951 if i > name_start {
952 let call_end = text[i..]
953 .find(close_tag)
954 .map(|offset| i + offset + close_tag.len())
955 .unwrap_or(i);
956 calls.push(PromptToolCall {
957 name: &text[name_start..i],
958 text: &text[call_start..call_end],
959 });
960 i = call_end;
961 }
962 continue;
963 }
964
965 if !is_ident_start(bytes[i]) {
966 i += 1;
967 continue;
968 }
969
970 let start = i;
971 i += 1;
972 while i < bytes.len() && is_ident_byte(bytes[i]) {
973 i += 1;
974 }
975
976 let name = &text[start..i];
977 let mut j = i;
978 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
979 j += 1;
980 }
981 if j < bytes.len() && bytes[j] == b'(' && !prompt_ref_stopword(name) {
982 let end = prompt_call_end(bytes, j);
983 calls.push(PromptToolCall {
984 name,
985 text: &text[start..end],
986 });
987 i = end;
988 continue;
989 }
990 }
991 calls
992}
993
994fn prompt_call_end(bytes: &[u8], open_index: usize) -> usize {
995 let mut depth = 0usize;
996 let mut quote = None;
997 let mut escaped = false;
998 let mut i = open_index;
999 while i < bytes.len() {
1000 let byte = bytes[i];
1001 if let Some(quote_byte) = quote {
1002 if escaped {
1003 escaped = false;
1004 } else if byte == b'\\' {
1005 escaped = true;
1006 } else if byte == quote_byte {
1007 quote = None;
1008 }
1009 i += 1;
1010 continue;
1011 }
1012
1013 match byte {
1014 b'\'' | b'"' | b'`' => quote = Some(byte),
1015 b'(' => depth += 1,
1016 b')' => {
1017 depth = depth.saturating_sub(1);
1018 if depth == 0 {
1019 return i + 1;
1020 }
1021 }
1022 _ => {}
1023 }
1024 i += 1;
1025 }
1026 bytes.len()
1027}
1028
1029fn validate_side_effect_ceiling(
1030 policy: Option<&CapabilityPolicy>,
1031 entries: &[ToolEntry],
1032 active_names: &BTreeSet<String>,
1033 diagnostics: &mut Vec<ToolSurfaceDiagnostic>,
1034) {
1035 let Some(policy) = policy else { return };
1036 let Some(ceiling) = policy
1037 .side_effect_level
1038 .as_deref()
1039 .map(SideEffectLevel::parse)
1040 else {
1041 return;
1042 };
1043 for entry in entries
1044 .iter()
1045 .filter(|entry| active_names.contains(entry.name.as_str()))
1046 {
1047 let Some(level) = entry.annotations.as_ref().map(|a| a.side_effect_level) else {
1048 continue;
1049 };
1050 if level.rank() > ceiling.rank() {
1051 diagnostics.push(
1052 ToolSurfaceDiagnostic::error(
1053 "TOOL_SURFACE_SIDE_EFFECT_CEILING_EXCEEDED",
1054 format!(
1055 "tool '{}' requires side-effect level '{}' but policy ceiling is '{}'",
1056 entry.name,
1057 level.as_str(),
1058 ceiling.as_str()
1059 ),
1060 )
1061 .with_tool(entry.name.clone())
1062 .with_field("side_effect_level"),
1063 );
1064 }
1065 }
1066}
1067
1068pub fn prompt_tool_references(text: &str) -> BTreeSet<String> {
1069 let text = prompt_binding_text(text);
1070 prompt_tool_calls(&text)
1071 .into_iter()
1072 .map(|call| call.name.to_string())
1073 .collect()
1074}
1075
1076fn prompt_binding_text(text: &str) -> String {
1077 let mut out = String::new();
1078 let mut in_fence = false;
1079 let mut ignore_block = false;
1080 let mut ignore_next = false;
1081 for line in text.lines() {
1082 let trimmed = line.trim();
1083 if trimmed.starts_with("```") {
1084 in_fence = !in_fence;
1085 continue;
1086 }
1087 if trimmed.contains("harn-tool-surface: ignore-start") {
1088 ignore_block = true;
1089 continue;
1090 }
1091 if trimmed.contains("harn-tool-surface: ignore-end") {
1092 ignore_block = false;
1093 continue;
1094 }
1095 if trimmed.contains("harn-tool-surface: ignore-next-line") {
1096 ignore_next = true;
1097 continue;
1098 }
1099 if in_fence
1100 || ignore_block
1101 || trimmed.contains("harn-tool-surface: ignore-line")
1102 || trimmed.contains("tool-surface-ignore")
1103 {
1104 continue;
1105 }
1106 if ignore_next {
1107 ignore_next = false;
1108 continue;
1109 }
1110 out.push_str(line);
1111 out.push('\n');
1112 }
1113 out
1114}
1115
1116fn prompt_ref_stopword(name: &str) -> bool {
1117 matches!(
1118 name,
1119 "if" | "for"
1120 | "while"
1121 | "switch"
1122 | "return"
1123 | "function"
1124 | "fn"
1125 | "JSON"
1126 | "print"
1127 | "println"
1128 | "contains"
1129 | "len"
1130 | "render"
1131 | "render_prompt"
1132 )
1133}
1134
1135fn looks_like_tool_name(name: &str) -> bool {
1136 name.contains('_') || name.starts_with("tool") || name.starts_with("run")
1137}
1138
1139fn contains_token(text: &str, needle: &str) -> bool {
1140 let bytes = text.as_bytes();
1141 let needle_bytes = needle.as_bytes();
1142 if needle_bytes.is_empty() || bytes.len() < needle_bytes.len() {
1143 return false;
1144 }
1145 for i in 0..=bytes.len() - needle_bytes.len() {
1146 if &bytes[i..i + needle_bytes.len()] != needle_bytes {
1147 continue;
1148 }
1149 let before_ok = i == 0 || !is_ident_byte(bytes[i - 1]);
1150 let after = i + needle_bytes.len();
1151 let after_ok = after == bytes.len() || !is_ident_byte(bytes[after]);
1152 if before_ok && after_ok {
1153 return true;
1154 }
1155 }
1156 false
1157}
1158
1159fn is_ident_start(byte: u8) -> bool {
1160 byte.is_ascii_alphabetic() || byte == b'_'
1161}
1162
1163fn is_ident_byte(byte: u8) -> bool {
1164 byte.is_ascii_alphanumeric() || byte == b'_'
1165}
1166
1167fn is_tool_registry_like(value: &VmValue) -> bool {
1168 value.as_dict().is_some_and(|dict| {
1169 dict.get("_type")
1170 .is_some_and(|value| value.display() == "tool_registry")
1171 || dict.contains_key("tools")
1172 })
1173}
1174
1175fn vm_parameter_keys(value: Option<&VmValue>) -> (bool, BTreeSet<String>) {
1176 let Some(value) = value else {
1177 return (false, BTreeSet::new());
1178 };
1179 let json = crate::llm::vm_value_to_json(value);
1180 json_parameter_keys(Some(&json))
1181}
1182
1183fn json_parameter_keys(value: Option<&serde_json::Value>) -> (bool, BTreeSet<String>) {
1184 let Some(value) = value else {
1185 return (false, BTreeSet::new());
1186 };
1187 let mut keys = BTreeSet::new();
1188 if let Some(properties) = value.get("properties").and_then(|value| value.as_object()) {
1189 keys.extend(properties.keys().cloned());
1190 } else if let Some(map) = value.as_object() {
1191 for key in map.keys() {
1192 if key != "type" && key != "required" && key != "description" {
1193 keys.insert(key.clone());
1194 }
1195 }
1196 }
1197 (true, keys)
1198}
1199
1200fn workflow_node_tools_as_native(
1201 node: &crate::orchestration::WorkflowNode,
1202) -> Vec<serde_json::Value> {
1203 match &node.tools {
1204 serde_json::Value::Array(items) => items.clone(),
1205 serde_json::Value::Object(_) => vec![node.tools.clone()],
1206 _ => Vec::new(),
1207 }
1208}
1209
1210fn workflow_tools_as_native(
1211 policy: &CapabilityPolicy,
1212 nodes: &BTreeMap<String, crate::orchestration::WorkflowNode>,
1213) -> Vec<serde_json::Value> {
1214 let mut tools = Vec::new();
1215 let mut seen = BTreeSet::new();
1216 for node in nodes.values() {
1217 for tool in workflow_node_tools_as_native(node) {
1218 let name = tool
1219 .get("name")
1220 .and_then(|value| value.as_str())
1221 .unwrap_or("")
1222 .to_string();
1223 if !name.is_empty() && seen.insert(name) {
1224 tools.push(tool);
1225 }
1226 }
1227 }
1228 for (name, annotations) in &policy.tool_annotations {
1229 if seen.insert(name.clone()) {
1230 tools.push(serde_json::json!({
1231 "name": name,
1232 "parameters": {"type": "object"},
1233 "annotations": annotations,
1234 "executor": "host_bridge",
1235 }));
1236 }
1237 }
1238 tools
1239}
1240
1241#[cfg(test)]
1242mod tests {
1243 use super::*;
1244 use crate::orchestration::ToolArgConstraint;
1245 use crate::tool_annotations::ToolArgSchema;
1246
1247 fn execute_annotations() -> ToolAnnotations {
1248 ToolAnnotations {
1249 kind: ToolKind::Execute,
1250 side_effect_level: SideEffectLevel::ProcessExec,
1251 emits_artifacts: true,
1252 ..ToolAnnotations::default()
1253 }
1254 }
1255
1256 #[test]
1257 fn tool_policy_preserves_agent_loop_transport_ceiling() {
1258 let mut annotations = ToolAnnotations {
1259 kind: ToolKind::Search,
1260 side_effect_level: SideEffectLevel::ReadOnly,
1261 ..ToolAnnotations::default()
1262 };
1263 annotations
1264 .capabilities
1265 .insert("workspace".into(), vec!["read_text".into()]);
1266 let policy = tool_capability_policy_from_spec(&serde_json::json!({
1267 "_type": "tool_registry",
1268 "tools": [
1269 {
1270 "name": "look",
1271 "parameters": {"type": "object"},
1272 "policy": annotations
1273 }
1274 ]
1275 }));
1276
1277 assert_eq!(policy.tools, vec!["look".to_string()]);
1278 assert_eq!(policy.side_effect_level.as_deref(), Some("read_only"));
1279 assert!(policy
1280 .capabilities
1281 .get("llm")
1282 .is_some_and(|ops| ops.contains(&"call".to_string())));
1283 assert!(policy
1284 .capabilities
1285 .get("workspace")
1286 .is_some_and(|ops| ops.contains(&"read_text".to_string())));
1287 }
1288
1289 #[test]
1290 fn tool_policy_preserves_dependency_key_params() {
1291 let policy = tool_capability_policy_from_spec(&serde_json::json!({
1292 "_type": "tool_registry",
1293 "tools": [
1294 {
1295 "name": "edit",
1296 "parameters": {"type": "object"},
1297 "policy": {
1298 "kind": "edit",
1299 "side_effect_level": "workspace_write",
1300 "arg_schema": {
1301 "path_params": ["path"],
1302 "dependency_key_params": ["anchor"],
1303 "dependency_range_params": [{"start": "range_start", "end": "range_end"}]
1304 }
1305 }
1306 },
1307 {
1308 "name": "edit_direct",
1309 "parameters": {"type": "object"},
1310 "policy": {
1311 "kind": "edit",
1312 "side_effect_level": "workspace_write",
1313 "path_params": ["path"],
1314 "dependency_key_params": ["old_string"],
1315 "dependency_range_params": [{"start": "line"}]
1316 }
1317 }
1318 ]
1319 }));
1320
1321 let annotations = policy.tool_annotations.get("edit").unwrap();
1322 assert_eq!(annotations.arg_schema.path_params, vec!["path".to_string()]);
1323 assert_eq!(
1324 annotations.arg_schema.dependency_key_params,
1325 vec!["anchor".to_string()]
1326 );
1327 assert_eq!(annotations.arg_schema.dependency_range_params.len(), 1);
1328 assert_eq!(
1329 annotations.arg_schema.dependency_range_params[0].start,
1330 "range_start"
1331 );
1332 assert_eq!(
1333 annotations.arg_schema.dependency_range_params[0].end,
1334 "range_end"
1335 );
1336 let direct_annotations = policy.tool_annotations.get("edit_direct").unwrap();
1337 assert_eq!(
1338 direct_annotations.arg_schema.dependency_key_params,
1339 vec!["old_string".to_string()]
1340 );
1341 assert_eq!(
1342 direct_annotations.arg_schema.dependency_range_params.len(),
1343 1
1344 );
1345 assert_eq!(
1346 direct_annotations.arg_schema.dependency_range_params[0].start,
1347 "line"
1348 );
1349 assert_eq!(
1350 direct_annotations.arg_schema.dependency_range_params[0].end,
1351 ""
1352 );
1353 }
1354
1355 #[test]
1356 fn tool_policy_without_capabilities_keeps_capability_ceiling_unspecified() {
1357 let policy = tool_capability_policy_from_spec(&serde_json::json!({
1358 "_type": "tool_registry",
1359 "tools": [
1360 {
1361 "name": "look",
1362 "parameters": {"type": "object"}
1363 }
1364 ]
1365 }));
1366
1367 assert_eq!(policy.tools, vec!["look".to_string()]);
1368 assert!(policy.capabilities.is_empty());
1369 assert!(policy.side_effect_level.is_none());
1370 }
1371
1372 #[test]
1373 fn execute_artifact_tool_requires_reader() {
1374 let mut policy = CapabilityPolicy::default();
1375 policy
1376 .tool_annotations
1377 .insert("run".into(), execute_annotations());
1378 let tools = VmValue::dict(std::collections::BTreeMap::<String, VmValue>::from_iter([
1379 (
1380 "_type".into(),
1381 VmValue::String(arcstr::ArcStr::from("tool_registry")),
1382 ),
1383 (
1384 "tools".into(),
1385 VmValue::List(std::sync::Arc::new(vec![VmValue::Dict(
1386 std::sync::Arc::new(crate::value::DictMap::from_iter([
1387 (
1388 crate::value::intern_key("name"),
1389 VmValue::String(arcstr::ArcStr::from("run")),
1390 ),
1391 (
1392 crate::value::intern_key("parameters"),
1393 VmValue::dict(crate::value::DictMap::new()),
1394 ),
1395 (
1396 crate::value::intern_key("executor"),
1397 VmValue::String(arcstr::ArcStr::from("host_bridge")),
1398 ),
1399 ])),
1400 )])),
1401 ),
1402 ]));
1403 let report = validate_tool_surface(&ToolSurfaceInput {
1404 tools: Some(tools),
1405 policy: Some(policy),
1406 ..ToolSurfaceInput::default()
1407 });
1408 assert!(report.diagnostics.iter().any(|d| {
1409 d.code == "TOOL_SURFACE_MISSING_RESULT_READER"
1410 && d.severity == ToolSurfaceSeverity::Error
1411 }));
1412 assert!(!report.valid);
1413 }
1414
1415 #[test]
1416 fn execute_artifact_tool_accepts_inline_escape_hatch() {
1417 let mut annotations = execute_annotations();
1418 annotations.inline_result = true;
1419 let mut policy = CapabilityPolicy::default();
1420 policy.tool_annotations.insert("run".into(), annotations);
1421 let report = validate_tool_surface(&ToolSurfaceInput {
1422 native_tools: Some(vec![serde_json::json!({
1423 "name": "run",
1424 "parameters": {"type": "object"},
1425 })]),
1426 policy: Some(policy),
1427 ..ToolSurfaceInput::default()
1428 });
1429 assert!(!report
1430 .diagnostics
1431 .iter()
1432 .any(|d| d.code == "TOOL_SURFACE_MISSING_RESULT_READER"));
1433 }
1434
1435 #[test]
1436 fn native_tool_annotations_are_read_from_tool_json() {
1437 let mut annotations = execute_annotations();
1438 annotations.inline_result = true;
1439 let report = validate_tool_surface(&ToolSurfaceInput {
1440 native_tools: Some(vec![serde_json::json!({
1441 "name": "run",
1442 "parameters": {"type": "object"},
1443 "annotations": annotations,
1444 })]),
1445 ..ToolSurfaceInput::default()
1446 });
1447 assert!(!report
1448 .diagnostics
1449 .iter()
1450 .any(|d| d.code == "TOOL_SURFACE_MISSING_ANNOTATIONS"));
1451 assert!(!report
1452 .diagnostics
1453 .iter()
1454 .any(|d| d.code == "TOOL_SURFACE_MISSING_RESULT_READER"));
1455 }
1456
1457 #[test]
1458 fn prompt_reference_outside_policy_is_reported() {
1459 let policy = CapabilityPolicy {
1460 tools: vec!["read_file".into()],
1461 ..CapabilityPolicy::default()
1462 };
1463 let report = validate_tool_surface(&ToolSurfaceInput {
1464 native_tools: Some(vec![
1465 serde_json::json!({"name": "read_file", "parameters": {"type": "object"}}),
1466 serde_json::json!({"name": "run_command", "parameters": {"type": "object"}}),
1467 ]),
1468 policy: Some(policy),
1469 prompt_texts: vec!["Use run_command({command: \"cargo test\"})".into()],
1470 ..ToolSurfaceInput::default()
1471 });
1472 assert!(report
1473 .diagnostics
1474 .iter()
1475 .any(|d| d.code == "TOOL_SURFACE_PROMPT_TOOL_NOT_IN_POLICY"));
1476 }
1477
1478 #[test]
1479 fn approval_rule_tool_references_are_reported() {
1480 let approval_policy: ToolApprovalPolicy = serde_json::from_value(serde_json::json!({
1481 "rules": [
1482 {"ask": {"tool": "missing_tool"}, "reason": "unknown"},
1483 {"allow": {"tool": "read_*"}}
1484 ]
1485 }))
1486 .unwrap();
1487 let report = validate_tool_surface(&ToolSurfaceInput {
1488 native_tools: Some(vec![serde_json::json!({
1489 "name": "read_file",
1490 "parameters": {"type": "object"},
1491 })]),
1492 approval_policy: Some(approval_policy),
1493 ..ToolSurfaceInput::default()
1494 });
1495
1496 assert!(report.diagnostics.iter().any(|d| {
1497 d.code == "TOOL_SURFACE_APPROVAL_PATTERN_NO_MATCH"
1498 && d.field.as_deref() == Some("approval_policy.rules[0].tool")
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[1].tool")
1503 }));
1504 }
1505
1506 #[test]
1507 fn prompt_suppression_ignores_examples() {
1508 let report = validate_tool_surface(&ToolSurfaceInput {
1509 native_tools: Some(vec![serde_json::json!({
1510 "name": "read_file",
1511 "parameters": {"type": "object"},
1512 })]),
1513 prompt_texts: vec![
1514 "```text\nrun_command({command: \"old\"})\n```\n<!-- harn-tool-surface: ignore-next-line -->\nrun_command({command: \"old\"})".into(),
1515 ],
1516 ..ToolSurfaceInput::default()
1517 });
1518 assert!(!report
1519 .diagnostics
1520 .iter()
1521 .any(|d| d.code == "TOOL_SURFACE_UNKNOWN_PROMPT_TOOL"));
1522 }
1523
1524 #[test]
1525 fn deprecated_alias_warnings_are_scoped_to_matching_tool_calls() {
1526 let mut edit_annotations = ToolAnnotations::default();
1527 edit_annotations
1528 .arg_schema
1529 .arg_aliases
1530 .insert("file".into(), "path".into());
1531 let mut look_annotations = ToolAnnotations::default();
1532 look_annotations
1533 .arg_schema
1534 .arg_aliases
1535 .insert("path".into(), "file".into());
1536
1537 let report = validate_tool_surface(&ToolSurfaceInput {
1538 native_tools: Some(vec![
1539 serde_json::json!({
1540 "name": "edit",
1541 "parameters": {"type": "object"},
1542 "annotations": edit_annotations,
1543 }),
1544 serde_json::json!({
1545 "name": "look",
1546 "parameters": {"type": "object"},
1547 "annotations": look_annotations,
1548 }),
1549 ]),
1550 prompt_texts: vec![
1551 "Use edit({ path: \"src/main.rs\", action: \"replace\" }) before look({ file: \"src/main.rs\" }).".into(),
1552 ],
1553 ..ToolSurfaceInput::default()
1554 });
1555
1556 assert!(!report
1557 .diagnostics
1558 .iter()
1559 .any(|d| d.code == "TOOL_SURFACE_DEPRECATED_ARG_ALIAS"));
1560 }
1561
1562 #[test]
1563 fn deprecated_alias_warnings_still_report_matching_multiline_calls() {
1564 let mut annotations = ToolAnnotations::default();
1565 annotations
1566 .arg_schema
1567 .arg_aliases
1568 .insert("file".into(), "path".into());
1569
1570 let report = validate_tool_surface(&ToolSurfaceInput {
1571 native_tools: Some(vec![serde_json::json!({
1572 "name": "edit",
1573 "parameters": {"type": "object"},
1574 "annotations": annotations,
1575 })]),
1576 prompt_texts: vec!["Use edit({\n file: \"src/main.rs\"\n}) once.".into()],
1577 ..ToolSurfaceInput::default()
1578 });
1579
1580 assert!(report
1581 .diagnostics
1582 .iter()
1583 .any(|d| d.code == "TOOL_SURFACE_DEPRECATED_ARG_ALIAS"));
1584 }
1585
1586 #[test]
1587 fn deprecated_alias_warnings_report_tagged_text_mode_calls() {
1588 let mut annotations = ToolAnnotations::default();
1589 annotations
1590 .arg_schema
1591 .arg_aliases
1592 .insert("file".into(), "path".into());
1593
1594 let report = validate_tool_surface(&ToolSurfaceInput {
1595 native_tools: Some(vec![serde_json::json!({
1596 "name": "edit",
1597 "parameters": {"type": "object"},
1598 "annotations": annotations,
1599 })]),
1600 prompt_texts: vec!["<tool_call>\nedit({ file: \"src/main.rs\" })\n</tool_call>".into()],
1601 ..ToolSurfaceInput::default()
1602 });
1603
1604 assert!(report
1605 .diagnostics
1606 .iter()
1607 .any(|d| d.code == "TOOL_SURFACE_DEPRECATED_ARG_ALIAS"));
1608 }
1609
1610 #[test]
1611 fn prompt_reference_scanner_tolerates_non_ascii_text() {
1612 let references = prompt_tool_references("Résumé: use run_command({command: \"test\"})");
1613 assert!(references.contains("run_command"));
1614 }
1615
1616 #[test]
1617 fn prompt_reference_scanner_reads_tagged_text_mode_calls() {
1618 let references =
1619 prompt_tool_references("<tool_call>\nrun({ command: \"cargo test\" })\n</tool_call>");
1620 assert!(references.contains("run"));
1621 }
1622
1623 #[test]
1624 fn arg_constraint_key_must_exist() {
1625 let mut annotations = ToolAnnotations {
1626 kind: ToolKind::Read,
1627 side_effect_level: SideEffectLevel::ReadOnly,
1628 arg_schema: ToolArgSchema {
1629 path_params: vec!["path".into()],
1630 ..ToolArgSchema::default()
1631 },
1632 ..ToolAnnotations::default()
1633 };
1634 annotations.arg_schema.required.push("path".into());
1635 let mut policy = CapabilityPolicy {
1636 tool_arg_constraints: vec![ToolArgConstraint {
1637 tool: "read_file".into(),
1638 arg_key: Some("missing".into()),
1639 arg_patterns: vec!["src/**".into()],
1640 }],
1641 ..CapabilityPolicy::default()
1642 };
1643 policy
1644 .tool_annotations
1645 .insert("read_file".into(), annotations);
1646 let report = validate_tool_surface(&ToolSurfaceInput {
1647 native_tools: Some(vec![serde_json::json!({
1648 "name": "read_file",
1649 "parameters": {"type": "object", "properties": {"path": {"type": "string"}}},
1650 })]),
1651 policy: Some(policy),
1652 ..ToolSurfaceInput::default()
1653 });
1654 assert!(report
1655 .diagnostics
1656 .iter()
1657 .any(|d| d.code == "TOOL_SURFACE_UNKNOWN_ARG_CONSTRAINT_KEY"));
1658 }
1659}