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