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