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