#!/usr/bin/env python3 """PostToolUse hook: validate .agent files for syntax errors after Write/Edit. Checks: 1. Mixed tabs and spaces within a single file (compilation error) 2. Lowercase booleans (must be True/False) 3. Required blocks (system, config, start_agent) 4. Config fields: developer_name (preferred over agent_name), default_agent_user. 5. Variables declared as both mutable AND linked 6. Undefined topic references in transitions 7. start_agent target references a defined topic 8. developer_name matches folder name 9. Reserved field names used as variable names 10. @inputs in set clauses (must use @outputs) 11. bundle-meta.xml extra fields that break publish 12. `default:` sub-property on variables (must use inline `= value`) 13. `type:` sub-property on action I/O fields (must use inline type) 14. Linked variable source using `$Context` instead of `@MessagingSession`/`@MessagingEndUser` 15. Invalid `connection:` block (must be `connection messaging:`) 16. Nested `description:` under slot-fill `...` token 17. Redundant routing/menu topics that duplicate start_agent Safety/content review is handled by the /adlc-safety skill (LLM-driven, not regex). Also auto-resolves REPLACE_WITH_EINSTEIN_AGENT_USER placeholder by querying the org. """ import json import os import re import subprocess import sys from pathlib import Path try: from stdin_utils import read_stdin_safe except ImportError: def read_stdin_safe(timeout_seconds=0.1): if sys.stdin.isatty(): return {} try: return json.load(sys.stdin) except Exception: return {} # Reserved field names that cause parse errors RESERVED_NAMES = { "description", "label", "is_required", "is_displayable", "default", "name", "type", "source", "visibility", } class AgentScriptValidator: """Validates .agent file syntax and structure.""" def __init__(self, file_path: str, content: str): self.file_path = file_path self.content = content self.lines = content.splitlines() self.errors: list[tuple[int, str, str]] = [] # (line_num, severity, message) self.warnings: list[tuple[int, str, str]] = [] def validate(self) -> dict: """Run all validations and return result dict.""" self._check_mixed_indentation() self._check_boolean_case() self._check_required_blocks() self._check_config_fields() self._check_variable_modifiers() self._check_topic_references() self._check_start_agent_target() self._check_folder_name_match() self._check_reserved_field_names() self._check_inputs_in_set() self._check_bundle_meta_xml() self._check_default_subproperty() self._check_type_subproperty() self._check_numeric_action_io() self._check_linked_var_source() self._check_connection_block() self._check_slot_fill_description() self._check_apex_target_shared_class() self._check_redundant_routing_topic() self._auto_resolve_placeholder() return { "success": len(self.errors) == 0, "errors": self.errors, "warnings": self.warnings, "file_path": self.file_path, } def _check_mixed_indentation(self): """Check for mixed tabs and spaces within a single file (compilation error). The Agent Script compiler (`sf agent validate authoring-bundle`) accepts either tabs OR spaces for indentation, but mixing both in the same file causes parse errors. Space-only and tab-only files both compile cleanly. """ has_tabs = False has_spaces = False for i, line in enumerate(self.lines, 1): if line.startswith("\t"): has_tabs = True elif line.startswith(" ") and line.strip(): has_spaces = True if has_tabs and has_spaces: self.errors.append((0, "ERROR", "Mixed tabs and spaces — pick one style per file (Agent Script accepts either, but not both)")) def _check_boolean_case(self): """Check for lowercase booleans (must be True/False).""" for i, line in enumerate(self.lines, 1): stripped = line.strip() # Skip comments and strings if stripped.startswith("#") or stripped.startswith("//"): continue # Check for lowercase true/false outside of quoted strings. # Booleans inside quoted values (JSON/SOQL examples, prose) are data, # not Agent Script literals, so strip quoted substrings before matching. unquoted = re.sub(r'"[^"]*"|\'[^\']*\'', "", stripped) # Match: = true, = false, : true, : false if re.search(r'[=:]\s*\btrue\b', unquoted): self.errors.append((i, "ERROR", f"Lowercase 'true' — use 'True' (line {i})")) if re.search(r'[=:]\s*\bfalse\b', unquoted): self.errors.append((i, "ERROR", f"Lowercase 'false' — use 'False' (line {i})")) def _check_required_blocks(self): """Check for required top-level blocks.""" required = {"system": False, "config": False, "start_agent": False} has_topic = False for line in self.lines: stripped = line.strip() if stripped.startswith("system:"): required["system"] = True elif stripped.startswith("config:"): required["config"] = True elif stripped.startswith("start_agent ") or stripped.startswith("start_agent:"): required["start_agent"] = True elif stripped.startswith("topic "): has_topic = True for block, found in required.items(): if not found: self.errors.append((0, "ERROR", f"Missing required block: {block}")) def _check_config_fields(self): """Check config block for required fields.""" in_config = False config_fields = set() for line in self.lines: stripped = line.strip() if stripped.startswith("config:"): in_config = True continue if in_config: if stripped and not stripped.startswith("#") and not line.startswith(("\t", " ")): in_config = False continue field_match = re.match(r'(\w+):', stripped) if field_match: config_fields.add(field_match.group(1)) # developer_name is preferred; agent_name is accepted as legacy if "developer_name" not in config_fields: if "agent_name" in config_fields: self.warnings.append((0, "WARN", "Config uses 'agent_name' — prefer 'developer_name' (must match folder name)")) else: self.warnings.append((0, "WARN", "Missing config field: developer_name")) if "default_agent_user" not in config_fields: self.warnings.append((0, "WARN", "Missing config field: default_agent_user")) def _check_variable_modifiers(self): """Check that variables aren't declared as both mutable AND linked.""" for i, line in enumerate(self.lines, 1): if "mutable" in line and "linked" in line: self.errors.append((i, "ERROR", f"Variable declared as both mutable AND linked (line {i})")) def _check_topic_references(self): """Check that @topic.X references resolve to defined topics.""" # Collect defined topics defined_topics = set() for line in self.lines: match = re.match(r'^(?:start_agent|topic)\s+(\w+):', line.strip()) if match: defined_topics.add(match.group(1)) # Check references for i, line in enumerate(self.lines, 1): for ref_match in re.finditer(r'@topic\.(\w+)', line): topic_name = ref_match.group(1) if topic_name not in defined_topics: self.warnings.append((i, "WARN", f"Undefined topic reference: @topic.{topic_name} (line {i})")) def _check_start_agent_target(self): """Check that start_agent references a defined topic. Two valid syntaxes: - `start_agent: topic_name` → references a separate topic (must exist) - `start_agent name:` → inline entry block definition (no separate topic needed) """ start_target = None start_line = 0 is_inline = False for i, line in enumerate(self.lines, 1): stripped = line.strip() # `start_agent: topic_name` — reference to a separate topic ref_match = re.match(r'^start_agent\s*:\s*(\w+)\s*$', stripped) if ref_match: start_target = ref_match.group(1) start_line = i is_inline = False break # `start_agent name:` — inline entry block definition inline_match = re.match(r'^start_agent\s+(\w+)\s*:', stripped) if inline_match: start_target = inline_match.group(1) start_line = i is_inline = True break if not start_target: return # Missing start_agent caught by _check_required_blocks # Inline definitions don't need a separate topic if is_inline: return # Reference-style must point to a defined topic defined_topics = set() for line in self.lines: match = re.match(r'^topic\s+(\w+):', line.strip()) if match: defined_topics.add(match.group(1)) if start_target not in defined_topics: self.errors.append((start_line, "ERROR", f"start_agent references '{start_target}' but no 'topic {start_target}:' is defined (line {start_line})")) def _check_folder_name_match(self): """Check that developer_name (or agent_name) matches the folder name.""" # Extract developer_name or agent_name from config agent_name = None field_used = None for line in self.lines: match = re.match(r'\s*developer_name:\s*"?([^"\s]+)"?', line.strip()) if match: agent_name = match.group(1) field_used = "developer_name" break match = re.match(r'\s*agent_name:\s*"?([^"\s]+)"?', line.strip()) if match: agent_name = match.group(1) field_used = "agent_name" break if agent_name: folder_name = Path(self.file_path).parent.name if folder_name != agent_name: self.warnings.append((0, "WARN", f"{field_used} '{agent_name}' doesn't match folder name '{folder_name}'")) def _check_reserved_field_names(self): """Check for reserved field names used as variable or action parameter names.""" in_variables = False for i, line in enumerate(self.lines, 1): stripped = line.strip() if stripped.startswith("variables:"): in_variables = True continue if in_variables and stripped and not line.startswith(("\t", " ")): in_variables = False if in_variables: var_match = re.match(r'(\w+):\s*(?:mutable|linked)', stripped) if var_match and var_match.group(1) in RESERVED_NAMES: self.errors.append((i, "ERROR", f"Reserved field name '{var_match.group(1)}' used as variable name (line {i})")) def _check_inputs_in_set(self): """Check for @inputs in set clauses (must use @outputs instead).""" for i, line in enumerate(self.lines, 1): stripped = line.strip() if stripped.startswith("#"): continue if re.search(r'\bset\b.*@inputs\.', stripped): self.errors.append((i, "ERROR", f"'@inputs' in set clause — use '@outputs' instead (line {i})")) def _check_bundle_meta_xml(self): """Check companion bundle-meta.xml for extra fields that break publish.""" meta_path = Path(self.file_path).with_suffix(".bundle-meta.xml") if not meta_path.exists(): # Also check parent dir for .bundle-meta.xml parent = Path(self.file_path).parent agent_stem = Path(self.file_path).stem meta_path = parent / f"{agent_stem}.bundle-meta.xml" if not meta_path.exists(): return try: meta_content = meta_path.read_text(encoding="utf-8") except (IOError, OSError): return # Check for fields that cause "Required fields missing: [BundleType]" on publish bad_fields = [] for field in ["", "", "", "