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