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