Skip to main content

harn_vm/
tool_surface.rs

1//! Validation for coherent tool surfaces before an agent spends model tokens.
2//!
3//! The checks here are deliberately structural and conservative. They do not
4//! try to understand arbitrary prose; they validate declared registries,
5//! policies, and prompt text with an explicit suppression convention.
6
7use 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}