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 collect_native_entries(native, input.policy.as_ref(), &mut entries);
294 }
295 entries
296}
297
298fn collect_vm_entries(
299 tools: &VmValue,
300 policy: Option<&CapabilityPolicy>,
301 entries: &mut Vec<ToolEntry>,
302) {
303 let values: Vec<&VmValue> = match tools {
304 VmValue::List(list) => list.iter().collect(),
305 VmValue::Dict(dict) => match dict.get("tools") {
306 Some(VmValue::List(list)) => list.iter().collect(),
307 _ => vec![tools],
308 },
309 _ => Vec::new(),
310 };
311 for value in values {
312 let Some(map) = value.as_dict() else { continue };
313 let name = map
314 .get("name")
315 .map(|value| value.display())
316 .unwrap_or_default();
317 if name.is_empty() {
318 continue;
319 }
320 let (has_schema, parameter_keys) = vm_parameter_keys(map.get("parameters"));
321 let annotations = map
322 .get("annotations")
323 .map(crate::llm::vm_value_to_json)
324 .and_then(|value| serde_json::from_value::<ToolAnnotations>(value).ok())
325 .or_else(|| {
326 policy
327 .and_then(|policy| policy.tool_annotations.get(&name))
328 .cloned()
329 });
330 let executor = map.get("executor").and_then(|value| match value {
331 VmValue::String(s) => Some(s.to_string()),
332 _ => None,
333 });
334 entries.push(ToolEntry {
335 name,
336 parameter_keys,
337 has_schema,
338 annotations,
339 has_executor: executor.is_some()
340 || matches!(map.get("handler"), Some(VmValue::Closure(_)))
341 || matches!(map.get("_mcp_server"), Some(VmValue::String(_))),
342 defer_loading: matches!(map.get("defer_loading"), Some(VmValue::Bool(true))),
343 provider_native: false,
344 });
345 }
346}
347
348fn collect_native_entries(
349 native_tools: &[serde_json::Value],
350 policy: Option<&CapabilityPolicy>,
351 entries: &mut Vec<ToolEntry>,
352) {
353 for tool in native_tools {
354 let name = tool
355 .get("function")
356 .and_then(|function| function.get("name"))
357 .or_else(|| tool.get("name"))
358 .and_then(|value| value.as_str())
359 .unwrap_or("");
360 if name.is_empty() || name == "tool_search" || name.starts_with("tool_search_tool_") {
361 continue;
362 }
363 let schema = tool
364 .get("function")
365 .and_then(|function| function.get("parameters"))
366 .or_else(|| tool.get("input_schema"))
367 .or_else(|| tool.get("parameters"));
368 let (has_schema, parameter_keys) = json_parameter_keys(schema);
369 let annotations = tool
370 .get("annotations")
371 .or_else(|| {
372 tool.get("function")
373 .and_then(|function| function.get("annotations"))
374 })
375 .cloned()
376 .and_then(|value| serde_json::from_value::<ToolAnnotations>(value).ok())
377 .or_else(|| {
378 policy
379 .and_then(|policy| policy.tool_annotations.get(name))
380 .cloned()
381 });
382 entries.push(ToolEntry {
383 name: name.to_string(),
384 parameter_keys,
385 has_schema,
386 annotations,
387 has_executor: true,
388 defer_loading: tool
389 .get("defer_loading")
390 .and_then(|value| value.as_bool())
391 .or_else(|| {
392 tool.get("function")
393 .and_then(|function| function.get("defer_loading"))
394 .and_then(|value| value.as_bool())
395 })
396 .unwrap_or(false),
397 provider_native: true,
398 });
399 }
400}
401
402fn effective_active_names(
403 entries: &[ToolEntry],
404 policy: Option<&CapabilityPolicy>,
405) -> BTreeSet<String> {
406 let policy_tools = policy.map(|policy| policy.tools.as_slice()).unwrap_or(&[]);
407 entries
408 .iter()
409 .filter(|entry| {
410 policy_tools.is_empty()
411 || policy_tools
412 .iter()
413 .any(|pattern| crate::orchestration::glob_match(pattern, &entry.name))
414 })
415 .map(|entry| entry.name.clone())
416 .collect()
417}
418
419fn validate_execute_result_routes(
420 entry: &ToolEntry,
421 entries: &[ToolEntry],
422 active_names: &BTreeSet<String>,
423 diagnostics: &mut Vec<ToolSurfaceDiagnostic>,
424) {
425 let Some(annotations) = entry.annotations.as_ref() else {
426 return;
427 };
428 if annotations.kind != ToolKind::Execute || !annotations.emits_artifacts {
429 return;
430 }
431 if annotations.inline_result {
432 return;
433 }
434 let active_reader_declared = annotations
435 .result_readers
436 .iter()
437 .any(|reader| active_names.contains(reader));
438 let command_output_reader = active_names.contains("read_command_output");
439 let read_tool = entries.iter().any(|candidate| {
440 active_names.contains(candidate.name.as_str())
441 && candidate
442 .annotations
443 .as_ref()
444 .is_some_and(|a| a.kind == ToolKind::Read || a.kind == ToolKind::Search)
445 });
446 if !active_reader_declared && !command_output_reader && !read_tool {
447 diagnostics.push(
448 ToolSurfaceDiagnostic::error(
449 "TOOL_SURFACE_MISSING_RESULT_READER",
450 format!(
451 "execute tool '{}' can emit output artifacts but has no active result reader",
452 entry.name
453 ),
454 )
455 .with_tool(entry.name.clone())
456 .with_field("result_readers"),
457 );
458 }
459 for reader in &annotations.result_readers {
460 if !active_names.contains(reader) {
461 diagnostics.push(
462 ToolSurfaceDiagnostic::warning(
463 "TOOL_SURFACE_UNKNOWN_RESULT_READER",
464 format!(
465 "tool '{}' declares result reader '{}' that is not active",
466 entry.name, reader
467 ),
468 )
469 .with_tool(entry.name.clone())
470 .with_field("result_readers"),
471 );
472 }
473 }
474}
475
476fn validate_arg_constraints(
477 policy: Option<&CapabilityPolicy>,
478 entries: &[ToolEntry],
479 active_names: &BTreeSet<String>,
480 diagnostics: &mut Vec<ToolSurfaceDiagnostic>,
481) {
482 let Some(policy) = policy else { return };
483 for constraint in &policy.tool_arg_constraints {
484 let matched = entries
485 .iter()
486 .filter(|entry| active_names.contains(entry.name.as_str()))
487 .filter(|entry| crate::orchestration::glob_match(&constraint.tool, &entry.name))
488 .collect::<Vec<_>>();
489 if matched.is_empty() && !constraint.tool.contains('*') {
490 diagnostics.push(
491 ToolSurfaceDiagnostic::warning(
492 "TOOL_SURFACE_UNKNOWN_ARG_CONSTRAINT_TOOL",
493 format!(
494 "ToolArgConstraint references tool '{}' which is not active",
495 constraint.tool
496 ),
497 )
498 .with_tool(constraint.tool.clone())
499 .with_field("tool_arg_constraints.tool"),
500 );
501 }
502 if let Some(arg_key) = constraint.arg_key.as_ref() {
503 for entry in matched {
504 let annotation_keys = entry
505 .annotations
506 .as_ref()
507 .map(|a| {
508 a.arg_schema
509 .path_params
510 .iter()
511 .chain(a.arg_schema.required.iter())
512 .chain(a.arg_schema.arg_aliases.keys())
513 .chain(a.arg_schema.arg_aliases.values())
514 .cloned()
515 .collect::<BTreeSet<_>>()
516 })
517 .unwrap_or_default();
518 if !entry.parameter_keys.contains(arg_key) && !annotation_keys.contains(arg_key) {
519 diagnostics.push(
520 ToolSurfaceDiagnostic::warning(
521 "TOOL_SURFACE_UNKNOWN_ARG_CONSTRAINT_KEY",
522 format!(
523 "ToolArgConstraint for '{}' targets unknown argument '{}'",
524 entry.name, arg_key
525 ),
526 )
527 .with_tool(entry.name.clone())
528 .with_field(format!("tool_arg_constraints.{arg_key}")),
529 );
530 }
531 }
532 }
533 }
534}
535
536fn validate_approval_patterns(
537 approval: Option<&ToolApprovalPolicy>,
538 active_names: &BTreeSet<String>,
539 diagnostics: &mut Vec<ToolSurfaceDiagnostic>,
540) {
541 let Some(approval) = approval else { return };
542 for (field, patterns) in [
543 ("approval_policy.auto_approve", &approval.auto_approve),
544 ("approval_policy.auto_deny", &approval.auto_deny),
545 (
546 "approval_policy.require_approval",
547 &approval.require_approval,
548 ),
549 ] {
550 for pattern in patterns {
551 if pattern.contains('*') {
552 continue;
553 }
554 if !active_names
555 .iter()
556 .any(|name| crate::orchestration::glob_match(pattern, name))
557 {
558 diagnostics.push(
559 ToolSurfaceDiagnostic::warning(
560 "TOOL_SURFACE_APPROVAL_PATTERN_NO_MATCH",
561 format!("{field} pattern '{pattern}' matches no active tool"),
562 )
563 .with_field(field),
564 );
565 }
566 }
567 }
568}
569
570fn validate_prompt_references(
571 input: &ToolSurfaceInput,
572 entries: &[ToolEntry],
573 active_names: &BTreeSet<String>,
574 diagnostics: &mut Vec<ToolSurfaceDiagnostic>,
575) {
576 let deferred = entries
577 .iter()
578 .filter(|entry| entry.defer_loading)
579 .map(|entry| entry.name.clone())
580 .collect::<BTreeSet<_>>();
581 let known_names = entries
582 .iter()
583 .map(|entry| entry.name.clone())
584 .chain(active_names.iter().cloned())
585 .collect::<BTreeSet<_>>();
586 for text in &input.prompt_texts {
587 for name in prompt_tool_references(text) {
588 if !known_names.contains(&name) && looks_like_tool_name(&name) {
589 diagnostics.push(
590 ToolSurfaceDiagnostic::warning(
591 "TOOL_SURFACE_UNKNOWN_PROMPT_TOOL",
592 format!("prompt references tool '{name}' which is not active"),
593 )
594 .with_tool(name.clone())
595 .with_field("prompt"),
596 );
597 continue;
598 }
599 if known_names.contains(&name) && !active_names.contains(&name) {
600 diagnostics.push(
601 ToolSurfaceDiagnostic::warning(
602 "TOOL_SURFACE_PROMPT_TOOL_NOT_IN_POLICY",
603 format!("prompt references tool '{name}' outside the active policy"),
604 )
605 .with_tool(name.clone())
606 .with_field("prompt"),
607 );
608 }
609 if deferred.contains(&name) && !input.tool_search_active {
610 diagnostics.push(
611 ToolSurfaceDiagnostic::warning(
612 "TOOL_SURFACE_DEFERRED_TOOL_PROMPT_REFERENCE",
613 format!(
614 "prompt references deferred tool '{name}' but tool_search is not active"
615 ),
616 )
617 .with_tool(name.clone())
618 .with_field("prompt"),
619 );
620 }
621 }
622 for entry in entries {
623 let Some(annotations) = entry.annotations.as_ref() else {
624 continue;
625 };
626 for (alias, canonical) in &annotations.arg_schema.arg_aliases {
627 if contains_token(text, alias) {
628 diagnostics.push(
629 ToolSurfaceDiagnostic::warning(
630 "TOOL_SURFACE_DEPRECATED_ARG_ALIAS",
631 format!(
632 "prompt mentions alias '{}' for tool '{}'; use canonical argument '{}'",
633 alias, entry.name, canonical
634 ),
635 )
636 .with_tool(entry.name.clone())
637 .with_field(format!("arg_schema.arg_aliases.{alias}")),
638 );
639 }
640 }
641 }
642 }
643}
644
645fn validate_side_effect_ceiling(
646 policy: Option<&CapabilityPolicy>,
647 entries: &[ToolEntry],
648 active_names: &BTreeSet<String>,
649 diagnostics: &mut Vec<ToolSurfaceDiagnostic>,
650) {
651 let Some(policy) = policy else { return };
652 let Some(ceiling) = policy
653 .side_effect_level
654 .as_deref()
655 .map(SideEffectLevel::parse)
656 else {
657 return;
658 };
659 for entry in entries
660 .iter()
661 .filter(|entry| active_names.contains(entry.name.as_str()))
662 {
663 let Some(level) = entry.annotations.as_ref().map(|a| a.side_effect_level) else {
664 continue;
665 };
666 if level.rank() > ceiling.rank() {
667 diagnostics.push(
668 ToolSurfaceDiagnostic::error(
669 "TOOL_SURFACE_SIDE_EFFECT_CEILING_EXCEEDED",
670 format!(
671 "tool '{}' requires side-effect level '{}' but policy ceiling is '{}'",
672 entry.name,
673 level.as_str(),
674 ceiling.as_str()
675 ),
676 )
677 .with_tool(entry.name.clone())
678 .with_field("side_effect_level"),
679 );
680 }
681 }
682}
683
684pub fn prompt_tool_references(text: &str) -> BTreeSet<String> {
685 let text = prompt_binding_text(text);
686 let mut names = BTreeSet::new();
687 let bytes = text.as_bytes();
688 let mut i = 0usize;
689 while i < bytes.len() {
690 if bytes[i..].starts_with(b"<tool_call>") {
691 i += "<tool_call>".len();
692 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
693 i += 1;
694 }
695 let start = i;
696 while i < bytes.len() && is_ident_byte(bytes[i]) {
697 i += 1;
698 }
699 if i > start {
700 names.insert(text[start..i].to_string());
701 }
702 continue;
703 }
704 if is_ident_start(bytes[i]) {
705 let start = i;
706 i += 1;
707 while i < bytes.len() && is_ident_byte(bytes[i]) {
708 i += 1;
709 }
710 let name = &text[start..i];
711 let mut j = i;
712 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
713 j += 1;
714 }
715 if j < bytes.len() && bytes[j] == b'(' && !prompt_ref_stopword(name) {
716 names.insert(name.to_string());
717 }
718 continue;
719 }
720 i += 1;
721 }
722 names
723}
724
725fn prompt_binding_text(text: &str) -> String {
726 let mut out = String::new();
727 let mut in_fence = false;
728 let mut ignore_block = false;
729 let mut ignore_next = false;
730 for line in text.lines() {
731 let trimmed = line.trim();
732 if trimmed.starts_with("```") {
733 in_fence = !in_fence;
734 continue;
735 }
736 if trimmed.contains("harn-tool-surface: ignore-start") {
737 ignore_block = true;
738 continue;
739 }
740 if trimmed.contains("harn-tool-surface: ignore-end") {
741 ignore_block = false;
742 continue;
743 }
744 if trimmed.contains("harn-tool-surface: ignore-next-line") {
745 ignore_next = true;
746 continue;
747 }
748 if in_fence
749 || ignore_block
750 || trimmed.contains("harn-tool-surface: ignore-line")
751 || trimmed.contains("tool-surface-ignore")
752 {
753 continue;
754 }
755 if ignore_next {
756 ignore_next = false;
757 continue;
758 }
759 out.push_str(line);
760 out.push('\n');
761 }
762 out
763}
764
765fn prompt_ref_stopword(name: &str) -> bool {
766 matches!(
767 name,
768 "if" | "for"
769 | "while"
770 | "switch"
771 | "return"
772 | "function"
773 | "fn"
774 | "JSON"
775 | "print"
776 | "println"
777 | "contains"
778 | "len"
779 | "render"
780 | "render_prompt"
781 )
782}
783
784fn looks_like_tool_name(name: &str) -> bool {
785 name.contains('_') || name.starts_with("tool") || name.starts_with("run")
786}
787
788fn contains_token(text: &str, needle: &str) -> bool {
789 let bytes = text.as_bytes();
790 let needle_bytes = needle.as_bytes();
791 if needle_bytes.is_empty() || bytes.len() < needle_bytes.len() {
792 return false;
793 }
794 for i in 0..=bytes.len() - needle_bytes.len() {
795 if &bytes[i..i + needle_bytes.len()] != needle_bytes {
796 continue;
797 }
798 let before_ok = i == 0 || !is_ident_byte(bytes[i - 1]);
799 let after = i + needle_bytes.len();
800 let after_ok = after == bytes.len() || !is_ident_byte(bytes[after]);
801 if before_ok && after_ok {
802 return true;
803 }
804 }
805 false
806}
807
808fn is_ident_start(byte: u8) -> bool {
809 byte.is_ascii_alphabetic() || byte == b'_'
810}
811
812fn is_ident_byte(byte: u8) -> bool {
813 byte.is_ascii_alphanumeric() || byte == b'_'
814}
815
816fn is_tool_registry_like(value: &VmValue) -> bool {
817 value.as_dict().is_some_and(|dict| {
818 dict.get("_type")
819 .is_some_and(|value| value.display() == "tool_registry")
820 || dict.contains_key("tools")
821 })
822}
823
824fn vm_parameter_keys(value: Option<&VmValue>) -> (bool, BTreeSet<String>) {
825 let Some(value) = value else {
826 return (false, BTreeSet::new());
827 };
828 let json = crate::llm::vm_value_to_json(value);
829 json_parameter_keys(Some(&json))
830}
831
832fn json_parameter_keys(value: Option<&serde_json::Value>) -> (bool, BTreeSet<String>) {
833 let Some(value) = value else {
834 return (false, BTreeSet::new());
835 };
836 let mut keys = BTreeSet::new();
837 if let Some(properties) = value.get("properties").and_then(|value| value.as_object()) {
838 keys.extend(properties.keys().cloned());
839 } else if let Some(map) = value.as_object() {
840 for key in map.keys() {
841 if key != "type" && key != "required" && key != "description" {
842 keys.insert(key.clone());
843 }
844 }
845 }
846 (true, keys)
847}
848
849fn workflow_node_tools_as_native(
850 node: &crate::orchestration::WorkflowNode,
851) -> Vec<serde_json::Value> {
852 match &node.tools {
853 serde_json::Value::Array(items) => items.clone(),
854 serde_json::Value::Object(_) => vec![node.tools.clone()],
855 _ => Vec::new(),
856 }
857}
858
859fn workflow_tools_as_native(
860 policy: &CapabilityPolicy,
861 nodes: &BTreeMap<String, crate::orchestration::WorkflowNode>,
862) -> Vec<serde_json::Value> {
863 let mut tools = Vec::new();
864 let mut seen = BTreeSet::new();
865 for node in nodes.values() {
866 for tool in workflow_node_tools_as_native(node) {
867 let name = tool
868 .get("name")
869 .and_then(|value| value.as_str())
870 .unwrap_or("")
871 .to_string();
872 if !name.is_empty() && seen.insert(name) {
873 tools.push(tool);
874 }
875 }
876 }
877 for (name, annotations) in &policy.tool_annotations {
878 if seen.insert(name.clone()) {
879 tools.push(serde_json::json!({
880 "name": name,
881 "parameters": {"type": "object"},
882 "annotations": annotations,
883 "executor": "host_bridge",
884 }));
885 }
886 }
887 tools
888}
889
890#[cfg(test)]
891mod tests {
892 use super::*;
893 use crate::orchestration::ToolArgConstraint;
894 use crate::tool_annotations::ToolArgSchema;
895
896 fn execute_annotations() -> ToolAnnotations {
897 ToolAnnotations {
898 kind: ToolKind::Execute,
899 side_effect_level: SideEffectLevel::ProcessExec,
900 emits_artifacts: true,
901 ..ToolAnnotations::default()
902 }
903 }
904
905 #[test]
906 fn execute_artifact_tool_requires_reader() {
907 let mut policy = CapabilityPolicy::default();
908 policy
909 .tool_annotations
910 .insert("run".into(), execute_annotations());
911 let tools = VmValue::Dict(std::rc::Rc::new(BTreeMap::from([
912 (
913 "_type".into(),
914 VmValue::String(std::rc::Rc::from("tool_registry")),
915 ),
916 (
917 "tools".into(),
918 VmValue::List(std::rc::Rc::new(vec![VmValue::Dict(std::rc::Rc::new(
919 BTreeMap::from([
920 ("name".into(), VmValue::String(std::rc::Rc::from("run"))),
921 (
922 "parameters".into(),
923 VmValue::Dict(std::rc::Rc::new(BTreeMap::new())),
924 ),
925 (
926 "executor".into(),
927 VmValue::String(std::rc::Rc::from("host_bridge")),
928 ),
929 ]),
930 ))])),
931 ),
932 ])));
933 let report = validate_tool_surface(&ToolSurfaceInput {
934 tools: Some(tools),
935 policy: Some(policy),
936 ..ToolSurfaceInput::default()
937 });
938 assert!(report.diagnostics.iter().any(|d| {
939 d.code == "TOOL_SURFACE_MISSING_RESULT_READER"
940 && d.severity == ToolSurfaceSeverity::Error
941 }));
942 assert!(!report.valid);
943 }
944
945 #[test]
946 fn execute_artifact_tool_accepts_inline_escape_hatch() {
947 let mut annotations = execute_annotations();
948 annotations.inline_result = true;
949 let mut policy = CapabilityPolicy::default();
950 policy.tool_annotations.insert("run".into(), annotations);
951 let report = validate_tool_surface(&ToolSurfaceInput {
952 native_tools: Some(vec![serde_json::json!({
953 "name": "run",
954 "parameters": {"type": "object"},
955 })]),
956 policy: Some(policy),
957 ..ToolSurfaceInput::default()
958 });
959 assert!(!report
960 .diagnostics
961 .iter()
962 .any(|d| d.code == "TOOL_SURFACE_MISSING_RESULT_READER"));
963 }
964
965 #[test]
966 fn native_tool_annotations_are_read_from_tool_json() {
967 let mut annotations = execute_annotations();
968 annotations.inline_result = true;
969 let report = validate_tool_surface(&ToolSurfaceInput {
970 native_tools: Some(vec![serde_json::json!({
971 "name": "run",
972 "parameters": {"type": "object"},
973 "annotations": annotations,
974 })]),
975 ..ToolSurfaceInput::default()
976 });
977 assert!(!report
978 .diagnostics
979 .iter()
980 .any(|d| d.code == "TOOL_SURFACE_MISSING_ANNOTATIONS"));
981 assert!(!report
982 .diagnostics
983 .iter()
984 .any(|d| d.code == "TOOL_SURFACE_MISSING_RESULT_READER"));
985 }
986
987 #[test]
988 fn prompt_reference_outside_policy_is_reported() {
989 let policy = CapabilityPolicy {
990 tools: vec!["read_file".into()],
991 ..CapabilityPolicy::default()
992 };
993 let report = validate_tool_surface(&ToolSurfaceInput {
994 native_tools: Some(vec![
995 serde_json::json!({"name": "read_file", "parameters": {"type": "object"}}),
996 serde_json::json!({"name": "run_command", "parameters": {"type": "object"}}),
997 ]),
998 policy: Some(policy),
999 prompt_texts: vec!["Use run_command({command: \"cargo test\"})".into()],
1000 ..ToolSurfaceInput::default()
1001 });
1002 assert!(report
1003 .diagnostics
1004 .iter()
1005 .any(|d| d.code == "TOOL_SURFACE_PROMPT_TOOL_NOT_IN_POLICY"));
1006 }
1007
1008 #[test]
1009 fn prompt_suppression_ignores_examples() {
1010 let report = validate_tool_surface(&ToolSurfaceInput {
1011 native_tools: Some(vec![serde_json::json!({
1012 "name": "read_file",
1013 "parameters": {"type": "object"},
1014 })]),
1015 prompt_texts: vec![
1016 "```text\nrun_command({command: \"old\"})\n```\n<!-- harn-tool-surface: ignore-next-line -->\nrun_command({command: \"old\"})".into(),
1017 ],
1018 ..ToolSurfaceInput::default()
1019 });
1020 assert!(!report
1021 .diagnostics
1022 .iter()
1023 .any(|d| d.code == "TOOL_SURFACE_UNKNOWN_PROMPT_TOOL"));
1024 }
1025
1026 #[test]
1027 fn prompt_reference_scanner_tolerates_non_ascii_text() {
1028 let references = prompt_tool_references("Résumé: use run_command({command: \"test\"})");
1029 assert!(references.contains("run_command"));
1030 }
1031
1032 #[test]
1033 fn arg_constraint_key_must_exist() {
1034 let mut annotations = ToolAnnotations {
1035 kind: ToolKind::Read,
1036 side_effect_level: SideEffectLevel::ReadOnly,
1037 arg_schema: ToolArgSchema {
1038 path_params: vec!["path".into()],
1039 ..ToolArgSchema::default()
1040 },
1041 ..ToolAnnotations::default()
1042 };
1043 annotations.arg_schema.required.push("path".into());
1044 let mut policy = CapabilityPolicy {
1045 tool_arg_constraints: vec![ToolArgConstraint {
1046 tool: "read_file".into(),
1047 arg_key: Some("missing".into()),
1048 arg_patterns: vec!["src/**".into()],
1049 }],
1050 ..CapabilityPolicy::default()
1051 };
1052 policy
1053 .tool_annotations
1054 .insert("read_file".into(), annotations);
1055 let report = validate_tool_surface(&ToolSurfaceInput {
1056 native_tools: Some(vec![serde_json::json!({
1057 "name": "read_file",
1058 "parameters": {"type": "object", "properties": {"path": {"type": "string"}}},
1059 })]),
1060 policy: Some(policy),
1061 ..ToolSurfaceInput::default()
1062 });
1063 assert!(report
1064 .diagnostics
1065 .iter()
1066 .any(|d| d.code == "TOOL_SURFACE_UNKNOWN_ARG_CONSTRAINT_KEY"));
1067 }
1068}