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