afv-library/skills/agentforce-generate/scripts/discover.py

566 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Discover which .agent file targets (flows, apex classes, retrievers) exist in a Salesforce org.
Reads .agent files to extract target: values (flow://, apex://, retriever://),
queries the org via sf CLI, and reports found/missing targets with fuzzy suggestions.
Usage:
python3 scripts/discover.py --agent-file path/to/Agent.agent -o OrgAlias
python3 scripts/discover.py --agent-dir force-app/main/default/aiAuthoringBundles -o OrgAlias
"""
from __future__ import annotations
import argparse
import difflib
import json
import re
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path
@dataclass
class Suggestion:
"""A similar resource found in the org."""
name: str
similarity: float # 0.01.0
@dataclass
class TargetStatus:
"""Status of one .agent target."""
agent_file: str
target: str # e.g. "flow://Get_Order_Status"
target_type: str # "flow", "apex", "retriever"
target_name: str # "Get_Order_Status"
found: bool = False
details: str = ""
suggestions: list[Suggestion] = field(default_factory=list)
@dataclass
class IoMismatch:
"""A mismatch between expected and actual I/O parameters."""
target_name: str
field_name: str
direction: str # "input" or "output"
expected_type: str
actual_type: str
issue: str # "missing", "type_mismatch", "extra"
@dataclass
class DiscoveryReport:
"""Collection of target statuses."""
targets: list[TargetStatus] = field(default_factory=list)
io_mismatches: list[IoMismatch] = field(default_factory=list)
@property
def found(self) -> list[TargetStatus]:
return [t for t in self.targets if t.found]
@property
def missing(self) -> list[TargetStatus]:
return [t for t in self.targets if not t.found]
@property
def all_found(self) -> bool:
return all(t.found for t in self.targets)
def extract_targets(agent_file: Path) -> list[tuple[str, str, str]]:
"""Extract target: values from an .agent file.
Returns list of (target_uri, target_type, target_name) tuples.
e.g. ("flow://Get_Order_Status", "flow", "Get_Order_Status")
"""
content = agent_file.read_text(encoding="utf-8")
targets = []
# Match target: "flow://Name" or target: "apex://Name" or target: "retriever://Name"
for match in re.finditer(r'target:\s*"?(flow|apex|retriever)://([^"\s]+)"?', content):
target_type = match.group(1)
target_name = match.group(2)
target_uri = f"{target_type}://{target_name}"
targets.append((target_uri, target_type, target_name))
return targets
def extract_actions(agent_file: Path) -> list[dict]:
"""Extract action definitions from an .agent file for scaffolding.
Returns list of dicts with keys: name, target, target_type, target_name, inputs, outputs.
"""
content = agent_file.read_text(encoding="utf-8")
actions = []
# Simple regex-based extraction of action blocks
# Matches patterns like:
# action_name:
# ...
# target: "flow://Name"
current_action = None
current_inputs = []
current_outputs = []
in_inputs = False
in_outputs = False
for line in content.splitlines():
stripped = line.strip()
# Detect target line
target_match = re.match(r'target:\s*"?(flow|apex|retriever)://([^"\s]+)"?', stripped)
if target_match and current_action:
current_action["target_type"] = target_match.group(1)
current_action["target_name"] = target_match.group(2)
current_action["target"] = f"{target_match.group(1)}://{target_match.group(2)}"
# Target terminates input/output collection
in_inputs = False
in_outputs = False
continue
# Detect description line (captures action description for classification)
desc_match = re.match(r'description:\s*"(.+)"', stripped)
if desc_match and current_action and "description" not in current_action:
current_action["description"] = desc_match.group(1)
continue
# Detect inputs/outputs sections
if stripped == "inputs:":
in_inputs = True
in_outputs = False
continue
elif stripped == "outputs:":
in_outputs = True
in_inputs = False
continue
# Detect action start (indented name followed by colon)
action_match = re.match(r'^(\t{2,3}|\s{6,12})(\w+):\s*$', line)
if action_match:
# Save previous action
if current_action and current_action.get("target"):
current_action["inputs"] = current_inputs
current_action["outputs"] = current_outputs
actions.append(current_action)
current_action = {"name": action_match.group(2)}
current_inputs = []
current_outputs = []
in_inputs = False
in_outputs = False
continue
# Collect complex_data_type_name for the last collected param
cdt_match = re.match(r'complex_data_type_name:\s*"([^"]+)"', stripped)
if cdt_match:
last_list = current_inputs if in_inputs else (current_outputs if in_outputs else None)
if last_list:
last_list[-1]["complex_data_type_name"] = cdt_match.group(1)
continue
# Collect input/output parameters (match "name: type" on stripped text)
param_match = re.match(r'^(\w+):\s*(string|number|boolean|date|datetime|id|object)\b', stripped)
if param_match:
param = {"name": param_match.group(1), "type": param_match.group(2)}
if in_inputs:
current_inputs.append(param)
elif in_outputs:
current_outputs.append(param)
# Don't forget the last action
if current_action and current_action.get("target"):
current_action["inputs"] = current_inputs
current_action["outputs"] = current_outputs
actions.append(current_action)
return actions
def _query_org(query: str, target_org: str) -> list[dict]:
"""Run SOQL query via sf CLI."""
cmd = ["sf", "data", "query", "--query", query, "-o", target_org, "--json"]
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
if proc.returncode != 0:
return []
data = json.loads(proc.stdout)
return data.get("result", {}).get("records", [])
except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError):
return []
def _check_flows(names: list[str], target_org: str) -> dict[str, bool]:
"""Check which flows exist in the org."""
records = _query_org(
"SELECT ApiName FROM FlowDefinitionView WHERE IsActive = true",
target_org,
)
org_flows = {r["ApiName"] for r in records if "ApiName" in r}
return {name: name in org_flows for name in names}
def _check_apex(names: list[str], target_org: str) -> dict[str, bool]:
"""Check which Apex classes exist in the org."""
records = _query_org(
"SELECT Name FROM ApexClass WHERE Status = 'Active'",
target_org,
)
org_classes = {r["Name"] for r in records if "Name" in r}
return {name: name in org_classes for name in names}
def _check_retrievers(names: list[str], target_org: str) -> dict[str, bool]:
"""Check which retrievers (DataKnowledgeSpace) exist in the org."""
records = _query_org(
"SELECT DeveloperName FROM DataKnowledgeSpace",
target_org,
)
org_retrievers = {r["DeveloperName"] for r in records if "DeveloperName" in r}
return {name: name in org_retrievers for name in names}
def _suggest_similar(name: str, available: list[str], threshold: float = 0.4) -> list[Suggestion]:
"""Find similar names in the org using fuzzy matching."""
suggestions = []
name_lower = name.lower()
name_tokens = set(re.split(r"[_\s]|(?<=[a-z])(?=[A-Z])", name))
for candidate in available:
# Sequence matching
seq_score = difflib.SequenceMatcher(None, name_lower, candidate.lower()).ratio()
# Jaccard keyword overlap
cand_tokens = set(re.split(r"[_\s]|(?<=[a-z])(?=[A-Z])", candidate))
if name_tokens and cand_tokens:
jaccard = len(name_tokens & cand_tokens) / len(name_tokens | cand_tokens)
else:
jaccard = 0.0
score = max(seq_score, jaccard)
if score >= threshold:
suggestions.append(Suggestion(name=candidate, similarity=round(score, 2)))
return sorted(suggestions, key=lambda s: s.similarity, reverse=True)[:3]
def _rest_api_get(path: str, target_org: str) -> dict | None:
"""Call a Salesforce REST API endpoint via sf CLI."""
cmd = ["sf", "org", "open", "-o", target_org, "--json", "--url-only"]
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if proc.returncode != 0:
return None
data = json.loads(proc.stdout)
instance_url = data.get("result", {}).get("url", "")
# Extract base URL (everything before /secur/ or similar)
import urllib.parse
parsed = urllib.parse.urlparse(instance_url)
base = f"{parsed.scheme}://{parsed.netloc}"
except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError):
return None
# Use sf api to call REST endpoint
cmd = ["sf", "api", "request", "rest", path, "-o", target_org, "--json"]
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
if proc.returncode != 0:
return None
return json.loads(proc.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError):
return None
# Flow type to .agent type mapping for validation
_FLOW_TYPE_TO_AGENT = {
"STRING": "string",
"NUMBER": "number",
"CURRENCY": "number",
"BOOLEAN": "boolean",
"DATE": "date",
"DATETIME": "datetime",
}
def validate_action_io(
target_type: str,
target_name: str,
expected_inputs: list[dict],
expected_outputs: list[dict],
target_org: str,
) -> list[IoMismatch]:
"""Validate that an existing target's I/O matches what the .agent file declares.
For flows, queries the Actions REST API to get actual parameter schema.
For apex, queries the Tooling API for @InvocableVariable fields.
Args:
target_type: "flow" or "apex".
target_name: The API name of the target.
expected_inputs: Input params declared in the .agent file.
expected_outputs: Output params declared in the .agent file.
target_org: Salesforce org alias.
Returns:
List of IoMismatch entries (empty if everything matches).
"""
mismatches = []
if target_type == "flow":
mismatches = _validate_flow_io(target_name, expected_inputs, expected_outputs, target_org)
elif target_type == "apex":
mismatches = _validate_apex_io(target_name, expected_inputs, expected_outputs, target_org)
return mismatches
def _validate_flow_io(
flow_name: str,
expected_inputs: list[dict],
expected_outputs: list[dict],
target_org: str,
) -> list[IoMismatch]:
"""Validate flow I/O via the custom/flow REST API."""
mismatches = []
api_path = f"/services/data/v{66}.0/actions/custom/flow/{flow_name}"
result = _rest_api_get(api_path, target_org)
if not result:
return mismatches
# Parse actual inputs/outputs from the REST response
actual_inputs = {}
actual_outputs = {}
for param in result.get("result", result).get("inputs", []):
name = param.get("name", "")
ptype = param.get("type", "").upper()
actual_inputs[name] = _FLOW_TYPE_TO_AGENT.get(ptype, "string")
for param in result.get("result", result).get("outputs", []):
name = param.get("name", "")
ptype = param.get("type", "").upper()
actual_outputs[name] = _FLOW_TYPE_TO_AGENT.get(ptype, "string")
# Check expected inputs exist
for inp in expected_inputs:
name = inp["name"]
if name not in actual_inputs:
mismatches.append(IoMismatch(
target_name=flow_name, field_name=name, direction="input",
expected_type=inp.get("type", "string"), actual_type="",
issue="missing",
))
elif actual_inputs[name] != inp.get("type", "string"):
mismatches.append(IoMismatch(
target_name=flow_name, field_name=name, direction="input",
expected_type=inp.get("type", "string"), actual_type=actual_inputs[name],
issue="type_mismatch",
))
# Check expected outputs exist
for out in expected_outputs:
name = out["name"]
if name not in actual_outputs:
mismatches.append(IoMismatch(
target_name=flow_name, field_name=name, direction="output",
expected_type=out.get("type", "string"), actual_type="",
issue="missing",
))
return mismatches
def _validate_apex_io(
class_name: str,
expected_inputs: list[dict],
expected_outputs: list[dict],
target_org: str,
) -> list[IoMismatch]:
"""Validate Apex I/O by querying the class body for @InvocableVariable fields."""
mismatches = []
records = _query_org(
f"SELECT Body FROM ApexClass WHERE Name = '{class_name}' LIMIT 1",
target_org,
)
if not records:
return mismatches
body = records[0].get("Body", "")
# Extract @InvocableVariable field names from Request/Response classes
# Simple regex: find field declarations after @InvocableVariable
field_pattern = re.compile(r'@InvocableVariable[^;]*\n\s*public\s+\w+\s+(\w+)\s*;')
actual_fields = set(field_pattern.findall(body))
for inp in expected_inputs:
if inp["name"] not in actual_fields:
mismatches.append(IoMismatch(
target_name=class_name, field_name=inp["name"], direction="input",
expected_type=inp.get("type", "string"), actual_type="",
issue="missing",
))
for out in expected_outputs:
if out["name"] not in actual_fields:
mismatches.append(IoMismatch(
target_name=class_name, field_name=out["name"], direction="output",
expected_type=out.get("type", "string"), actual_type="",
issue="missing",
))
return mismatches
def discover(agent_file: Path, target_org: str, validate_io: bool = False) -> DiscoveryReport:
"""Run discovery for a single .agent file.
Args:
agent_file: Path to the .agent file.
target_org: Salesforce org alias.
validate_io: If True, validate I/O parameters for found targets.
"""
report = DiscoveryReport()
raw_targets = extract_targets(agent_file)
if not raw_targets:
return report
# Deduplicate targets (same action can appear in multiple topics)
seen_uris = set()
unique_targets = []
for uri, ttype, tname in raw_targets:
if uri not in seen_uris:
seen_uris.add(uri)
unique_targets.append((uri, ttype, tname))
raw_targets = unique_targets
# Group by type
by_type: dict[str, list[tuple[str, str]]] = {"flow": [], "apex": [], "retriever": []}
for uri, ttype, tname in raw_targets:
by_type.setdefault(ttype, []).append((uri, tname))
# Check each type
checkers = {
"flow": (_check_flows, "SELECT ApiName FROM FlowDefinitionView WHERE IsActive = true", "ApiName"),
"apex": (_check_apex, "SELECT Name FROM ApexClass WHERE Status = 'Active'", "Name"),
"retriever": (_check_retrievers, "SELECT DeveloperName FROM DataKnowledgeSpace", "DeveloperName"),
}
for ttype, targets in by_type.items():
if not targets:
continue
checker_fn, query, field_name = checkers[ttype]
names = [t[1] for t in targets]
found_map = checker_fn(names, target_org)
# Get all available resources for fuzzy matching
all_records = _query_org(query, target_org)
available = [r[field_name] for r in all_records if field_name in r]
for uri, name in targets:
status = TargetStatus(
agent_file=str(agent_file),
target=uri,
target_type=ttype,
target_name=name,
found=found_map.get(name, False),
)
if not status.found:
status.suggestions = _suggest_similar(name, available)
report.targets.append(status)
# Validate I/O for found targets
if validate_io:
actions = {a["target_name"]: a for a in extract_actions(agent_file) if a.get("target_name")}
for target in report.found:
if target.target_type in ("flow", "apex") and target.target_name in actions:
action_def = actions[target.target_name]
mismatches = validate_action_io(
target.target_type,
target.target_name,
action_def.get("inputs", []),
action_def.get("outputs", []),
target_org,
)
report.io_mismatches.extend(mismatches)
return report
def discover_dir(agent_dir: Path, target_org: str, validate_io: bool = False) -> DiscoveryReport:
"""Run discovery for all .agent files in a directory."""
combined = DiscoveryReport()
for agent_file in sorted(agent_dir.rglob("*.agent")):
sub_report = discover(agent_file, target_org, validate_io=validate_io)
combined.targets.extend(sub_report.targets)
return combined
def print_report(report: DiscoveryReport) -> None:
"""Print a human-readable discovery report."""
if not report.targets:
print("No targets found in .agent file(s).")
return
print(f"\n{'=' * 60}")
print(f"Discovery Report: {len(report.targets)} target(s)")
print(f"{'=' * 60}")
# Found targets
if report.found:
print(f"\n✅ Found ({len(report.found)}):")
for t in report.found:
print(f" {t.target}")
# Missing targets
if report.missing:
print(f"\n❌ Missing ({len(report.missing)}):")
for t in report.missing:
print(f" {t.target}")
for s in t.suggestions:
print(f" 💡 Did you mean: {s.name} ({s.similarity:.0%} match)?")
# I/O mismatches
if report.io_mismatches:
print(f"\n⚠️ I/O Mismatches ({len(report.io_mismatches)}):")
for m in report.io_mismatches:
if m.issue == "missing":
print(f" {m.target_name}: {m.direction} '{m.field_name}' not found in org target")
elif m.issue == "type_mismatch":
print(f" {m.target_name}: {m.direction} '{m.field_name}' type mismatch — expected {m.expected_type}, got {m.actual_type}")
print(f"\n{'=' * 60}")
if report.all_found:
print("All targets found in org.")
else:
print(f"{len(report.missing)} target(s) missing. Run /adlc-scaffold to generate stubs.")
print()
def main():
parser = argparse.ArgumentParser(description="Discover .agent file targets in a Salesforce org")
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--agent-file", type=Path, help="Path to a single .agent file")
group.add_argument("--agent-dir", type=Path, help="Directory containing .agent files")
parser.add_argument("-o", "--target-org", required=True, help="Salesforce org alias")
parser.add_argument("--validate-io", action="store_true", help="Validate I/O parameters for found targets")
args = parser.parse_args()
if args.agent_file:
if not args.agent_file.exists():
print(f"Error: {args.agent_file} not found", file=sys.stderr)
sys.exit(1)
report = discover(args.agent_file, args.target_org, validate_io=args.validate_io)
else:
if not args.agent_dir.exists():
print(f"Error: {args.agent_dir} not found", file=sys.stderr)
sys.exit(1)
report = discover_dir(args.agent_dir, args.target_org, validate_io=args.validate_io)
print_report(report)
# Exit with non-zero if any targets are missing
sys.exit(0 if report.all_found else 1)
if __name__ == "__main__":
main()