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