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