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        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}