1use std::collections::{BTreeMap, BTreeSet};
8
9use serde::{Deserialize, Serialize};
10
11use crate::orchestration::{CapabilityPolicy, ToolApprovalPolicy};
12use crate::tool_annotations::{SideEffectLevel, ToolAnnotations, ToolKind};
13use crate::value::VmValue;
14
15#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum ToolSurfaceSeverity {
18 Warning,
19 Error,
20}
21
22#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
23pub struct ToolSurfaceDiagnostic {
24 pub code: String,
25 pub severity: ToolSurfaceSeverity,
26 pub message: String,
27 #[serde(skip_serializing_if = "Option::is_none")]
28 pub tool: Option<String>,
29 #[serde(skip_serializing_if = "Option::is_none")]
30 pub field: Option<String>,
31}
32
33impl ToolSurfaceDiagnostic {
34 fn warning(code: &str, message: impl Into<String>) -> Self {
35 Self {
36 code: code.to_string(),
37 severity: ToolSurfaceSeverity::Warning,
38 message: message.into(),
39 tool: None,
40 field: None,
41 }
42 }
43
44 fn error(code: &str, message: impl Into<String>) -> Self {
45 Self {
46 code: code.to_string(),
47 severity: ToolSurfaceSeverity::Error,
48 message: message.into(),
49 tool: None,
50 field: None,
51 }
52 }
53
54 fn with_tool(mut self, tool: impl Into<String>) -> Self {
55 self.tool = Some(tool.into());
56 self
57 }
58
59 fn with_field(mut self, field: impl Into<String>) -> Self {
60 self.field = Some(field.into());
61 self
62 }
63}
64
65#[derive(Clone, Debug, Default, Serialize, Deserialize)]
66pub struct ToolSurfaceReport {
67 pub valid: bool,
68 pub diagnostics: Vec<ToolSurfaceDiagnostic>,
69}
70
71impl ToolSurfaceReport {
72 fn new(diagnostics: Vec<ToolSurfaceDiagnostic>) -> Self {
73 let valid = diagnostics
74 .iter()
75 .all(|d| d.severity != ToolSurfaceSeverity::Error);
76 Self { valid, diagnostics }
77 }
78}
79
80#[derive(Clone, Debug, Default)]
81pub struct ToolSurfaceInput {
82 pub tools: Option<VmValue>,
83 pub native_tools: Option<Vec<serde_json::Value>>,
84 pub policy: Option<CapabilityPolicy>,
85 pub approval_policy: Option<ToolApprovalPolicy>,
86 pub prompt_texts: Vec<String>,
87 pub tool_search_active: bool,
88}
89
90#[derive(Clone, Debug, Default)]
91struct ToolEntry {
92 name: String,
93 parameter_keys: BTreeSet<String>,
94 has_schema: bool,
95 annotations: Option<ToolAnnotations>,
96 has_executor: bool,
97 defer_loading: bool,
98 provider_native: bool,
99}
100
101pub fn validate_tool_surface(input: &ToolSurfaceInput) -> ToolSurfaceReport {
102 ToolSurfaceReport::new(validate_tool_surface_diagnostics(input))
103}
104
105pub fn validate_tool_surface_diagnostics(input: &ToolSurfaceInput) -> Vec<ToolSurfaceDiagnostic> {
106 let entries = collect_entries(input);
107 let active_names = effective_active_names(&entries, input.policy.as_ref());
108 let mut diagnostics = Vec::new();
109
110 for entry in entries
111 .iter()
112 .filter(|entry| active_names.contains(entry.name.as_str()))
113 {
114 if !entry.has_schema {
115 diagnostics.push(
116 ToolSurfaceDiagnostic::warning(
117 "TOOL_SURFACE_MISSING_SCHEMA",
118 format!("active tool '{}' has no parameter schema", entry.name),
119 )
120 .with_tool(entry.name.clone())
121 .with_field("parameters"),
122 );
123 }
124 if entry.annotations.is_none() {
125 diagnostics.push(
126 ToolSurfaceDiagnostic::warning(
127 "TOOL_SURFACE_MISSING_ANNOTATIONS",
128 format!("active tool '{}' has no ToolAnnotations", entry.name),
129 )
130 .with_tool(entry.name.clone())
131 .with_field("annotations"),
132 );
133 }
134 if entry
135 .annotations
136 .as_ref()
137 .is_some_and(|annotations| annotations.side_effect_level == SideEffectLevel::None)
138 {
139 diagnostics.push(
140 ToolSurfaceDiagnostic::warning(
141 "TOOL_SURFACE_MISSING_SIDE_EFFECT_LEVEL",
142 format!("active tool '{}' has no side-effect level", entry.name),
143 )
144 .with_tool(entry.name.clone())
145 .with_field("side_effect_level"),
146 );
147 }
148 if !entry.has_executor && !entry.provider_native {
149 diagnostics.push(
150 ToolSurfaceDiagnostic::warning(
151 "TOOL_SURFACE_MISSING_EXECUTOR",
152 format!("active tool '{}' has no declared executor", entry.name),
153 )
154 .with_tool(entry.name.clone())
155 .with_field("executor"),
156 );
157 }
158 validate_execute_result_routes(entry, &entries, &active_names, &mut diagnostics);
159 }
160
161 validate_arg_constraints(
162 input.policy.as_ref(),
163 &entries,
164 &active_names,
165 &mut diagnostics,
166 );
167 validate_approval_patterns(
168 input.approval_policy.as_ref(),
169 &active_names,
170 &mut diagnostics,
171 );
172 validate_prompt_references(input, &entries, &active_names, &mut diagnostics);
173 validate_side_effect_ceiling(
174 input.policy.as_ref(),
175 &entries,
176 &active_names,
177 &mut diagnostics,
178 );
179
180 diagnostics
181}
182
183pub fn validate_workflow_graph(
184 graph: &crate::orchestration::WorkflowGraph,
185) -> Vec<ToolSurfaceDiagnostic> {
186 let mut diagnostics = Vec::new();
187 diagnostics.extend(
188 validate_tool_surface_diagnostics(&ToolSurfaceInput {
189 tools: None,
190 native_tools: Some(workflow_tools_as_native(
191 &graph.capability_policy,
192 &graph.nodes,
193 )),
194 policy: Some(graph.capability_policy.clone()),
195 approval_policy: Some(graph.approval_policy.clone()),
196 prompt_texts: Vec::new(),
197 tool_search_active: false,
198 })
199 .into_iter()
200 .map(|mut diagnostic| {
201 diagnostic.message = format!("workflow: {}", diagnostic.message);
202 diagnostic
203 }),
204 );
205 for (node_id, node) in &graph.nodes {
206 let prompt_texts = [node.system.clone(), node.prompt.clone()]
207 .into_iter()
208 .flatten()
209 .collect::<Vec<_>>();
210 diagnostics.extend(
211 validate_tool_surface_diagnostics(&ToolSurfaceInput {
212 tools: None,
213 native_tools: Some(workflow_node_tools_as_native(node)),
214 policy: Some(node.capability_policy.clone()),
215 approval_policy: Some(node.approval_policy.clone()),
216 prompt_texts,
217 tool_search_active: false,
218 })
219 .into_iter()
220 .map(|mut diagnostic| {
221 diagnostic.message = format!("node {node_id}: {}", diagnostic.message);
222 diagnostic
223 }),
224 );
225 }
226 diagnostics
227}
228
229pub fn surface_report_to_json(report: &ToolSurfaceReport) -> serde_json::Value {
230 serde_json::to_value(report).unwrap_or_else(|_| serde_json::json!({"valid": false}))
231}
232
233pub fn surface_input_from_vm(surface: &VmValue, options: Option<&VmValue>) -> ToolSurfaceInput {
234 let dict = surface.as_dict();
235 let options_dict = options.and_then(VmValue::as_dict);
236 let tools = dict
237 .and_then(|d| d.get("tools").cloned())
238 .or_else(|| options_dict.and_then(|d| d.get("tools").cloned()))
239 .or_else(|| Some(surface.clone()).filter(is_tool_registry_like));
240 let native_tools = dict
241 .and_then(|d| d.get("native_tools"))
242 .or_else(|| options_dict.and_then(|d| d.get("native_tools")))
243 .map(crate::llm::vm_value_to_json)
244 .and_then(|value| value.as_array().cloned());
245 let policy = dict
246 .and_then(|d| d.get("policy"))
247 .or_else(|| options_dict.and_then(|d| d.get("policy")))
248 .map(crate::llm::vm_value_to_json)
249 .and_then(|value| serde_json::from_value(value).ok());
250 let approval_policy = dict
251 .and_then(|d| d.get("approval_policy"))
252 .or_else(|| options_dict.and_then(|d| d.get("approval_policy")))
253 .map(crate::llm::vm_value_to_json)
254 .and_then(|value| serde_json::from_value(value).ok());
255 let mut prompt_texts = Vec::new();
256 for source in [dict, options_dict].into_iter().flatten() {
257 for key in ["system", "prompt"] {
258 if let Some(text) = source.get(key).map(|value| value.display()) {
259 if !text.is_empty() {
260 prompt_texts.push(text);
261 }
262 }
263 }
264 if let Some(VmValue::List(items)) = source.get("prompts") {
265 for item in items.iter() {
266 let text = item.display();
267 if !text.is_empty() {
268 prompt_texts.push(text);
269 }
270 }
271 }
272 }
273 let tool_search_active = dict
274 .and_then(|d| d.get("tool_search"))
275 .or_else(|| options_dict.and_then(|d| d.get("tool_search")))
276 .is_some_and(|value| !matches!(value, VmValue::Bool(false) | VmValue::Nil));
277 ToolSurfaceInput {
278 tools,
279 native_tools,
280 policy,
281 approval_policy,
282 prompt_texts,
283 tool_search_active,
284 }
285}
286
287fn collect_entries(input: &ToolSurfaceInput) -> Vec<ToolEntry> {
288 let mut entries = Vec::new();
289 if let Some(tools) = input.tools.as_ref() {
290 collect_vm_entries(tools, input.policy.as_ref(), &mut entries);
291 }
292 if let Some(native) = input.native_tools.as_ref() {
293 let vm_names: BTreeSet<String> = entries.iter().map(|entry| entry.name.clone()).collect();
294 let mut native_entries = Vec::new();
295 collect_native_entries(native, input.policy.as_ref(), &mut native_entries);
296 entries.extend(
297 native_entries
298 .into_iter()
299 .filter(|entry| !vm_names.contains(&entry.name)),
300 );
301 }
302 entries
303}
304
305fn collect_vm_entries(
306 tools: &VmValue,
307 policy: Option<&CapabilityPolicy>,
308 entries: &mut Vec<ToolEntry>,
309) {
310 let values: Vec<&VmValue> = match tools {
311 VmValue::List(list) => list.iter().collect(),
312 VmValue::Dict(dict) => match dict.get("tools") {
313 Some(VmValue::List(list)) => list.iter().collect(),
314 _ => vec![tools],
315 },
316 _ => Vec::new(),
317 };
318 for value in values {
319 let Some(map) = value.as_dict() else { continue };
320 let name = map
321 .get("name")
322 .map(|value| value.display())
323 .unwrap_or_default();
324 if name.is_empty() {
325 continue;
326 }
327 let (has_schema, parameter_keys) = vm_parameter_keys(map.get("parameters"));
328 let annotations = map
329 .get("annotations")
330 .map(crate::llm::vm_value_to_json)
331 .and_then(|value| serde_json::from_value::<ToolAnnotations>(value).ok())
332 .or_else(|| {
333 policy
334 .and_then(|policy| policy.tool_annotations.get(&name))
335 .cloned()
336 });
337 let executor = map.get("executor").and_then(|value| match value {
338 VmValue::String(s) => Some(s.to_string()),
339 _ => None,
340 });
341 entries.push(ToolEntry {
342 name,
343 parameter_keys,
344 has_schema,
345 annotations,
346 has_executor: executor.is_some()
347 || matches!(map.get("handler"), Some(VmValue::Closure(_)))
348 || matches!(map.get("_mcp_server"), Some(VmValue::String(_))),
349 defer_loading: matches!(map.get("defer_loading"), Some(VmValue::Bool(true))),
350 provider_native: false,
351 });
352 }
353}
354
355fn collect_native_entries(
356 native_tools: &[serde_json::Value],
357 policy: Option<&CapabilityPolicy>,
358 entries: &mut Vec<ToolEntry>,
359) {
360 for tool in native_tools {
361 let name = tool
362 .get("function")
363 .and_then(|function| function.get("name"))
364 .or_else(|| tool.get("name"))
365 .and_then(|value| value.as_str())
366 .unwrap_or("");
367 if name.is_empty() || name == "tool_search" || name.starts_with("tool_search_tool_") {
368 continue;
369 }
370 let schema = tool
371 .get("function")
372 .and_then(|function| function.get("parameters"))
373 .or_else(|| tool.get("input_schema"))
374 .or_else(|| tool.get("parameters"));
375 let (has_schema, parameter_keys) = json_parameter_keys(schema);
376 let annotations = tool
377 .get("annotations")
378 .or_else(|| {
379 tool.get("function")
380 .and_then(|function| function.get("annotations"))
381 })
382 .cloned()
383 .and_then(|value| serde_json::from_value::<ToolAnnotations>(value).ok())
384 .or_else(|| {
385 policy
386 .and_then(|policy| policy.tool_annotations.get(name))
387 .cloned()
388 });
389 entries.push(ToolEntry {
390 name: name.to_string(),
391 parameter_keys,
392 has_schema,
393 annotations,
394 has_executor: true,
395 defer_loading: tool
396 .get("defer_loading")
397 .and_then(|value| value.as_bool())
398 .or_else(|| {
399 tool.get("function")
400 .and_then(|function| function.get("defer_loading"))
401 .and_then(|value| value.as_bool())
402 })
403 .unwrap_or(false),
404 provider_native: true,
405 });
406 }
407}
408
409fn effective_active_names(
410 entries: &[ToolEntry],
411 policy: Option<&CapabilityPolicy>,
412) -> BTreeSet<String> {
413 let policy_tools = policy.map(|policy| policy.tools.as_slice()).unwrap_or(&[]);
414 entries
415 .iter()
416 .filter(|entry| {
417 policy_tools.is_empty()
418 || policy_tools
419 .iter()
420 .any(|pattern| crate::orchestration::glob_match(pattern, &entry.name))
421 })
422 .map(|entry| entry.name.clone())
423 .collect()
424}
425
426fn validate_execute_result_routes(
427 entry: &ToolEntry,
428 entries: &[ToolEntry],
429 active_names: &BTreeSet<String>,
430 diagnostics: &mut Vec<ToolSurfaceDiagnostic>,
431) {
432 let Some(annotations) = entry.annotations.as_ref() else {
433 return;
434 };
435 if annotations.kind != ToolKind::Execute || !annotations.emits_artifacts {
436 return;
437 }
438 if annotations.inline_result {
439 return;
440 }
441 let active_reader_declared = annotations
442 .result_readers
443 .iter()
444 .any(|reader| active_names.contains(reader));
445 let command_output_reader = active_names.contains("read_command_output");
446 let read_tool = entries.iter().any(|candidate| {
447 active_names.contains(candidate.name.as_str())
448 && candidate
449 .annotations
450 .as_ref()
451 .is_some_and(|a| a.kind == ToolKind::Read || a.kind == ToolKind::Search)
452 });
453 if !active_reader_declared && !command_output_reader && !read_tool {
454 diagnostics.push(
455 ToolSurfaceDiagnostic::error(
456 "TOOL_SURFACE_MISSING_RESULT_READER",
457 format!(
458 "execute tool '{}' can emit output artifacts but has no active result reader",
459 entry.name
460 ),
461 )
462 .with_tool(entry.name.clone())
463 .with_field("result_readers"),
464 );
465 }
466 for reader in &annotations.result_readers {
467 if !active_names.contains(reader) {
468 diagnostics.push(
469 ToolSurfaceDiagnostic::warning(
470 "TOOL_SURFACE_UNKNOWN_RESULT_READER",
471 format!(
472 "tool '{}' declares result reader '{}' that is not active",
473 entry.name, reader
474 ),
475 )
476 .with_tool(entry.name.clone())
477 .with_field("result_readers"),
478 );
479 }
480 }
481}
482
483fn validate_arg_constraints(
484 policy: Option<&CapabilityPolicy>,
485 entries: &[ToolEntry],
486 active_names: &BTreeSet<String>,
487 diagnostics: &mut Vec<ToolSurfaceDiagnostic>,
488) {
489 let Some(policy) = policy else { return };
490 for constraint in &policy.tool_arg_constraints {
491 let matched = entries
492 .iter()
493 .filter(|entry| active_names.contains(entry.name.as_str()))
494 .filter(|entry| crate::orchestration::glob_match(&constraint.tool, &entry.name))
495 .collect::<Vec<_>>();
496 if matched.is_empty() && !constraint.tool.contains('*') {
497 diagnostics.push(
498 ToolSurfaceDiagnostic::warning(
499 "TOOL_SURFACE_UNKNOWN_ARG_CONSTRAINT_TOOL",
500 format!(
501 "ToolArgConstraint references tool '{}' which is not active",
502 constraint.tool
503 ),
504 )
505 .with_tool(constraint.tool.clone())
506 .with_field("tool_arg_constraints.tool"),
507 );
508 }
509 if let Some(arg_key) = constraint.arg_key.as_ref() {
510 for entry in matched {
511 let annotation_keys = entry
512 .annotations
513 .as_ref()
514 .map(|a| {
515 a.arg_schema
516 .path_params
517 .iter()
518 .chain(a.arg_schema.required.iter())
519 .chain(a.arg_schema.arg_aliases.keys())
520 .chain(a.arg_schema.arg_aliases.values())
521 .cloned()
522 .collect::<BTreeSet<_>>()
523 })
524 .unwrap_or_default();
525 if !entry.parameter_keys.contains(arg_key) && !annotation_keys.contains(arg_key) {
526 diagnostics.push(
527 ToolSurfaceDiagnostic::warning(
528 "TOOL_SURFACE_UNKNOWN_ARG_CONSTRAINT_KEY",
529 format!(
530 "ToolArgConstraint for '{}' targets unknown argument '{}'",
531 entry.name, arg_key
532 ),
533 )
534 .with_tool(entry.name.clone())
535 .with_field(format!("tool_arg_constraints.{arg_key}")),
536 );
537 }
538 }
539 }
540 }
541}
542
543fn validate_approval_patterns(
544 approval: Option<&ToolApprovalPolicy>,
545 active_names: &BTreeSet<String>,
546 diagnostics: &mut Vec<ToolSurfaceDiagnostic>,
547) {
548 let Some(approval) = approval else { return };
549 for (field, patterns) in [
550 ("approval_policy.auto_approve", &approval.auto_approve),
551 ("approval_policy.auto_deny", &approval.auto_deny),
552 (
553 "approval_policy.require_approval",
554 &approval.require_approval,
555 ),
556 ] {
557 for pattern in patterns {
558 if pattern.contains('*') {
559 continue;
560 }
561 if !active_names
562 .iter()
563 .any(|name| crate::orchestration::glob_match(pattern, name))
564 {
565 diagnostics.push(
566 ToolSurfaceDiagnostic::warning(
567 "TOOL_SURFACE_APPROVAL_PATTERN_NO_MATCH",
568 format!("{field} pattern '{pattern}' matches no active tool"),
569 )
570 .with_field(field),
571 );
572 }
573 }
574 }
575}
576
577fn validate_prompt_references(
578 input: &ToolSurfaceInput,
579 entries: &[ToolEntry],
580 active_names: &BTreeSet<String>,
581 diagnostics: &mut Vec<ToolSurfaceDiagnostic>,
582) {
583 let deferred = entries
584 .iter()
585 .filter(|entry| entry.defer_loading)
586 .map(|entry| entry.name.clone())
587 .collect::<BTreeSet<_>>();
588 let known_names = entries
589 .iter()
590 .map(|entry| entry.name.clone())
591 .chain(active_names.iter().cloned())
592 .collect::<BTreeSet<_>>();
593 for text in &input.prompt_texts {
594 for name in prompt_tool_references(text) {
595 if !known_names.contains(&name) && looks_like_tool_name(&name) {
596 diagnostics.push(
597 ToolSurfaceDiagnostic::warning(
598 "TOOL_SURFACE_UNKNOWN_PROMPT_TOOL",
599 format!("prompt references tool '{name}' which is not active"),
600 )
601 .with_tool(name.clone())
602 .with_field("prompt"),
603 );
604 continue;
605 }
606 if known_names.contains(&name) && !active_names.contains(&name) {
607 diagnostics.push(
608 ToolSurfaceDiagnostic::warning(
609 "TOOL_SURFACE_PROMPT_TOOL_NOT_IN_POLICY",
610 format!("prompt references tool '{name}' outside the active policy"),
611 )
612 .with_tool(name.clone())
613 .with_field("prompt"),
614 );
615 }
616 if deferred.contains(&name) && !input.tool_search_active {
617 diagnostics.push(
618 ToolSurfaceDiagnostic::warning(
619 "TOOL_SURFACE_DEFERRED_TOOL_PROMPT_REFERENCE",
620 format!(
621 "prompt references deferred tool '{name}' but tool_search is not active"
622 ),
623 )
624 .with_tool(name.clone())
625 .with_field("prompt"),
626 );
627 }
628 }
629 for entry in entries {
630 let Some(annotations) = entry.annotations.as_ref() else {
631 continue;
632 };
633 for (alias, canonical) in &annotations.arg_schema.arg_aliases {
634 if contains_token(text, alias) {
635 diagnostics.push(
636 ToolSurfaceDiagnostic::warning(
637 "TOOL_SURFACE_DEPRECATED_ARG_ALIAS",
638 format!(
639 "prompt mentions alias '{}' for tool '{}'; use canonical argument '{}'",
640 alias, entry.name, canonical
641 ),
642 )
643 .with_tool(entry.name.clone())
644 .with_field(format!("arg_schema.arg_aliases.{alias}")),
645 );
646 }
647 }
648 }
649 }
650}
651
652fn validate_side_effect_ceiling(
653 policy: Option<&CapabilityPolicy>,
654 entries: &[ToolEntry],
655 active_names: &BTreeSet<String>,
656 diagnostics: &mut Vec<ToolSurfaceDiagnostic>,
657) {
658 let Some(policy) = policy else { return };
659 let Some(ceiling) = policy
660 .side_effect_level
661 .as_deref()
662 .map(SideEffectLevel::parse)
663 else {
664 return;
665 };
666 for entry in entries
667 .iter()
668 .filter(|entry| active_names.contains(entry.name.as_str()))
669 {
670 let Some(level) = entry.annotations.as_ref().map(|a| a.side_effect_level) else {
671 continue;
672 };
673 if level.rank() > ceiling.rank() {
674 diagnostics.push(
675 ToolSurfaceDiagnostic::error(
676 "TOOL_SURFACE_SIDE_EFFECT_CEILING_EXCEEDED",
677 format!(
678 "tool '{}' requires side-effect level '{}' but policy ceiling is '{}'",
679 entry.name,
680 level.as_str(),
681 ceiling.as_str()
682 ),
683 )
684 .with_tool(entry.name.clone())
685 .with_field("side_effect_level"),
686 );
687 }
688 }
689}
690
691pub fn prompt_tool_references(text: &str) -> BTreeSet<String> {
692 let text = prompt_binding_text(text);
693 let mut names = BTreeSet::new();
694 let bytes = text.as_bytes();
695 let mut i = 0usize;
696 while i < bytes.len() {
697 if bytes[i..].starts_with(b"<tool_call>") {
698 i += "<tool_call>".len();
699 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
700 i += 1;
701 }
702 let start = i;
703 while i < bytes.len() && is_ident_byte(bytes[i]) {
704 i += 1;
705 }
706 if i > start {
707 names.insert(text[start..i].to_string());
708 }
709 continue;
710 }
711 if is_ident_start(bytes[i]) {
712 let start = i;
713 i += 1;
714 while i < bytes.len() && is_ident_byte(bytes[i]) {
715 i += 1;
716 }
717 let name = &text[start..i];
718 let mut j = i;
719 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
720 j += 1;
721 }
722 if j < bytes.len() && bytes[j] == b'(' && !prompt_ref_stopword(name) {
723 names.insert(name.to_string());
724 }
725 continue;
726 }
727 i += 1;
728 }
729 names
730}
731
732fn prompt_binding_text(text: &str) -> String {
733 let mut out = String::new();
734 let mut in_fence = false;
735 let mut ignore_block = false;
736 let mut ignore_next = false;
737 for line in text.lines() {
738 let trimmed = line.trim();
739 if trimmed.starts_with("```") {
740 in_fence = !in_fence;
741 continue;
742 }
743 if trimmed.contains("harn-tool-surface: ignore-start") {
744 ignore_block = true;
745 continue;
746 }
747 if trimmed.contains("harn-tool-surface: ignore-end") {
748 ignore_block = false;
749 continue;
750 }
751 if trimmed.contains("harn-tool-surface: ignore-next-line") {
752 ignore_next = true;
753 continue;
754 }
755 if in_fence
756 || ignore_block
757 || trimmed.contains("harn-tool-surface: ignore-line")
758 || trimmed.contains("tool-surface-ignore")
759 {
760 continue;
761 }
762 if ignore_next {
763 ignore_next = false;
764 continue;
765 }
766 out.push_str(line);
767 out.push('\n');
768 }
769 out
770}
771
772fn prompt_ref_stopword(name: &str) -> bool {
773 matches!(
774 name,
775 "if" | "for"
776 | "while"
777 | "switch"
778 | "return"
779 | "function"
780 | "fn"
781 | "JSON"
782 | "print"
783 | "println"
784 | "contains"
785 | "len"
786 | "render"
787 | "render_prompt"
788 )
789}
790
791fn looks_like_tool_name(name: &str) -> bool {
792 name.contains('_') || name.starts_with("tool") || name.starts_with("run")
793}
794
795fn contains_token(text: &str, needle: &str) -> bool {
796 let bytes = text.as_bytes();
797 let needle_bytes = needle.as_bytes();
798 if needle_bytes.is_empty() || bytes.len() < needle_bytes.len() {
799 return false;
800 }
801 for i in 0..=bytes.len() - needle_bytes.len() {
802 if &bytes[i..i + needle_bytes.len()] != needle_bytes {
803 continue;
804 }
805 let before_ok = i == 0 || !is_ident_byte(bytes[i - 1]);
806 let after = i + needle_bytes.len();
807 let after_ok = after == bytes.len() || !is_ident_byte(bytes[after]);
808 if before_ok && after_ok {
809 return true;
810 }
811 }
812 false
813}
814
815fn is_ident_start(byte: u8) -> bool {
816 byte.is_ascii_alphabetic() || byte == b'_'
817}
818
819fn is_ident_byte(byte: u8) -> bool {
820 byte.is_ascii_alphanumeric() || byte == b'_'
821}
822
823fn is_tool_registry_like(value: &VmValue) -> bool {
824 value.as_dict().is_some_and(|dict| {
825 dict.get("_type")
826 .is_some_and(|value| value.display() == "tool_registry")
827 || dict.contains_key("tools")
828 })
829}
830
831fn vm_parameter_keys(value: Option<&VmValue>) -> (bool, BTreeSet<String>) {
832 let Some(value) = value else {
833 return (false, BTreeSet::new());
834 };
835 let json = crate::llm::vm_value_to_json(value);
836 json_parameter_keys(Some(&json))
837}
838
839fn json_parameter_keys(value: Option<&serde_json::Value>) -> (bool, BTreeSet<String>) {
840 let Some(value) = value else {
841 return (false, BTreeSet::new());
842 };
843 let mut keys = BTreeSet::new();
844 if let Some(properties) = value.get("properties").and_then(|value| value.as_object()) {
845 keys.extend(properties.keys().cloned());
846 } else if let Some(map) = value.as_object() {
847 for key in map.keys() {
848 if key != "type" && key != "required" && key != "description" {
849 keys.insert(key.clone());
850 }
851 }
852 }
853 (true, keys)
854}
855
856fn workflow_node_tools_as_native(
857 node: &crate::orchestration::WorkflowNode,
858) -> Vec<serde_json::Value> {
859 match &node.tools {
860 serde_json::Value::Array(items) => items.clone(),
861 serde_json::Value::Object(_) => vec![node.tools.clone()],
862 _ => Vec::new(),
863 }
864}
865
866fn workflow_tools_as_native(
867 policy: &CapabilityPolicy,
868 nodes: &BTreeMap<String, crate::orchestration::WorkflowNode>,
869) -> Vec<serde_json::Value> {
870 let mut tools = Vec::new();
871 let mut seen = BTreeSet::new();
872 for node in nodes.values() {
873 for tool in workflow_node_tools_as_native(node) {
874 let name = tool
875 .get("name")
876 .and_then(|value| value.as_str())
877 .unwrap_or("")
878 .to_string();
879 if !name.is_empty() && seen.insert(name) {
880 tools.push(tool);
881 }
882 }
883 }
884 for (name, annotations) in &policy.tool_annotations {
885 if seen.insert(name.clone()) {
886 tools.push(serde_json::json!({
887 "name": name,
888 "parameters": {"type": "object"},
889 "annotations": annotations,
890 "executor": "host_bridge",
891 }));
892 }
893 }
894 tools
895}
896
897#[cfg(test)]
898mod tests {
899 use super::*;
900 use crate::orchestration::ToolArgConstraint;
901 use crate::tool_annotations::ToolArgSchema;
902
903 fn execute_annotations() -> ToolAnnotations {
904 ToolAnnotations {
905 kind: ToolKind::Execute,
906 side_effect_level: SideEffectLevel::ProcessExec,
907 emits_artifacts: true,
908 ..ToolAnnotations::default()
909 }
910 }
911
912 #[test]
913 fn execute_artifact_tool_requires_reader() {
914 let mut policy = CapabilityPolicy::default();
915 policy
916 .tool_annotations
917 .insert("run".into(), execute_annotations());
918 let tools = VmValue::Dict(std::rc::Rc::new(BTreeMap::from([
919 (
920 "_type".into(),
921 VmValue::String(std::rc::Rc::from("tool_registry")),
922 ),
923 (
924 "tools".into(),
925 VmValue::List(std::rc::Rc::new(vec![VmValue::Dict(std::rc::Rc::new(
926 BTreeMap::from([
927 ("name".into(), VmValue::String(std::rc::Rc::from("run"))),
928 (
929 "parameters".into(),
930 VmValue::Dict(std::rc::Rc::new(BTreeMap::new())),
931 ),
932 (
933 "executor".into(),
934 VmValue::String(std::rc::Rc::from("host_bridge")),
935 ),
936 ]),
937 ))])),
938 ),
939 ])));
940 let report = validate_tool_surface(&ToolSurfaceInput {
941 tools: Some(tools),
942 policy: Some(policy),
943 ..ToolSurfaceInput::default()
944 });
945 assert!(report.diagnostics.iter().any(|d| {
946 d.code == "TOOL_SURFACE_MISSING_RESULT_READER"
947 && d.severity == ToolSurfaceSeverity::Error
948 }));
949 assert!(!report.valid);
950 }
951
952 #[test]
953 fn execute_artifact_tool_accepts_inline_escape_hatch() {
954 let mut annotations = execute_annotations();
955 annotations.inline_result = true;
956 let mut policy = CapabilityPolicy::default();
957 policy.tool_annotations.insert("run".into(), annotations);
958 let report = validate_tool_surface(&ToolSurfaceInput {
959 native_tools: Some(vec![serde_json::json!({
960 "name": "run",
961 "parameters": {"type": "object"},
962 })]),
963 policy: Some(policy),
964 ..ToolSurfaceInput::default()
965 });
966 assert!(!report
967 .diagnostics
968 .iter()
969 .any(|d| d.code == "TOOL_SURFACE_MISSING_RESULT_READER"));
970 }
971
972 #[test]
973 fn native_tool_annotations_are_read_from_tool_json() {
974 let mut annotations = execute_annotations();
975 annotations.inline_result = true;
976 let report = validate_tool_surface(&ToolSurfaceInput {
977 native_tools: Some(vec![serde_json::json!({
978 "name": "run",
979 "parameters": {"type": "object"},
980 "annotations": annotations,
981 })]),
982 ..ToolSurfaceInput::default()
983 });
984 assert!(!report
985 .diagnostics
986 .iter()
987 .any(|d| d.code == "TOOL_SURFACE_MISSING_ANNOTATIONS"));
988 assert!(!report
989 .diagnostics
990 .iter()
991 .any(|d| d.code == "TOOL_SURFACE_MISSING_RESULT_READER"));
992 }
993
994 #[test]
995 fn prompt_reference_outside_policy_is_reported() {
996 let policy = CapabilityPolicy {
997 tools: vec!["read_file".into()],
998 ..CapabilityPolicy::default()
999 };
1000 let report = validate_tool_surface(&ToolSurfaceInput {
1001 native_tools: Some(vec![
1002 serde_json::json!({"name": "read_file", "parameters": {"type": "object"}}),
1003 serde_json::json!({"name": "run_command", "parameters": {"type": "object"}}),
1004 ]),
1005 policy: Some(policy),
1006 prompt_texts: vec!["Use run_command({command: \"cargo test\"})".into()],
1007 ..ToolSurfaceInput::default()
1008 });
1009 assert!(report
1010 .diagnostics
1011 .iter()
1012 .any(|d| d.code == "TOOL_SURFACE_PROMPT_TOOL_NOT_IN_POLICY"));
1013 }
1014
1015 #[test]
1016 fn prompt_suppression_ignores_examples() {
1017 let report = validate_tool_surface(&ToolSurfaceInput {
1018 native_tools: Some(vec![serde_json::json!({
1019 "name": "read_file",
1020 "parameters": {"type": "object"},
1021 })]),
1022 prompt_texts: vec![
1023 "```text\nrun_command({command: \"old\"})\n```\n<!-- harn-tool-surface: ignore-next-line -->\nrun_command({command: \"old\"})".into(),
1024 ],
1025 ..ToolSurfaceInput::default()
1026 });
1027 assert!(!report
1028 .diagnostics
1029 .iter()
1030 .any(|d| d.code == "TOOL_SURFACE_UNKNOWN_PROMPT_TOOL"));
1031 }
1032
1033 #[test]
1034 fn prompt_reference_scanner_tolerates_non_ascii_text() {
1035 let references = prompt_tool_references("Résumé: use run_command({command: \"test\"})");
1036 assert!(references.contains("run_command"));
1037 }
1038
1039 #[test]
1040 fn arg_constraint_key_must_exist() {
1041 let mut annotations = ToolAnnotations {
1042 kind: ToolKind::Read,
1043 side_effect_level: SideEffectLevel::ReadOnly,
1044 arg_schema: ToolArgSchema {
1045 path_params: vec!["path".into()],
1046 ..ToolArgSchema::default()
1047 },
1048 ..ToolAnnotations::default()
1049 };
1050 annotations.arg_schema.required.push("path".into());
1051 let mut policy = CapabilityPolicy {
1052 tool_arg_constraints: vec![ToolArgConstraint {
1053 tool: "read_file".into(),
1054 arg_key: Some("missing".into()),
1055 arg_patterns: vec!["src/**".into()],
1056 }],
1057 ..CapabilityPolicy::default()
1058 };
1059 policy
1060 .tool_annotations
1061 .insert("read_file".into(), annotations);
1062 let report = validate_tool_surface(&ToolSurfaceInput {
1063 native_tools: Some(vec![serde_json::json!({
1064 "name": "read_file",
1065 "parameters": {"type": "object", "properties": {"path": {"type": "string"}}},
1066 })]),
1067 policy: Some(policy),
1068 ..ToolSurfaceInput::default()
1069 });
1070 assert!(report
1071 .diagnostics
1072 .iter()
1073 .any(|d| d.code == "TOOL_SURFACE_UNKNOWN_ARG_CONSTRAINT_KEY"));
1074 }
1075}