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