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        sandbox_profile: crate::orchestration::SandboxProfile::default(),
312    }
313}
314
315#[derive(Clone, Debug, Default)]
316pub struct ToolSurfaceInput {
317    pub tools: Option<VmValue>,
318    pub native_tools: Option<Vec<serde_json::Value>>,
319    pub policy: Option<CapabilityPolicy>,
320    pub approval_policy: Option<ToolApprovalPolicy>,
321    pub prompt_texts: Vec<String>,
322    pub tool_search_active: bool,
323}
324
325#[derive(Clone, Debug, Default)]
326struct ToolEntry {
327    name: String,
328    parameter_keys: BTreeSet<String>,
329    has_schema: bool,
330    annotations: Option<ToolAnnotations>,
331    has_executor: bool,
332    defer_loading: bool,
333    provider_native: bool,
334}
335
336pub fn validate_tool_surface(input: &ToolSurfaceInput) -> ToolSurfaceReport {
337    ToolSurfaceReport::new(validate_tool_surface_diagnostics(input))
338}
339
340pub fn validate_tool_surface_diagnostics(input: &ToolSurfaceInput) -> Vec<ToolSurfaceDiagnostic> {
341    let entries = collect_entries(input);
342    let active_names = effective_active_names(&entries, input.policy.as_ref());
343    let mut diagnostics = Vec::new();
344
345    for entry in entries
346        .iter()
347        .filter(|entry| active_names.contains(entry.name.as_str()))
348    {
349        if !entry.has_schema {
350            diagnostics.push(
351                ToolSurfaceDiagnostic::warning(
352                    "TOOL_SURFACE_MISSING_SCHEMA",
353                    format!("active tool '{}' has no parameter schema", entry.name),
354                )
355                .with_tool(entry.name.clone())
356                .with_field("parameters"),
357            );
358        }
359        if entry.annotations.is_none() {
360            diagnostics.push(
361                ToolSurfaceDiagnostic::warning(
362                    "TOOL_SURFACE_MISSING_ANNOTATIONS",
363                    format!("active tool '{}' has no ToolAnnotations", entry.name),
364                )
365                .with_tool(entry.name.clone())
366                .with_field("annotations"),
367            );
368        }
369        if entry
370            .annotations
371            .as_ref()
372            .is_some_and(|annotations| annotations.side_effect_level == SideEffectLevel::None)
373        {
374            diagnostics.push(
375                ToolSurfaceDiagnostic::warning(
376                    "TOOL_SURFACE_MISSING_SIDE_EFFECT_LEVEL",
377                    format!("active tool '{}' has no side-effect level", entry.name),
378                )
379                .with_tool(entry.name.clone())
380                .with_field("side_effect_level"),
381            );
382        }
383        if !entry.has_executor && !entry.provider_native {
384            diagnostics.push(
385                ToolSurfaceDiagnostic::warning(
386                    "TOOL_SURFACE_MISSING_EXECUTOR",
387                    format!("active tool '{}' has no declared executor", entry.name),
388                )
389                .with_tool(entry.name.clone())
390                .with_field("executor"),
391            );
392        }
393        validate_execute_result_routes(entry, &entries, &active_names, &mut diagnostics);
394    }
395
396    validate_arg_constraints(
397        input.policy.as_ref(),
398        &entries,
399        &active_names,
400        &mut diagnostics,
401    );
402    validate_approval_patterns(
403        input.approval_policy.as_ref(),
404        &active_names,
405        &mut diagnostics,
406    );
407    validate_prompt_references(input, &entries, &active_names, &mut diagnostics);
408    validate_side_effect_ceiling(
409        input.policy.as_ref(),
410        &entries,
411        &active_names,
412        &mut diagnostics,
413    );
414
415    diagnostics
416}
417
418pub fn validate_workflow_graph(
419    graph: &crate::orchestration::WorkflowGraph,
420) -> Vec<ToolSurfaceDiagnostic> {
421    let mut diagnostics = Vec::new();
422    diagnostics.extend(
423        validate_tool_surface_diagnostics(&ToolSurfaceInput {
424            tools: None,
425            native_tools: Some(workflow_tools_as_native(
426                &graph.capability_policy,
427                &graph.nodes,
428            )),
429            policy: Some(graph.capability_policy.clone()),
430            approval_policy: Some(graph.approval_policy.clone()),
431            prompt_texts: Vec::new(),
432            tool_search_active: false,
433        })
434        .into_iter()
435        .map(|mut diagnostic| {
436            diagnostic.message = format!("workflow: {}", diagnostic.message);
437            diagnostic
438        }),
439    );
440    for (node_id, node) in &graph.nodes {
441        let prompt_texts = [node.system.clone(), node.prompt.clone()]
442            .into_iter()
443            .flatten()
444            .collect::<Vec<_>>();
445        diagnostics.extend(
446            validate_tool_surface_diagnostics(&ToolSurfaceInput {
447                tools: None,
448                native_tools: Some(workflow_node_tools_as_native(node)),
449                policy: Some(node.capability_policy.clone()),
450                approval_policy: Some(node.approval_policy.clone()),
451                prompt_texts,
452                tool_search_active: false,
453            })
454            .into_iter()
455            .map(|mut diagnostic| {
456                diagnostic.message = format!("node {node_id}: {}", diagnostic.message);
457                diagnostic
458            }),
459        );
460    }
461    diagnostics
462}
463
464pub fn surface_report_to_json(report: &ToolSurfaceReport) -> serde_json::Value {
465    serde_json::to_value(report).unwrap_or_else(|_| serde_json::json!({"valid": false}))
466}
467
468pub fn surface_input_from_vm(surface: &VmValue, options: Option<&VmValue>) -> ToolSurfaceInput {
469    let dict = surface.as_dict();
470    let options_dict = options.and_then(VmValue::as_dict);
471    let tools = dict
472        .and_then(|d| d.get("tools").cloned())
473        .or_else(|| options_dict.and_then(|d| d.get("tools").cloned()))
474        .or_else(|| Some(surface.clone()).filter(is_tool_registry_like));
475    let native_tools = dict
476        .and_then(|d| d.get("native_tools"))
477        .or_else(|| options_dict.and_then(|d| d.get("native_tools")))
478        .map(crate::llm::vm_value_to_json)
479        .and_then(|value| value.as_array().cloned());
480    let policy = dict
481        .and_then(|d| d.get("policy"))
482        .or_else(|| options_dict.and_then(|d| d.get("policy")))
483        .map(crate::llm::vm_value_to_json)
484        .and_then(|value| serde_json::from_value(value).ok());
485    let approval_policy = dict
486        .and_then(|d| d.get("approval_policy"))
487        .or_else(|| options_dict.and_then(|d| d.get("approval_policy")))
488        .map(crate::llm::vm_value_to_json)
489        .and_then(|value| serde_json::from_value(value).ok());
490    let mut prompt_texts = Vec::new();
491    for source in [dict, options_dict].into_iter().flatten() {
492        for key in ["system", "prompt"] {
493            if let Some(text) = source.get(key).map(|value| value.display()) {
494                if !text.is_empty() {
495                    prompt_texts.push(text);
496                }
497            }
498        }
499        if let Some(VmValue::List(items)) = source.get("prompts") {
500            for item in items.iter() {
501                let text = item.display();
502                if !text.is_empty() {
503                    prompt_texts.push(text);
504                }
505            }
506        }
507    }
508    let tool_search_active = dict
509        .and_then(|d| d.get("tool_search"))
510        .or_else(|| options_dict.and_then(|d| d.get("tool_search")))
511        .is_some_and(|value| !matches!(value, VmValue::Bool(false) | VmValue::Nil));
512    ToolSurfaceInput {
513        tools,
514        native_tools,
515        policy,
516        approval_policy,
517        prompt_texts,
518        tool_search_active,
519    }
520}
521
522fn collect_entries(input: &ToolSurfaceInput) -> Vec<ToolEntry> {
523    let mut entries = Vec::new();
524    if let Some(tools) = input.tools.as_ref() {
525        collect_vm_entries(tools, input.policy.as_ref(), &mut entries);
526    }
527    if let Some(native) = input.native_tools.as_ref() {
528        let vm_names: BTreeSet<String> = entries.iter().map(|entry| entry.name.clone()).collect();
529        let mut native_entries = Vec::new();
530        collect_native_entries(native, input.policy.as_ref(), &mut native_entries);
531        entries.extend(
532            native_entries
533                .into_iter()
534                .filter(|entry| !vm_names.contains(&entry.name)),
535        );
536    }
537    entries
538}
539
540fn collect_vm_entries(
541    tools: &VmValue,
542    policy: Option<&CapabilityPolicy>,
543    entries: &mut Vec<ToolEntry>,
544) {
545    let values: Vec<&VmValue> = match tools {
546        VmValue::List(list) => list.iter().collect(),
547        VmValue::Dict(dict) => match dict.get("tools") {
548            Some(VmValue::List(list)) => list.iter().collect(),
549            _ => vec![tools],
550        },
551        _ => Vec::new(),
552    };
553    for value in values {
554        let Some(map) = value.as_dict() else { continue };
555        let name = map
556            .get("name")
557            .map(|value| value.display())
558            .unwrap_or_default();
559        if name.is_empty() {
560            continue;
561        }
562        let (has_schema, parameter_keys) = vm_parameter_keys(map.get("parameters"));
563        let annotations = map
564            .get("annotations")
565            .map(crate::llm::vm_value_to_json)
566            .and_then(|value| serde_json::from_value::<ToolAnnotations>(value).ok())
567            .or_else(|| {
568                policy
569                    .and_then(|policy| policy.tool_annotations.get(&name))
570                    .cloned()
571            });
572        let executor = map.get("executor").and_then(|value| match value {
573            VmValue::String(s) => Some(s.to_string()),
574            _ => None,
575        });
576        entries.push(ToolEntry {
577            name,
578            parameter_keys,
579            has_schema,
580            annotations,
581            has_executor: executor.is_some()
582                || matches!(map.get("handler"), Some(VmValue::Closure(_)))
583                || matches!(map.get("_mcp_server"), Some(VmValue::String(_))),
584            defer_loading: matches!(map.get("defer_loading"), Some(VmValue::Bool(true))),
585            provider_native: false,
586        });
587    }
588}
589
590fn collect_native_entries(
591    native_tools: &[serde_json::Value],
592    policy: Option<&CapabilityPolicy>,
593    entries: &mut Vec<ToolEntry>,
594) {
595    for tool in native_tools {
596        let name = tool
597            .get("function")
598            .and_then(|function| function.get("name"))
599            .or_else(|| tool.get("name"))
600            .and_then(|value| value.as_str())
601            .unwrap_or("");
602        if name.is_empty() || name == "tool_search" || name.starts_with("tool_search_tool_") {
603            continue;
604        }
605        let schema = tool
606            .get("function")
607            .and_then(|function| function.get("parameters"))
608            .or_else(|| tool.get("input_schema"))
609            .or_else(|| tool.get("parameters"));
610        let (has_schema, parameter_keys) = json_parameter_keys(schema);
611        let annotations = tool
612            .get("annotations")
613            .or_else(|| {
614                tool.get("function")
615                    .and_then(|function| function.get("annotations"))
616            })
617            .cloned()
618            .and_then(|value| serde_json::from_value::<ToolAnnotations>(value).ok())
619            .or_else(|| {
620                policy
621                    .and_then(|policy| policy.tool_annotations.get(name))
622                    .cloned()
623            });
624        entries.push(ToolEntry {
625            name: name.to_string(),
626            parameter_keys,
627            has_schema,
628            annotations,
629            has_executor: true,
630            defer_loading: tool
631                .get("defer_loading")
632                .and_then(|value| value.as_bool())
633                .or_else(|| {
634                    tool.get("function")
635                        .and_then(|function| function.get("defer_loading"))
636                        .and_then(|value| value.as_bool())
637                })
638                .unwrap_or(false),
639            provider_native: true,
640        });
641    }
642}
643
644fn effective_active_names(
645    entries: &[ToolEntry],
646    policy: Option<&CapabilityPolicy>,
647) -> BTreeSet<String> {
648    let policy_tools = policy.map(|policy| policy.tools.as_slice()).unwrap_or(&[]);
649    entries
650        .iter()
651        .filter(|entry| {
652            policy_tools.is_empty()
653                || policy_tools
654                    .iter()
655                    .any(|pattern| crate::orchestration::glob_match(pattern, &entry.name))
656        })
657        .map(|entry| entry.name.clone())
658        .collect()
659}
660
661fn validate_execute_result_routes(
662    entry: &ToolEntry,
663    entries: &[ToolEntry],
664    active_names: &BTreeSet<String>,
665    diagnostics: &mut Vec<ToolSurfaceDiagnostic>,
666) {
667    let Some(annotations) = entry.annotations.as_ref() else {
668        return;
669    };
670    if annotations.kind != ToolKind::Execute || !annotations.emits_artifacts {
671        return;
672    }
673    if annotations.inline_result {
674        return;
675    }
676    let active_reader_declared = annotations
677        .result_readers
678        .iter()
679        .any(|reader| active_names.contains(reader));
680    let command_output_reader = active_names.contains("read_command_output");
681    let read_tool = entries.iter().any(|candidate| {
682        active_names.contains(candidate.name.as_str())
683            && candidate
684                .annotations
685                .as_ref()
686                .is_some_and(|a| a.kind == ToolKind::Read || a.kind == ToolKind::Search)
687    });
688    if !active_reader_declared && !command_output_reader && !read_tool {
689        diagnostics.push(
690            ToolSurfaceDiagnostic::error(
691                "TOOL_SURFACE_MISSING_RESULT_READER",
692                format!(
693                    "execute tool '{}' can emit output artifacts but has no active result reader",
694                    entry.name
695                ),
696            )
697            .with_tool(entry.name.clone())
698            .with_field("result_readers"),
699        );
700    }
701    for reader in &annotations.result_readers {
702        if !active_names.contains(reader) {
703            diagnostics.push(
704                ToolSurfaceDiagnostic::warning(
705                    "TOOL_SURFACE_UNKNOWN_RESULT_READER",
706                    format!(
707                        "tool '{}' declares result reader '{}' that is not active",
708                        entry.name, reader
709                    ),
710                )
711                .with_tool(entry.name.clone())
712                .with_field("result_readers"),
713            );
714        }
715    }
716}
717
718fn validate_arg_constraints(
719    policy: Option<&CapabilityPolicy>,
720    entries: &[ToolEntry],
721    active_names: &BTreeSet<String>,
722    diagnostics: &mut Vec<ToolSurfaceDiagnostic>,
723) {
724    let Some(policy) = policy else { return };
725    for constraint in &policy.tool_arg_constraints {
726        let matched = entries
727            .iter()
728            .filter(|entry| active_names.contains(entry.name.as_str()))
729            .filter(|entry| crate::orchestration::glob_match(&constraint.tool, &entry.name))
730            .collect::<Vec<_>>();
731        if matched.is_empty() && !constraint.tool.contains('*') {
732            diagnostics.push(
733                ToolSurfaceDiagnostic::warning(
734                    "TOOL_SURFACE_UNKNOWN_ARG_CONSTRAINT_TOOL",
735                    format!(
736                        "ToolArgConstraint references tool '{}' which is not active",
737                        constraint.tool
738                    ),
739                )
740                .with_tool(constraint.tool.clone())
741                .with_field("tool_arg_constraints.tool"),
742            );
743        }
744        if let Some(arg_key) = constraint.arg_key.as_ref() {
745            for entry in matched {
746                let annotation_keys = entry
747                    .annotations
748                    .as_ref()
749                    .map(|a| {
750                        a.arg_schema
751                            .path_params
752                            .iter()
753                            .chain(a.arg_schema.required.iter())
754                            .chain(a.arg_schema.arg_aliases.keys())
755                            .chain(a.arg_schema.arg_aliases.values())
756                            .cloned()
757                            .collect::<BTreeSet<_>>()
758                    })
759                    .unwrap_or_default();
760                if !entry.parameter_keys.contains(arg_key) && !annotation_keys.contains(arg_key) {
761                    diagnostics.push(
762                        ToolSurfaceDiagnostic::warning(
763                            "TOOL_SURFACE_UNKNOWN_ARG_CONSTRAINT_KEY",
764                            format!(
765                                "ToolArgConstraint for '{}' targets unknown argument '{}'",
766                                entry.name, arg_key
767                            ),
768                        )
769                        .with_tool(entry.name.clone())
770                        .with_field(format!("tool_arg_constraints.{arg_key}")),
771                    );
772                }
773            }
774        }
775    }
776}
777
778fn validate_approval_patterns(
779    approval: Option<&ToolApprovalPolicy>,
780    active_names: &BTreeSet<String>,
781    diagnostics: &mut Vec<ToolSurfaceDiagnostic>,
782) {
783    let Some(approval) = approval else { return };
784    for (field, patterns) in [
785        ("approval_policy.auto_approve", &approval.auto_approve),
786        ("approval_policy.auto_deny", &approval.auto_deny),
787        (
788            "approval_policy.require_approval",
789            &approval.require_approval,
790        ),
791    ] {
792        for pattern in patterns {
793            validate_approval_tool_pattern(pattern, field, active_names, diagnostics);
794        }
795    }
796    for (index, rule) in approval.rules.iter().enumerate() {
797        for pattern in &rule.matches.tool {
798            validate_approval_tool_pattern(
799                pattern,
800                &format!("approval_policy.rules[{index}].tool"),
801                active_names,
802                diagnostics,
803            );
804        }
805    }
806}
807
808fn validate_approval_tool_pattern(
809    pattern: &str,
810    field: &str,
811    active_names: &BTreeSet<String>,
812    diagnostics: &mut Vec<ToolSurfaceDiagnostic>,
813) {
814    if pattern.contains('*') {
815        return;
816    }
817    if !active_names
818        .iter()
819        .any(|name| crate::orchestration::glob_match(pattern, name))
820    {
821        diagnostics.push(
822            ToolSurfaceDiagnostic::warning(
823                "TOOL_SURFACE_APPROVAL_PATTERN_NO_MATCH",
824                format!("{field} pattern '{pattern}' matches no active tool"),
825            )
826            .with_field(field),
827        );
828    }
829}
830
831fn validate_prompt_references(
832    input: &ToolSurfaceInput,
833    entries: &[ToolEntry],
834    active_names: &BTreeSet<String>,
835    diagnostics: &mut Vec<ToolSurfaceDiagnostic>,
836) {
837    let deferred = entries
838        .iter()
839        .filter(|entry| entry.defer_loading)
840        .map(|entry| entry.name.clone())
841        .collect::<BTreeSet<_>>();
842    let known_names = entries
843        .iter()
844        .map(|entry| entry.name.clone())
845        .chain(active_names.iter().cloned())
846        .collect::<BTreeSet<_>>();
847    for text in &input.prompt_texts {
848        let binding_text = prompt_binding_text(text);
849        let calls = prompt_tool_calls(&binding_text);
850        for call in &calls {
851            let name = call.name;
852            if !known_names.contains(name) && looks_like_tool_name(name) {
853                diagnostics.push(
854                    ToolSurfaceDiagnostic::warning(
855                        "TOOL_SURFACE_UNKNOWN_PROMPT_TOOL",
856                        format!("prompt references tool '{name}' which is not active"),
857                    )
858                    .with_tool(name.to_string())
859                    .with_field("prompt"),
860                );
861                continue;
862            }
863            if known_names.contains(name) && !active_names.contains(name) {
864                diagnostics.push(
865                    ToolSurfaceDiagnostic::warning(
866                        "TOOL_SURFACE_PROMPT_TOOL_NOT_IN_POLICY",
867                        format!("prompt references tool '{name}' outside the active policy"),
868                    )
869                    .with_tool(name.to_string())
870                    .with_field("prompt"),
871                );
872            }
873            if deferred.contains(name) && !input.tool_search_active {
874                diagnostics.push(
875                    ToolSurfaceDiagnostic::warning(
876                        "TOOL_SURFACE_DEFERRED_TOOL_PROMPT_REFERENCE",
877                        format!(
878                            "prompt references deferred tool '{name}' but tool_search is not active"
879                        ),
880                    )
881                    .with_tool(name.to_string())
882                    .with_field("prompt"),
883                );
884            }
885        }
886        for entry in entries {
887            let Some(annotations) = entry.annotations.as_ref() else {
888                continue;
889            };
890            for (alias, canonical) in &annotations.arg_schema.arg_aliases {
891                if calls
892                    .iter()
893                    .any(|call| call.name == entry.name && contains_token(call.text, alias))
894                {
895                    diagnostics.push(
896                        ToolSurfaceDiagnostic::warning(
897                            "TOOL_SURFACE_DEPRECATED_ARG_ALIAS",
898                            format!(
899                                "prompt mentions alias '{}' for tool '{}'; use canonical argument '{}'",
900                                alias, entry.name, canonical
901                            ),
902                        )
903                        .with_tool(entry.name.clone())
904                        .with_field(format!("arg_schema.arg_aliases.{alias}")),
905                    );
906                }
907            }
908        }
909    }
910}
911
912struct PromptToolCall<'a> {
913    name: &'a str,
914    text: &'a str,
915}
916
917fn prompt_tool_calls(text: &str) -> Vec<PromptToolCall<'_>> {
918    let mut calls = Vec::new();
919    let bytes = text.as_bytes();
920    let mut i = 0usize;
921    while i < bytes.len() {
922        if let Some((open_tag, close_tag)) = text_tool_call_tag_pairs()
923            .into_iter()
924            .find(|(open_tag, _)| bytes[i..].starts_with(open_tag.as_bytes()))
925        {
926            let call_start = i;
927            i += open_tag.len();
928            while i < bytes.len() && bytes[i].is_ascii_whitespace() {
929                i += 1;
930            }
931            let name_start = i;
932            while i < bytes.len() && is_ident_byte(bytes[i]) {
933                i += 1;
934            }
935            if i > name_start {
936                let call_end = text[i..]
937                    .find(close_tag)
938                    .map(|offset| i + offset + close_tag.len())
939                    .unwrap_or(i);
940                calls.push(PromptToolCall {
941                    name: &text[name_start..i],
942                    text: &text[call_start..call_end],
943                });
944                i = call_end;
945            }
946            continue;
947        }
948
949        if !is_ident_start(bytes[i]) {
950            i += 1;
951            continue;
952        }
953
954        let start = i;
955        i += 1;
956        while i < bytes.len() && is_ident_byte(bytes[i]) {
957            i += 1;
958        }
959
960        let name = &text[start..i];
961        let mut j = i;
962        while j < bytes.len() && bytes[j].is_ascii_whitespace() {
963            j += 1;
964        }
965        if j < bytes.len() && bytes[j] == b'(' && !prompt_ref_stopword(name) {
966            let end = prompt_call_end(bytes, j);
967            calls.push(PromptToolCall {
968                name,
969                text: &text[start..end],
970            });
971            i = end;
972            continue;
973        }
974    }
975    calls
976}
977
978fn prompt_call_end(bytes: &[u8], open_index: usize) -> usize {
979    let mut depth = 0usize;
980    let mut quote = None;
981    let mut escaped = false;
982    let mut i = open_index;
983    while i < bytes.len() {
984        let byte = bytes[i];
985        if let Some(quote_byte) = quote {
986            if escaped {
987                escaped = false;
988            } else if byte == b'\\' {
989                escaped = true;
990            } else if byte == quote_byte {
991                quote = None;
992            }
993            i += 1;
994            continue;
995        }
996
997        match byte {
998            b'\'' | b'"' | b'`' => quote = Some(byte),
999            b'(' => depth += 1,
1000            b')' => {
1001                depth = depth.saturating_sub(1);
1002                if depth == 0 {
1003                    return i + 1;
1004                }
1005            }
1006            _ => {}
1007        }
1008        i += 1;
1009    }
1010    bytes.len()
1011}
1012
1013fn validate_side_effect_ceiling(
1014    policy: Option<&CapabilityPolicy>,
1015    entries: &[ToolEntry],
1016    active_names: &BTreeSet<String>,
1017    diagnostics: &mut Vec<ToolSurfaceDiagnostic>,
1018) {
1019    let Some(policy) = policy else { return };
1020    let Some(ceiling) = policy
1021        .side_effect_level
1022        .as_deref()
1023        .map(SideEffectLevel::parse)
1024    else {
1025        return;
1026    };
1027    for entry in entries
1028        .iter()
1029        .filter(|entry| active_names.contains(entry.name.as_str()))
1030    {
1031        let Some(level) = entry.annotations.as_ref().map(|a| a.side_effect_level) else {
1032            continue;
1033        };
1034        if level.rank() > ceiling.rank() {
1035            diagnostics.push(
1036                ToolSurfaceDiagnostic::error(
1037                    "TOOL_SURFACE_SIDE_EFFECT_CEILING_EXCEEDED",
1038                    format!(
1039                        "tool '{}' requires side-effect level '{}' but policy ceiling is '{}'",
1040                        entry.name,
1041                        level.as_str(),
1042                        ceiling.as_str()
1043                    ),
1044                )
1045                .with_tool(entry.name.clone())
1046                .with_field("side_effect_level"),
1047            );
1048        }
1049    }
1050}
1051
1052pub fn prompt_tool_references(text: &str) -> BTreeSet<String> {
1053    let text = prompt_binding_text(text);
1054    prompt_tool_calls(&text)
1055        .into_iter()
1056        .map(|call| call.name.to_string())
1057        .collect()
1058}
1059
1060fn prompt_binding_text(text: &str) -> String {
1061    let mut out = String::new();
1062    let mut in_fence = false;
1063    let mut ignore_block = false;
1064    let mut ignore_next = false;
1065    for line in text.lines() {
1066        let trimmed = line.trim();
1067        if trimmed.starts_with("```") {
1068            in_fence = !in_fence;
1069            continue;
1070        }
1071        if trimmed.contains("harn-tool-surface: ignore-start") {
1072            ignore_block = true;
1073            continue;
1074        }
1075        if trimmed.contains("harn-tool-surface: ignore-end") {
1076            ignore_block = false;
1077            continue;
1078        }
1079        if trimmed.contains("harn-tool-surface: ignore-next-line") {
1080            ignore_next = true;
1081            continue;
1082        }
1083        if in_fence
1084            || ignore_block
1085            || trimmed.contains("harn-tool-surface: ignore-line")
1086            || trimmed.contains("tool-surface-ignore")
1087        {
1088            continue;
1089        }
1090        if ignore_next {
1091            ignore_next = false;
1092            continue;
1093        }
1094        out.push_str(line);
1095        out.push('\n');
1096    }
1097    out
1098}
1099
1100fn prompt_ref_stopword(name: &str) -> bool {
1101    matches!(
1102        name,
1103        "if" | "for"
1104            | "while"
1105            | "switch"
1106            | "return"
1107            | "function"
1108            | "fn"
1109            | "JSON"
1110            | "print"
1111            | "println"
1112            | "contains"
1113            | "len"
1114            | "render"
1115            | "render_prompt"
1116    )
1117}
1118
1119fn looks_like_tool_name(name: &str) -> bool {
1120    name.contains('_') || name.starts_with("tool") || name.starts_with("run")
1121}
1122
1123fn contains_token(text: &str, needle: &str) -> bool {
1124    let bytes = text.as_bytes();
1125    let needle_bytes = needle.as_bytes();
1126    if needle_bytes.is_empty() || bytes.len() < needle_bytes.len() {
1127        return false;
1128    }
1129    for i in 0..=bytes.len() - needle_bytes.len() {
1130        if &bytes[i..i + needle_bytes.len()] != needle_bytes {
1131            continue;
1132        }
1133        let before_ok = i == 0 || !is_ident_byte(bytes[i - 1]);
1134        let after = i + needle_bytes.len();
1135        let after_ok = after == bytes.len() || !is_ident_byte(bytes[after]);
1136        if before_ok && after_ok {
1137            return true;
1138        }
1139    }
1140    false
1141}
1142
1143fn is_ident_start(byte: u8) -> bool {
1144    byte.is_ascii_alphabetic() || byte == b'_'
1145}
1146
1147fn is_ident_byte(byte: u8) -> bool {
1148    byte.is_ascii_alphanumeric() || byte == b'_'
1149}
1150
1151fn is_tool_registry_like(value: &VmValue) -> bool {
1152    value.as_dict().is_some_and(|dict| {
1153        dict.get("_type")
1154            .is_some_and(|value| value.display() == "tool_registry")
1155            || dict.contains_key("tools")
1156    })
1157}
1158
1159fn vm_parameter_keys(value: Option<&VmValue>) -> (bool, BTreeSet<String>) {
1160    let Some(value) = value else {
1161        return (false, BTreeSet::new());
1162    };
1163    let json = crate::llm::vm_value_to_json(value);
1164    json_parameter_keys(Some(&json))
1165}
1166
1167fn json_parameter_keys(value: Option<&serde_json::Value>) -> (bool, BTreeSet<String>) {
1168    let Some(value) = value else {
1169        return (false, BTreeSet::new());
1170    };
1171    let mut keys = BTreeSet::new();
1172    if let Some(properties) = value.get("properties").and_then(|value| value.as_object()) {
1173        keys.extend(properties.keys().cloned());
1174    } else if let Some(map) = value.as_object() {
1175        for key in map.keys() {
1176            if key != "type" && key != "required" && key != "description" {
1177                keys.insert(key.clone());
1178            }
1179        }
1180    }
1181    (true, keys)
1182}
1183
1184fn workflow_node_tools_as_native(
1185    node: &crate::orchestration::WorkflowNode,
1186) -> Vec<serde_json::Value> {
1187    match &node.tools {
1188        serde_json::Value::Array(items) => items.clone(),
1189        serde_json::Value::Object(_) => vec![node.tools.clone()],
1190        _ => Vec::new(),
1191    }
1192}
1193
1194fn workflow_tools_as_native(
1195    policy: &CapabilityPolicy,
1196    nodes: &BTreeMap<String, crate::orchestration::WorkflowNode>,
1197) -> Vec<serde_json::Value> {
1198    let mut tools = Vec::new();
1199    let mut seen = BTreeSet::new();
1200    for node in nodes.values() {
1201        for tool in workflow_node_tools_as_native(node) {
1202            let name = tool
1203                .get("name")
1204                .and_then(|value| value.as_str())
1205                .unwrap_or("")
1206                .to_string();
1207            if !name.is_empty() && seen.insert(name) {
1208                tools.push(tool);
1209            }
1210        }
1211    }
1212    for (name, annotations) in &policy.tool_annotations {
1213        if seen.insert(name.clone()) {
1214            tools.push(serde_json::json!({
1215                "name": name,
1216                "parameters": {"type": "object"},
1217                "annotations": annotations,
1218                "executor": "host_bridge",
1219            }));
1220        }
1221    }
1222    tools
1223}
1224
1225#[cfg(test)]
1226mod tests {
1227    use super::*;
1228    use crate::orchestration::ToolArgConstraint;
1229    use crate::tool_annotations::ToolArgSchema;
1230
1231    fn execute_annotations() -> ToolAnnotations {
1232        ToolAnnotations {
1233            kind: ToolKind::Execute,
1234            side_effect_level: SideEffectLevel::ProcessExec,
1235            emits_artifacts: true,
1236            ..ToolAnnotations::default()
1237        }
1238    }
1239
1240    #[test]
1241    fn tool_policy_preserves_agent_loop_transport_ceiling() {
1242        let mut annotations = ToolAnnotations {
1243            kind: ToolKind::Search,
1244            side_effect_level: SideEffectLevel::ReadOnly,
1245            ..ToolAnnotations::default()
1246        };
1247        annotations
1248            .capabilities
1249            .insert("workspace".into(), vec!["read_text".into()]);
1250        let policy = tool_capability_policy_from_spec(&serde_json::json!({
1251            "_type": "tool_registry",
1252            "tools": [
1253                {
1254                    "name": "look",
1255                    "parameters": {"type": "object"},
1256                    "policy": annotations
1257                }
1258            ]
1259        }));
1260
1261        assert_eq!(policy.tools, vec!["look".to_string()]);
1262        assert_eq!(policy.side_effect_level.as_deref(), Some("read_only"));
1263        assert!(policy
1264            .capabilities
1265            .get("llm")
1266            .is_some_and(|ops| ops.contains(&"call".to_string())));
1267        assert!(policy
1268            .capabilities
1269            .get("workspace")
1270            .is_some_and(|ops| ops.contains(&"read_text".to_string())));
1271    }
1272
1273    #[test]
1274    fn tool_policy_without_capabilities_keeps_capability_ceiling_unspecified() {
1275        let policy = tool_capability_policy_from_spec(&serde_json::json!({
1276            "_type": "tool_registry",
1277            "tools": [
1278                {
1279                    "name": "look",
1280                    "parameters": {"type": "object"}
1281                }
1282            ]
1283        }));
1284
1285        assert_eq!(policy.tools, vec!["look".to_string()]);
1286        assert!(policy.capabilities.is_empty());
1287        assert!(policy.side_effect_level.is_none());
1288    }
1289
1290    #[test]
1291    fn execute_artifact_tool_requires_reader() {
1292        let mut policy = CapabilityPolicy::default();
1293        policy
1294            .tool_annotations
1295            .insert("run".into(), execute_annotations());
1296        let tools = VmValue::Dict(std::rc::Rc::new(BTreeMap::from([
1297            (
1298                "_type".into(),
1299                VmValue::String(std::rc::Rc::from("tool_registry")),
1300            ),
1301            (
1302                "tools".into(),
1303                VmValue::List(std::rc::Rc::new(vec![VmValue::Dict(std::rc::Rc::new(
1304                    BTreeMap::from([
1305                        ("name".into(), VmValue::String(std::rc::Rc::from("run"))),
1306                        (
1307                            "parameters".into(),
1308                            VmValue::Dict(std::rc::Rc::new(BTreeMap::new())),
1309                        ),
1310                        (
1311                            "executor".into(),
1312                            VmValue::String(std::rc::Rc::from("host_bridge")),
1313                        ),
1314                    ]),
1315                ))])),
1316            ),
1317        ])));
1318        let report = validate_tool_surface(&ToolSurfaceInput {
1319            tools: Some(tools),
1320            policy: Some(policy),
1321            ..ToolSurfaceInput::default()
1322        });
1323        assert!(report.diagnostics.iter().any(|d| {
1324            d.code == "TOOL_SURFACE_MISSING_RESULT_READER"
1325                && d.severity == ToolSurfaceSeverity::Error
1326        }));
1327        assert!(!report.valid);
1328    }
1329
1330    #[test]
1331    fn execute_artifact_tool_accepts_inline_escape_hatch() {
1332        let mut annotations = execute_annotations();
1333        annotations.inline_result = true;
1334        let mut policy = CapabilityPolicy::default();
1335        policy.tool_annotations.insert("run".into(), annotations);
1336        let report = validate_tool_surface(&ToolSurfaceInput {
1337            native_tools: Some(vec![serde_json::json!({
1338                "name": "run",
1339                "parameters": {"type": "object"},
1340            })]),
1341            policy: Some(policy),
1342            ..ToolSurfaceInput::default()
1343        });
1344        assert!(!report
1345            .diagnostics
1346            .iter()
1347            .any(|d| d.code == "TOOL_SURFACE_MISSING_RESULT_READER"));
1348    }
1349
1350    #[test]
1351    fn native_tool_annotations_are_read_from_tool_json() {
1352        let mut annotations = execute_annotations();
1353        annotations.inline_result = true;
1354        let report = validate_tool_surface(&ToolSurfaceInput {
1355            native_tools: Some(vec![serde_json::json!({
1356                "name": "run",
1357                "parameters": {"type": "object"},
1358                "annotations": annotations,
1359            })]),
1360            ..ToolSurfaceInput::default()
1361        });
1362        assert!(!report
1363            .diagnostics
1364            .iter()
1365            .any(|d| d.code == "TOOL_SURFACE_MISSING_ANNOTATIONS"));
1366        assert!(!report
1367            .diagnostics
1368            .iter()
1369            .any(|d| d.code == "TOOL_SURFACE_MISSING_RESULT_READER"));
1370    }
1371
1372    #[test]
1373    fn prompt_reference_outside_policy_is_reported() {
1374        let policy = CapabilityPolicy {
1375            tools: vec!["read_file".into()],
1376            ..CapabilityPolicy::default()
1377        };
1378        let report = validate_tool_surface(&ToolSurfaceInput {
1379            native_tools: Some(vec![
1380                serde_json::json!({"name": "read_file", "parameters": {"type": "object"}}),
1381                serde_json::json!({"name": "run_command", "parameters": {"type": "object"}}),
1382            ]),
1383            policy: Some(policy),
1384            prompt_texts: vec!["Use run_command({command: \"cargo test\"})".into()],
1385            ..ToolSurfaceInput::default()
1386        });
1387        assert!(report
1388            .diagnostics
1389            .iter()
1390            .any(|d| d.code == "TOOL_SURFACE_PROMPT_TOOL_NOT_IN_POLICY"));
1391    }
1392
1393    #[test]
1394    fn approval_rule_tool_references_are_reported() {
1395        let approval_policy: ToolApprovalPolicy = serde_json::from_value(serde_json::json!({
1396            "rules": [
1397                {"ask": {"tool": "missing_tool"}, "reason": "unknown"},
1398                {"allow": {"tool": "read_*"}}
1399            ]
1400        }))
1401        .unwrap();
1402        let report = validate_tool_surface(&ToolSurfaceInput {
1403            native_tools: Some(vec![serde_json::json!({
1404                "name": "read_file",
1405                "parameters": {"type": "object"},
1406            })]),
1407            approval_policy: Some(approval_policy),
1408            ..ToolSurfaceInput::default()
1409        });
1410
1411        assert!(report.diagnostics.iter().any(|d| {
1412            d.code == "TOOL_SURFACE_APPROVAL_PATTERN_NO_MATCH"
1413                && d.field.as_deref() == Some("approval_policy.rules[0].tool")
1414        }));
1415        assert!(!report.diagnostics.iter().any(|d| {
1416            d.code == "TOOL_SURFACE_APPROVAL_PATTERN_NO_MATCH"
1417                && d.field.as_deref() == Some("approval_policy.rules[1].tool")
1418        }));
1419    }
1420
1421    #[test]
1422    fn prompt_suppression_ignores_examples() {
1423        let report = validate_tool_surface(&ToolSurfaceInput {
1424            native_tools: Some(vec![serde_json::json!({
1425                "name": "read_file",
1426                "parameters": {"type": "object"},
1427            })]),
1428            prompt_texts: vec![
1429                "```text\nrun_command({command: \"old\"})\n```\n<!-- harn-tool-surface: ignore-next-line -->\nrun_command({command: \"old\"})".into(),
1430            ],
1431            ..ToolSurfaceInput::default()
1432        });
1433        assert!(!report
1434            .diagnostics
1435            .iter()
1436            .any(|d| d.code == "TOOL_SURFACE_UNKNOWN_PROMPT_TOOL"));
1437    }
1438
1439    #[test]
1440    fn deprecated_alias_warnings_are_scoped_to_matching_tool_calls() {
1441        let mut edit_annotations = ToolAnnotations::default();
1442        edit_annotations
1443            .arg_schema
1444            .arg_aliases
1445            .insert("file".into(), "path".into());
1446        let mut look_annotations = ToolAnnotations::default();
1447        look_annotations
1448            .arg_schema
1449            .arg_aliases
1450            .insert("path".into(), "file".into());
1451
1452        let report = validate_tool_surface(&ToolSurfaceInput {
1453            native_tools: Some(vec![
1454                serde_json::json!({
1455                    "name": "edit",
1456                    "parameters": {"type": "object"},
1457                    "annotations": edit_annotations,
1458                }),
1459                serde_json::json!({
1460                    "name": "look",
1461                    "parameters": {"type": "object"},
1462                    "annotations": look_annotations,
1463                }),
1464            ]),
1465            prompt_texts: vec![
1466                "Use edit({ path: \"src/main.rs\", action: \"replace\" }) before look({ file: \"src/main.rs\" }).".into(),
1467            ],
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 deprecated_alias_warnings_still_report_matching_multiline_calls() {
1479        let mut annotations = ToolAnnotations::default();
1480        annotations
1481            .arg_schema
1482            .arg_aliases
1483            .insert("file".into(), "path".into());
1484
1485        let report = validate_tool_surface(&ToolSurfaceInput {
1486            native_tools: Some(vec![serde_json::json!({
1487                "name": "edit",
1488                "parameters": {"type": "object"},
1489                "annotations": annotations,
1490            })]),
1491            prompt_texts: vec!["Use edit({\n  file: \"src/main.rs\"\n}) once.".into()],
1492            ..ToolSurfaceInput::default()
1493        });
1494
1495        assert!(report
1496            .diagnostics
1497            .iter()
1498            .any(|d| d.code == "TOOL_SURFACE_DEPRECATED_ARG_ALIAS"));
1499    }
1500
1501    #[test]
1502    fn deprecated_alias_warnings_report_tagged_text_mode_calls() {
1503        let mut annotations = ToolAnnotations::default();
1504        annotations
1505            .arg_schema
1506            .arg_aliases
1507            .insert("file".into(), "path".into());
1508
1509        let report = validate_tool_surface(&ToolSurfaceInput {
1510            native_tools: Some(vec![serde_json::json!({
1511                "name": "edit",
1512                "parameters": {"type": "object"},
1513                "annotations": annotations,
1514            })]),
1515            prompt_texts: vec!["<tool_call>\nedit({ file: \"src/main.rs\" })\n</tool_call>".into()],
1516            ..ToolSurfaceInput::default()
1517        });
1518
1519        assert!(report
1520            .diagnostics
1521            .iter()
1522            .any(|d| d.code == "TOOL_SURFACE_DEPRECATED_ARG_ALIAS"));
1523    }
1524
1525    #[test]
1526    fn prompt_reference_scanner_tolerates_non_ascii_text() {
1527        let references = prompt_tool_references("Résumé: use run_command({command: \"test\"})");
1528        assert!(references.contains("run_command"));
1529    }
1530
1531    #[test]
1532    fn prompt_reference_scanner_reads_tagged_text_mode_calls() {
1533        let references =
1534            prompt_tool_references("<tool_call>\nrun({ command: \"cargo test\" })\n</tool_call>");
1535        assert!(references.contains("run"));
1536    }
1537
1538    #[test]
1539    fn arg_constraint_key_must_exist() {
1540        let mut annotations = ToolAnnotations {
1541            kind: ToolKind::Read,
1542            side_effect_level: SideEffectLevel::ReadOnly,
1543            arg_schema: ToolArgSchema {
1544                path_params: vec!["path".into()],
1545                ..ToolArgSchema::default()
1546            },
1547            ..ToolAnnotations::default()
1548        };
1549        annotations.arg_schema.required.push("path".into());
1550        let mut policy = CapabilityPolicy {
1551            tool_arg_constraints: vec![ToolArgConstraint {
1552                tool: "read_file".into(),
1553                arg_key: Some("missing".into()),
1554                arg_patterns: vec!["src/**".into()],
1555            }],
1556            ..CapabilityPolicy::default()
1557        };
1558        policy
1559            .tool_annotations
1560            .insert("read_file".into(), annotations);
1561        let report = validate_tool_surface(&ToolSurfaceInput {
1562            native_tools: Some(vec![serde_json::json!({
1563                "name": "read_file",
1564                "parameters": {"type": "object", "properties": {"path": {"type": "string"}}},
1565            })]),
1566            policy: Some(policy),
1567            ..ToolSurfaceInput::default()
1568        });
1569        assert!(report
1570            .diagnostics
1571            .iter()
1572            .any(|d| d.code == "TOOL_SURFACE_UNKNOWN_ARG_CONSTRAINT_KEY"));
1573    }
1574}