mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-10-05 06:36:55 +01:00
Merge main into Finance knowledge domain
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
commit
639b6d1870
745 changed files with 18699 additions and 2548 deletions
18
.claude-plugin/marketplace.json
Normal file
18
.claude-plugin/marketplace.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"name": "bcquality",
|
||||
"owner": {
|
||||
"name": "microsoft/BCQuality",
|
||||
"url": "https://github.com/microsoft/BCQuality"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "bcquality",
|
||||
"source": "./",
|
||||
"description": "Business Central AL quality knowledge base and review skills, packaged as an installable plugin. Exposes an AL review adapter while preserving BCQuality's internal Entry and action-skill protocols.",
|
||||
"version": "0.2.0",
|
||||
"skills": [
|
||||
"./skills/"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
25
.github/custom-layer-autoclose.md
vendored
Normal file
25
.github/custom-layer-autoclose.md
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
Thank you for contributing, @{{AUTHOR}}.
|
||||
|
||||
This PR was closed automatically because it changes custom-layer content.
|
||||
`custom/` is reserved for organization-specific knowledge and skills in
|
||||
**your own fork**, not the shared upstream repository.
|
||||
|
||||
Keep company-only rules in your fork and point your host at that copy. The
|
||||
[customization guide](https://github.com/microsoft/BCQuality/blob/main/docs/customizing-bcquality.md)
|
||||
shows the complete flow.
|
||||
|
||||
If the guidance is useful to everyone, submit it to the layer that owns the
|
||||
domain: Microsoft-owned domains belong under `microsoft/knowledge/`, even
|
||||
when contributed by a partner; Community-owned domains belong under
|
||||
`community/knowledge/`. See
|
||||
[Contributing](https://github.com/microsoft/BCQuality/blob/main/docs/contributing.md).
|
||||
BCQuality contains knowledge and skills, not agents.
|
||||
|
||||
<details>
|
||||
<summary>Files in this PR that triggered the auto-close</summary>
|
||||
|
||||
{{FILES}}
|
||||
</details>
|
||||
|
||||
Template changes to `custom/README.md` and `.gitkeep` files are allowed. If
|
||||
you believe this closure was a mistake, comment here for maintainer review.
|
||||
11
.github/dependabot.yml
vendored
Normal file
11
.github/dependabot.yml
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
groups:
|
||||
github-actions:
|
||||
patterns: ["*"]
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
10
.github/new-top-level-flag.md
vendored
Normal file
10
.github/new-top-level-flag.md
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<!-- guard:new-top-level -->
|
||||
👋 Heads up @{{AUTHOR}} — and cc maintainers — this PR introduces **new top-level entries** that aren't part of BCQuality's known repository structure:
|
||||
|
||||
{{ENTRIES}}
|
||||
|
||||
This isn't a block — just a flag. 🚩 New top-level folders and files are *usually* unintended (a stray export, a tool's scratch dir, or content that meant to land inside an existing layer). BCQuality keeps a deliberately small root: plugin metadata, `community/`, `custom/`, `microsoft/`, `skills/`, `tools/`, `docs/`, `evaluation/`, `.github/`, and a handful of root docs. Partner guides belong under `docs/`; shared knowledge belongs beside the skill that owns its domain.
|
||||
|
||||
**If this was intentional** and the new entry genuinely belongs at the repo root, a maintainer can review and merge as normal — no action needed beyond a quick sanity check. **If it wasn't**, please move the content into the right existing layer (or drop it) and push an update. 🙏
|
||||
|
||||
A maintainer will take a look before merging.
|
||||
3
.github/scripts/Test-KnowledgeIndex.ps1
vendored
3
.github/scripts/Test-KnowledgeIndex.ps1
vendored
|
|
@ -16,6 +16,8 @@
|
|||
3. Selection-input integrity — every parsed article row carries the
|
||||
non-empty `domain` + `keywords` the worklist predicate selects on, and
|
||||
every article parses (an unparseable article is an invalid file).
|
||||
4. Bounded retrieval — delegates to tools/Test-KnowledgeRetrieval.ps1 for
|
||||
lossless paging, exact-body round trips, and explicit failure cases.
|
||||
|
||||
Exit code 0 = healthy; non-zero = a problem CI must block on.
|
||||
#>
|
||||
|
|
@ -90,4 +92,5 @@ if ($problems.Count) {
|
|||
exit 1
|
||||
}
|
||||
Write-Host "Knowledge-index check PASSED: $($rows.Count) articles, deterministic, full coverage, selection inputs intact." -ForegroundColor Green
|
||||
& (Join-Path $Root 'tools/Test-KnowledgeRetrieval.ps1') -Root $Root
|
||||
exit 0
|
||||
|
|
|
|||
241
.github/scripts/Test-SkillIndex.ps1
vendored
Normal file
241
.github/scripts/Test-SkillIndex.ps1
vendored
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
<#
|
||||
.SYNOPSIS
|
||||
Validates the BCQuality action-skill index generator and shared schemas.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string] $Root = (Resolve-Path (Join-Path -Path $PSScriptRoot -ChildPath '..' '..'))
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$Root = (Resolve-Path -LiteralPath $Root).Path
|
||||
|
||||
function Assert-ThrowsLike {
|
||||
param(
|
||||
[scriptblock] $Action,
|
||||
[string] $Pattern
|
||||
)
|
||||
|
||||
try {
|
||||
& $Action
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message -like $Pattern) {
|
||||
return
|
||||
}
|
||||
throw "Expected error like '$Pattern', received: $($_.Exception.Message)"
|
||||
}
|
||||
throw "Expected error like '$Pattern', but no error was thrown."
|
||||
}
|
||||
|
||||
$generator = Join-Path $Root 'tools/Build-SkillIndex.ps1'
|
||||
$indexSchema = Join-Path $Root 'schemas/skill-index.schema.json'
|
||||
$reportSchema = Join-Path $Root 'schemas/findings-report.schema.json'
|
||||
foreach ($path in $generator, $indexSchema, $reportSchema) {
|
||||
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
|
||||
throw "Required contract file not found: $path"
|
||||
}
|
||||
}
|
||||
|
||||
$tmp = Join-Path ([IO.Path]::GetTempPath()) ("skillindex_" + [guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory -Path $tmp -Force | Out-Null
|
||||
try {
|
||||
$first = Join-Path $tmp 'first.json'
|
||||
$second = Join-Path $tmp 'second.json'
|
||||
& $generator -BCQualityRoot $Root -IndexPath $first | Out-Null
|
||||
& $generator -BCQualityRoot $Root -IndexPath $second | Out-Null
|
||||
|
||||
$normalize = {
|
||||
param([string] $Path)
|
||||
return ((Get-Content -LiteralPath $Path -Raw) -replace '"generatedAt":"[^"]*"', '"generatedAt":"<timestamp>"')
|
||||
}
|
||||
if ((& $normalize $first) -ne (& $normalize $second)) {
|
||||
throw 'Skill index is not deterministic beyond generatedAt.'
|
||||
}
|
||||
|
||||
$raw = Get-Content -LiteralPath $first -Raw
|
||||
if (-not ($raw | Test-Json -SchemaFile $indexSchema -ErrorAction Stop)) {
|
||||
throw 'Generated skill index does not satisfy schemas/skill-index.schema.json.'
|
||||
}
|
||||
|
||||
$index = $raw | ConvertFrom-Json
|
||||
$skills = @($index.skills)
|
||||
if ($index.skillCount -ne $skills.Count) {
|
||||
throw "skillCount is $($index.skillCount), but the index contains $($skills.Count) records."
|
||||
}
|
||||
|
||||
$paths = @($skills.path)
|
||||
$duplicates = @($paths | Group-Object | Where-Object Count -gt 1)
|
||||
if ($duplicates.Count) {
|
||||
throw "Duplicate skill paths: $($duplicates.Name -join ', ')"
|
||||
}
|
||||
foreach ($path in $paths) {
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $Root $path) -PathType Leaf)) {
|
||||
throw "Indexed skill does not exist: $path"
|
||||
}
|
||||
}
|
||||
|
||||
$expectedLeaves = @(
|
||||
'microsoft/skills/review/al-performance-review.md',
|
||||
'microsoft/skills/review/al-security-review.md',
|
||||
'microsoft/skills/review/al-privacy-review.md',
|
||||
'microsoft/skills/review/al-upgrade-review.md',
|
||||
'microsoft/skills/review/al-style-review.md',
|
||||
'microsoft/skills/review/al-ui-review.md',
|
||||
'microsoft/skills/review/al-error-handling-review.md',
|
||||
'microsoft/skills/review/al-events-review.md',
|
||||
'microsoft/skills/review/al-interfaces-review.md',
|
||||
'microsoft/skills/review/al-breaking-changes-review.md',
|
||||
'microsoft/skills/review/al-web-services-review.md',
|
||||
'microsoft/skills/review/al-testing-review.md',
|
||||
'microsoft/skills/review/al-data-modeling-review.md',
|
||||
'microsoft/skills/review/al-query-review.md',
|
||||
'microsoft/skills/review/al-reporting-review.md',
|
||||
'microsoft/skills/review/al-appsource-review.md',
|
||||
'microsoft/skills/review/al-telemetry-review.md'
|
||||
)
|
||||
$review = @($skills | Where-Object id -eq 'al-code-review')
|
||||
if ($review.Count -ne 1) {
|
||||
throw "Expected exactly one al-code-review record, found $($review.Count)."
|
||||
}
|
||||
if ((@($review[0].subSkills) -join "`n") -cne ($expectedLeaves -join "`n")) {
|
||||
throw 'al-code-review subSkills did not preserve the declared 17-leaf order.'
|
||||
}
|
||||
foreach ($leafPath in $expectedLeaves) {
|
||||
$leaf = @($skills | Where-Object path -ceq $leafPath)
|
||||
if ($leaf.Count -ne 1 -or @($leaf[0].subSkills).Count -ne 0) {
|
||||
throw "Expected '$leafPath' to resolve to exactly one leaf action skill."
|
||||
}
|
||||
}
|
||||
|
||||
$minimalReport = @{
|
||||
skill = @{ id = 'al-style-review'; version = 1 }
|
||||
outcome = 'completed'
|
||||
summary = @{
|
||||
counts = @{ blocker = 0; major = 0; minor = 0; info = 0 }
|
||||
coverage = @{ 'worklist-size' = 0; 'items-evaluated' = 0 }
|
||||
}
|
||||
findings = @()
|
||||
suppressed = @()
|
||||
} | ConvertTo-Json -Depth 8
|
||||
if (-not ($minimalReport | Test-Json -SchemaFile $reportSchema -ErrorAction Stop)) {
|
||||
throw 'Minimal findings report does not satisfy schemas/findings-report.schema.json.'
|
||||
}
|
||||
|
||||
$reviewSkillText = Get-Content -LiteralPath (
|
||||
Join-Path -Path $Root -ChildPath 'microsoft/skills/review/al-code-review.md'
|
||||
) -Raw
|
||||
$reportExamples = [regex]::Matches($reviewSkillText, '(?s)```json\s*(\{.*?\})\s*```')
|
||||
if ($reportExamples.Count -ne 2) {
|
||||
throw "Expected two al-code-review JSON examples, found $($reportExamples.Count)."
|
||||
}
|
||||
foreach ($example in $reportExamples) {
|
||||
if (-not ($example.Groups[1].Value | Test-Json -SchemaFile $reportSchema -ErrorAction Stop)) {
|
||||
throw 'An al-code-review output example does not satisfy schemas/findings-report.schema.json.'
|
||||
}
|
||||
}
|
||||
|
||||
$fixtureRoot = Join-Path -Path $tmp -ChildPath 'fixture'
|
||||
$fixtureSkills = Join-Path -Path $fixtureRoot -ChildPath 'microsoft/skills/review'
|
||||
New-Item -ItemType Directory -Path $fixtureSkills -Force | Out-Null
|
||||
$leaf = @'
|
||||
---
|
||||
kind: action-skill
|
||||
id: al-leaf-review
|
||||
version: 1
|
||||
title: Leaf
|
||||
description: Test leaf.
|
||||
inputs: [file-path]
|
||||
outputs: [findings-report]
|
||||
---
|
||||
|
||||
# Leaf
|
||||
|
||||
## Source
|
||||
Source.
|
||||
## Relevance
|
||||
Relevance.
|
||||
## Worklist
|
||||
Worklist.
|
||||
## Action
|
||||
Action.
|
||||
## Output
|
||||
Output.
|
||||
'@
|
||||
Set-Content -LiteralPath (Join-Path $fixtureSkills 'al-leaf-review.md') -Value $leaf -Encoding utf8NoBOM
|
||||
|
||||
$duplicateSuper = @'
|
||||
---
|
||||
kind: action-skill
|
||||
id: al-code-review
|
||||
version: 1
|
||||
title: Review
|
||||
description: Test super-skill.
|
||||
inputs: [file-path]
|
||||
outputs: [findings-report]
|
||||
sub-skills:
|
||||
- microsoft/skills/review/al-leaf-review.md
|
||||
- microsoft/skills/review/al-leaf-review.md
|
||||
---
|
||||
|
||||
# Review
|
||||
|
||||
## Source
|
||||
Source.
|
||||
## Relevance
|
||||
Relevance.
|
||||
## Worklist
|
||||
Worklist.
|
||||
## Action
|
||||
Action.
|
||||
## Output
|
||||
Output.
|
||||
'@
|
||||
$superPath = Join-Path $fixtureSkills 'al-code-review.md'
|
||||
Set-Content -LiteralPath $superPath -Value $duplicateSuper -Encoding utf8NoBOM
|
||||
Assert-ThrowsLike -Pattern '*duplicate sub-skill*' -Action {
|
||||
& $generator -BCQualityRoot $fixtureRoot -IndexPath (Join-Path $tmp 'invalid.json')
|
||||
}
|
||||
|
||||
$nestedLeaf = $leaf.Replace('id: al-leaf-review', 'id: al-nested-review').Replace(
|
||||
'outputs: [findings-report]',
|
||||
"outputs: [findings-report]`nsub-skills:`n - microsoft/skills/review/al-leaf-review.md"
|
||||
)
|
||||
Set-Content -LiteralPath (Join-Path $fixtureSkills 'al-nested-review.md') -Value $nestedLeaf -Encoding utf8NoBOM
|
||||
$nestedSuper = @'
|
||||
---
|
||||
kind: action-skill
|
||||
id: al-code-review
|
||||
version: 1
|
||||
title: Review
|
||||
description: Test super-skill.
|
||||
inputs: [file-path]
|
||||
outputs: [findings-report]
|
||||
sub-skills:
|
||||
- microsoft/skills/review/al-nested-review.md
|
||||
---
|
||||
|
||||
# Review
|
||||
|
||||
## Source
|
||||
Source.
|
||||
## Relevance
|
||||
Relevance.
|
||||
## Worklist
|
||||
Worklist.
|
||||
## Action
|
||||
Action.
|
||||
## Output
|
||||
Output.
|
||||
'@
|
||||
Set-Content -LiteralPath $superPath -Value $nestedSuper -Encoding utf8NoBOM
|
||||
Assert-ThrowsLike -Pattern '*Nested super-skills are not supported*' -Action {
|
||||
& $generator -BCQualityRoot $fixtureRoot -IndexPath (Join-Path $tmp 'nested.json')
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Write-Output 'Skill-index check PASSED: deterministic, schema-valid, and all 17 review leaves preserved in order.'
|
||||
110
.github/scripts/validate_frontmatter.py
vendored
110
.github/scripts/validate_frontmatter.py
vendored
|
|
@ -42,9 +42,10 @@ ACTION_SKILL_OPTIONAL_KEYS = {
|
|||
}
|
||||
META_SKILL_REQUIRED_KEYS = {"kind", "id", "version", "title"}
|
||||
ENTRY_SKILL_REQUIRED_KEYS = {"kind", "id", "version", "title"}
|
||||
HOST_SKILL_REQUIRED_KEYS = {"name", "description"}
|
||||
|
||||
STANDARD_INPUTS = {
|
||||
"pr-diff", "object-list", "file-path", "repository", "telemetry-query",
|
||||
"pr-diff", "object-list", "file-path", "folder-path", "repository", "telemetry-query",
|
||||
}
|
||||
ALLOWED_OUTPUTS = {"findings-report"}
|
||||
VALID_SAMPLE_KINDS = {"good", "bad"}
|
||||
|
|
@ -62,6 +63,7 @@ ISO_ALPHA2 = re.compile(r"^[a-z]{2}$")
|
|||
RANGE_SHORTHAND = re.compile(r"^(\d+)\.\.(\d+)?$")
|
||||
FENCED_CODE_BLOCK = re.compile(r"^```", re.MULTILINE)
|
||||
HEADING_H2 = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE)
|
||||
SAMPLE_REFERENCE = re.compile(r"`([a-z0-9]+(?:-[a-z0-9]+)*\.(?:good|bad)\.[a-z0-9]+)`")
|
||||
|
||||
|
||||
# --- Diagnostics ------------------------------------------------------------
|
||||
|
|
@ -222,6 +224,13 @@ def validate_knowledge(path: Path, parsed: Parsed, report: Report) -> None:
|
|||
if "domain" in fm:
|
||||
if not isinstance(fm["domain"], str) or not fm["domain"].strip():
|
||||
report.error(path, "R04", "domain must be a non-empty string", 1)
|
||||
elif fm["domain"] != path.parent.name:
|
||||
report.error(
|
||||
path,
|
||||
"R27",
|
||||
f"frontmatter domain '{fm['domain']}' must match directory '{path.parent.name}'",
|
||||
1,
|
||||
)
|
||||
|
||||
# R05 keywords
|
||||
if "keywords" in fm:
|
||||
|
|
@ -372,6 +381,20 @@ def validate_action_skill(path: Path, parsed: Parsed, report: Report) -> None:
|
|||
bad = [x for x in ss if not x.endswith(".md")]
|
||||
if bad:
|
||||
report.error(path, "R20", f"sub-skills entries must end in '.md': {bad}", 1)
|
||||
non_canonical = [
|
||||
x for x in ss
|
||||
if "\\" in x or x.startswith("/") or ".." in Path(x).parts or x.startswith("./")
|
||||
]
|
||||
if non_canonical:
|
||||
report.error(
|
||||
path,
|
||||
"R20",
|
||||
f"sub-skills entries must be canonical repo-relative paths: {non_canonical}",
|
||||
1,
|
||||
)
|
||||
duplicates = sorted({x for x in ss if ss.count(x) > 1})
|
||||
if duplicates:
|
||||
report.error(path, "R20", f"sub-skills contains duplicate paths: {duplicates}", 1)
|
||||
|
||||
# R21 five required sections, in order, each exactly once
|
||||
heads = [h for h, _ in headings_in_order(parsed.body)]
|
||||
|
|
@ -436,10 +459,36 @@ def validate_entry_skill(path: Path, parsed: Parsed, report: Report) -> None:
|
|||
report.error(path, "R23", f"version must be a positive integer: {v!r}", 1)
|
||||
|
||||
|
||||
def validate_host_skill(path: Path, parsed: Parsed, report: Report) -> None:
|
||||
if parsed.frontmatter_error:
|
||||
report.error(path, "R01", parsed.frontmatter_error, 1)
|
||||
return
|
||||
fm = parsed.frontmatter
|
||||
assert fm is not None
|
||||
missing = HOST_SKILL_REQUIRED_KEYS - fm.keys()
|
||||
if missing:
|
||||
report.error(path, "R29", f"missing required host-skill keys: {sorted(missing)}", 1)
|
||||
|
||||
name = fm.get("name")
|
||||
if not isinstance(name, str) or not name:
|
||||
report.error(path, "R29", "host-skill name must be a non-empty string", 1)
|
||||
else:
|
||||
if len(name) > 64 or not KEBAB_CASE.fullmatch(name):
|
||||
report.error(path, "R29", f"host-skill name must be lowercase kebab-case and at most 64 characters: '{name}'", 1)
|
||||
if name != path.parent.name:
|
||||
report.error(path, "R29", f"host-skill name must match parent directory '{path.parent.name}', got '{name}'", 1)
|
||||
|
||||
description = fm.get("description")
|
||||
if not isinstance(description, str) or not description:
|
||||
report.error(path, "R29", "host-skill description must be a non-empty string", 1)
|
||||
elif len(description) > 1024:
|
||||
report.error(path, "R29", "host-skill description must be at most 1024 characters", 1)
|
||||
|
||||
|
||||
# --- Path and sample checks -------------------------------------------------
|
||||
|
||||
def classify(path_from_root: Path) -> str | None:
|
||||
"""Return 'knowledge' | 'action-skill' | 'meta' | 'entry' | None."""
|
||||
"""Return 'knowledge' | 'action-skill' | 'host-skill' | 'meta' | 'entry' | None."""
|
||||
parts = path_from_root.parts
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
|
|
@ -451,6 +500,8 @@ def classify(path_from_root: Path) -> str | None:
|
|||
return "entry"
|
||||
if name in META_SKILL_FILES:
|
||||
return "meta"
|
||||
if len(parts) == 3 and parts[2] == "SKILL.md":
|
||||
return "host-skill"
|
||||
return None
|
||||
if top in LAYERS and path_from_root.suffix == ".md":
|
||||
if len(parts) >= 3 and parts[1] == "skills":
|
||||
|
|
@ -477,7 +528,16 @@ def validate_samples_in_domain(domain_dir: Path, root: Path, report: Report) ->
|
|||
"""R14: every non-.md file must match <slug>.<kind>.<ext> with <slug>.md present."""
|
||||
if not domain_dir.is_dir():
|
||||
return
|
||||
article_slugs = {p.stem for p in domain_dir.glob("*.md")}
|
||||
articles = {p.stem: p for p in domain_dir.glob("*.md")}
|
||||
article_slugs = set(articles)
|
||||
article_texts: dict[str, str] = {}
|
||||
for slug, article in articles.items():
|
||||
try:
|
||||
article_texts[slug] = article.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
# R01 reports this during the article pass.
|
||||
continue
|
||||
|
||||
for entry in domain_dir.iterdir():
|
||||
if not entry.is_file() or entry.suffix == ".md":
|
||||
continue
|
||||
|
|
@ -491,9 +551,24 @@ def validate_samples_in_domain(domain_dir: Path, root: Path, report: Report) ->
|
|||
kind = m.group("kind")
|
||||
if slug not in article_slugs:
|
||||
report.error(entry, "R14", f"orphan sample: no matching article '{slug}.md' in {domain_dir.relative_to(root).as_posix()}")
|
||||
elif entry.name not in article_texts.get(slug, ""):
|
||||
report.error(
|
||||
entry,
|
||||
"R28",
|
||||
f"sample is not referenced by its article '{slug}.md'",
|
||||
)
|
||||
if kind not in VALID_SAMPLE_KINDS:
|
||||
report.warn(entry, "R14", f"non-standard sample kind '{kind}'; standard kinds are {sorted(VALID_SAMPLE_KINDS)}")
|
||||
|
||||
for slug, article in articles.items():
|
||||
for sample_name in SAMPLE_REFERENCE.findall(article_texts.get(slug, "")):
|
||||
if not (domain_dir / sample_name).is_file():
|
||||
report.error(
|
||||
article,
|
||||
"R28",
|
||||
f"referenced sample does not exist: '{sample_name}'",
|
||||
)
|
||||
|
||||
|
||||
# --- Orchestration ----------------------------------------------------------
|
||||
|
||||
|
|
@ -504,7 +579,13 @@ class SkillRecord:
|
|||
skill_id: str | None
|
||||
|
||||
|
||||
def validate_sub_skills_registry(path: Path, fm: dict[str, Any], root: Path, report: Report) -> None:
|
||||
def validate_sub_skills_registry(
|
||||
path: Path,
|
||||
fm: dict[str, Any],
|
||||
root: Path,
|
||||
action_skills_by_path: dict[str, dict[str, Any]],
|
||||
report: Report,
|
||||
) -> None:
|
||||
"""R26: a super-skill's declared `sub-skills` must exactly match the
|
||||
`al-*-review.md` leaf files present in the same directory (set equality,
|
||||
ordering-agnostic). This keeps the registered leaf list the single source
|
||||
|
|
@ -537,6 +618,17 @@ def validate_sub_skills_registry(path: Path, fm: dict[str, Any], root: Path, rep
|
|||
f"sub-skills entry is not a sibling 'al-*-review.md' leaf: {entry}", 1,
|
||||
)
|
||||
|
||||
for entry in ss:
|
||||
leaf = action_skills_by_path.get(entry)
|
||||
if leaf is None:
|
||||
if (root / entry).exists():
|
||||
report.error(path, "R26", f"sub-skills entry is not an action skill: {entry}", 1)
|
||||
continue
|
||||
if is_non_empty_list_of_str(leaf.get("sub-skills")):
|
||||
report.error(path, "R26", f"nested super-skill is not permitted in v1 composition: {entry}", 1)
|
||||
if leaf.get("outputs") != ["findings-report"]:
|
||||
report.error(path, "R26", f"sub-skill must produce findings-report: {entry}", 1)
|
||||
|
||||
# Sibling leaves on disk that were never registered ('forgot to wire it up').
|
||||
for leaf in sorted(leaves - declared):
|
||||
report.error(path, "R26", f"leaf not registered in sub-skills: {leaf}", 1)
|
||||
|
|
@ -584,6 +676,8 @@ def run(root: Path) -> Report:
|
|||
validate_entry_skill(path, parsed, report)
|
||||
if parsed.frontmatter and isinstance(parsed.frontmatter.get("id"), str):
|
||||
skill_records.append(SkillRecord(path, "entry-point", parsed.frontmatter["id"]))
|
||||
elif kind == "host-skill":
|
||||
validate_host_skill(path, parsed, report)
|
||||
|
||||
# Second pass: sample files per knowledge domain
|
||||
for layer in LAYERS:
|
||||
|
|
@ -607,9 +701,13 @@ def run(root: Path) -> Report:
|
|||
others = [q.relative_to(root).as_posix() for q in paths if q != p]
|
||||
report.error(p, "R24", f"skill id '{sid}' ({kind}) is not unique; also defined in: {others}")
|
||||
|
||||
# Fourth pass: R26 sub-skills registry matches leaf files on disk
|
||||
# Fourth pass: R26 sub-skills registry matches compatible leaf files on disk
|
||||
action_skills_by_path = {
|
||||
path.relative_to(root).as_posix(): fm
|
||||
for path, fm in action_skill_fms
|
||||
}
|
||||
for path, fm in action_skill_fms:
|
||||
validate_sub_skills_registry(path, fm, root, report)
|
||||
validate_sub_skills_registry(path, fm, root, action_skills_by_path, report)
|
||||
|
||||
return report
|
||||
|
||||
|
|
|
|||
109
.github/workflows/flag-new-top-level.yml
vendored
Normal file
109
.github/workflows/flag-new-top-level.yml
vendored
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
name: Flag new top-level entries
|
||||
|
||||
# BCQuality keeps a deliberately small repository root. New top-level folders
|
||||
# or files are almost always unintended — a stray export, a tool's scratch
|
||||
# directory, or content that meant to land inside an existing layer (e.g.
|
||||
# /community/knowledge/). PR #55 leaked exactly this kind of stray folder.
|
||||
#
|
||||
# Unlike the custom-layer guard, this workflow does NOT close the PR. It only
|
||||
# posts a single advisory comment so a maintainer (and the author) can eyeball
|
||||
# the addition. It reads the PR's file LIST via the API and never checks out or
|
||||
# runs PR code.
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, synchronize]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
flag:
|
||||
if: github.repository == 'microsoft/BCQuality'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
sparse-checkout: |
|
||||
.github/new-top-level-flag.md
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: Flag unexpected new top-level entries
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
|
||||
// Known, intended repository root. Anything else added at the root
|
||||
// is flagged for a human to eyeball.
|
||||
const ALLOWED_DIRS = new Set([
|
||||
'.claude-plugin', '.github', 'community', 'custom', 'docs', 'evaluation',
|
||||
'microsoft', 'skills', 'tools',
|
||||
]);
|
||||
const ALLOWED_FILES = new Set([
|
||||
'.gitignore', 'CODEOWNERS', 'LICENSE', 'README.md',
|
||||
'SECURITY.md', 'plugin.json',
|
||||
]);
|
||||
|
||||
const MARKER = '<!-- guard:new-top-level -->';
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner, repo, pull_number: prNumber, per_page: 100,
|
||||
});
|
||||
|
||||
// Only consider newly-added paths — a new top-level entry can only
|
||||
// appear via an added file.
|
||||
const added = files
|
||||
.filter((f) => f.status === 'added')
|
||||
.map((f) => f.filename);
|
||||
|
||||
const newDirs = new Set();
|
||||
const newFiles = new Set();
|
||||
for (const p of added) {
|
||||
const slash = p.indexOf('/');
|
||||
if (slash === -1) {
|
||||
// Top-level file.
|
||||
if (!ALLOWED_FILES.has(p)) newFiles.add(p);
|
||||
} else {
|
||||
// Top-level directory.
|
||||
const dir = p.slice(0, slash);
|
||||
if (!ALLOWED_DIRS.has(dir)) newDirs.add(dir);
|
||||
}
|
||||
}
|
||||
|
||||
if (newDirs.size === 0 && newFiles.size === 0) {
|
||||
core.info('No unexpected new top-level entries. Nothing to flag.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Idempotency: don't re-flag on every synchronize.
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner, repo, issue_number: prNumber, per_page: 100,
|
||||
});
|
||||
if (comments.some((c) => c.body && c.body.includes(MARKER))) {
|
||||
core.info('Already flagged on this PR. Skipping duplicate comment.');
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = [];
|
||||
for (const d of [...newDirs].sort()) lines.push(`- 📁 \`${d}/\` (new top-level folder)`);
|
||||
for (const f of [...newFiles].sort()) lines.push(`- 📄 \`${f}\` (new top-level file)`);
|
||||
const entries = lines.join('\n');
|
||||
|
||||
core.warning(`Unexpected new top-level entries: ${[...newDirs, ...newFiles].join(', ')}`);
|
||||
|
||||
let body = fs.readFileSync('.github/new-top-level-flag.md', 'utf8');
|
||||
body = body
|
||||
.replace(/{{AUTHOR}}/g, context.payload.pull_request.user.login)
|
||||
.replace(/{{ENTRIES}}/g, entries);
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: prNumber, body,
|
||||
});
|
||||
|
||||
core.info(`Flagged PR #${prNumber}.`);
|
||||
88
.github/workflows/guard-custom-layer.yml
vendored
Normal file
88
.github/workflows/guard-custom-layer.yml
vendored
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
name: Guard custom layer
|
||||
|
||||
# The /custom/ layer is a template: in upstream microsoft/BCQuality it stays
|
||||
# empty by default (README.md + .gitkeep placeholders only). Custom knowledge
|
||||
# and skills are partner/customer-specific and belong in a fork, never upstream.
|
||||
#
|
||||
# This workflow auto-closes any PR that adds or changes content under /custom/
|
||||
# (anything beyond the allowed template files). It runs only on the upstream
|
||||
# repo, so forks that legitimately populate /custom/ are unaffected.
|
||||
#
|
||||
# pull_request_target is required so the workflow runs with a token that can
|
||||
# comment on and close the PR (including PRs opened from forks). It only reads
|
||||
# the PR's file LIST via the API and never checks out or executes PR code, so
|
||||
# the elevated token is not exposed to untrusted content.
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, synchronize]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
guard:
|
||||
# Never run on forks — a fork's /custom/ content is exactly what's supposed
|
||||
# to live there.
|
||||
if: github.repository == 'microsoft/BCQuality'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
sparse-checkout: |
|
||||
.github/custom-layer-autoclose.md
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: Close PR if it touches the custom layer
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
|
||||
// Files under custom/ that ARE allowed to change (the template seed).
|
||||
const ALLOWED = new Set([
|
||||
'custom/README.md',
|
||||
]);
|
||||
// Any .gitkeep under custom/ is also allowed.
|
||||
const isAllowed = (p) =>
|
||||
ALLOWED.has(p) || /^custom\/.*\.gitkeep$/.test(p) || p === 'custom/.gitkeep';
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner, repo, pull_number: prNumber, per_page: 100,
|
||||
});
|
||||
|
||||
// Offending = added/modified/renamed/copied/changed paths under custom/
|
||||
// that are not template files. (We ignore pure deletions.)
|
||||
const offending = files
|
||||
.filter((f) => f.status !== 'removed')
|
||||
.map((f) => f.filename)
|
||||
.filter((p) => p.startsWith('custom/') && !isAllowed(p));
|
||||
|
||||
if (offending.length === 0) {
|
||||
core.info('No disallowed /custom/ changes found. Nothing to do.');
|
||||
return;
|
||||
}
|
||||
|
||||
core.warning(`PR #${prNumber} touches the custom layer: ${offending.join(', ')}`);
|
||||
|
||||
const fileList = offending.map((p) => `- \`${p}\``).join('\n');
|
||||
let body = fs.readFileSync('.github/custom-layer-autoclose.md', 'utf8');
|
||||
body = body
|
||||
.replace(/{{AUTHOR}}/g, context.payload.pull_request.user.login)
|
||||
.replace(/{{FILES}}/g, fileList);
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: prNumber, body,
|
||||
});
|
||||
|
||||
await github.rest.pulls.update({
|
||||
owner, repo, pull_number: prNumber, state: 'closed',
|
||||
});
|
||||
|
||||
core.info(`Closed PR #${prNumber}.`);
|
||||
2
.github/workflows/knowledge-index.yml
vendored
2
.github/workflows/knowledge-index.yml
vendored
|
|
@ -17,7 +17,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Validate knowledge-index generator
|
||||
shell: pwsh
|
||||
|
|
|
|||
69
.github/workflows/release-version.yml
vendored
Normal file
69
.github/workflows/release-version.yml
vendored
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# Cuts a BCQuality content release on demand (roughly monthly), NOT on every
|
||||
# commit. Run this workflow manually once the `main` content is ready, and choose
|
||||
# whether to bump the minor (usual periodic content update) or the major
|
||||
# (breaking change).
|
||||
#
|
||||
# The version is a `major.minor` value derived from existing git tags — there is
|
||||
# no VERSION file. The minor is a monotonic counter: it only ever increments and
|
||||
# never resets, even across a major bump, so it uniquely identifies a release.
|
||||
# This workflow computes the next version and tags the current commit as
|
||||
# `v{major}.{minor}`.
|
||||
|
||||
name: Release version
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
description: Which part to bump
|
||||
type: choice
|
||||
options:
|
||||
- minor
|
||||
- major
|
||||
default: minor
|
||||
|
||||
# Only tag creation needs write.
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-version
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Compute and tag release
|
||||
shell: bash
|
||||
run: |
|
||||
git fetch --tags --force --quiet
|
||||
tags="$(git tag -l | grep -E '^v[0-9]+\.[0-9]+$' || true)"
|
||||
|
||||
if [[ -z "$tags" ]]; then
|
||||
# First release.
|
||||
major=1
|
||||
minor=0
|
||||
else
|
||||
latest_major="$(printf '%s\n' "$tags" | sed -E 's/^v([0-9]+)\..*/\1/' | sort -n | tail -1)"
|
||||
latest_minor="$(printf '%s\n' "$tags" | sed -E 's/^v[0-9]+\.([0-9]+)$/\1/' | sort -n | tail -1)"
|
||||
minor=$(( latest_minor + 1 )) # monotonic, never resets
|
||||
if [[ "${{ inputs.bump }}" == "major" ]]; then
|
||||
major=$(( latest_major + 1 ))
|
||||
else
|
||||
major="$latest_major"
|
||||
fi
|
||||
fi
|
||||
|
||||
tag="v${major}.${minor}"
|
||||
if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then
|
||||
echo "::error::Tag ${tag} already exists"
|
||||
exit 1
|
||||
fi
|
||||
git tag "$tag" "${{ github.sha }}"
|
||||
git push origin "$tag"
|
||||
echo "Released BCQuality ${tag} at ${{ github.sha }}"
|
||||
18
.github/workflows/review-fixtures.yml
vendored
Normal file
18
.github/workflows/review-fixtures.yml
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
name: Validate AL review fixtures
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
validate-review-fixtures:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Validate review evaluation corpus
|
||||
shell: pwsh
|
||||
run: ./tools/Test-ReviewFixtures.ps1 -Root . -PrepareDirectory "$env:RUNNER_TEMP/bcquality-review-fixtures"
|
||||
18
.github/workflows/skill-index.yml
vendored
Normal file
18
.github/workflows/skill-index.yml
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
name: Validate skill index and report schemas
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
validate-contract:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Validate skill-index generator and schemas
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/Test-SkillIndex.ps1 -Root .
|
||||
4
.github/workflows/validate-frontmatter.yml
vendored
4
.github/workflows/validate-frontmatter.yml
vendored
|
|
@ -11,10 +11,10 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
|
|
|
|||
218
README.md
218
README.md
|
|
@ -1,150 +1,130 @@
|
|||
# BCQuality
|
||||
<p align="left">
|
||||
<img src="docs/assets/bcq-logo.svg" alt="BCQuality logo" width="300">
|
||||
</p>
|
||||
|
||||
Quality skills and knowledge for Business Central development.
|
||||
Quality skills and knowledge that help AI tools make better Business Central
|
||||
development decisions: catch BC-specific defects, avoid misleading advice,
|
||||
and explain findings with references you can read.
|
||||
|
||||
BCQuality is a curated knowledge base and skills library for Business Central. It provides structured, machine-readable guidance that development agents and tools can consume — establishing a consistent quality bar across tooling and teams.
|
||||
BCQuality contains **knowledge and reusable skills**, not agents or a Business
|
||||
Central extension. Your host supplies the agent. You can install the content
|
||||
as a plugin, use it from another integration, or browse the knowledge directly.
|
||||
|
||||
## What belongs here
|
||||
## Quick start
|
||||
|
||||
BCQuality is a remedial knowledge base. A file exists because a capable LLM **would get something wrong, or miss something, without it** — not because the topic is important. The admission test for a knowledge file is one question:
|
||||
The walkthrough below uses **GitHub Copilot CLI in a terminal**, not the
|
||||
Copilot Chat panel in VS Code. First
|
||||
[install Copilot CLI and sign in](https://docs.github.com/en/copilot/get-started/cli-quickstart).
|
||||
Your account and organization policy must allow its use. You do not need to
|
||||
clone BCQuality, build a runner, or deploy an app to Business Central for this
|
||||
source-review example.
|
||||
|
||||
> If this file did not exist, would a modern LLM reviewing or generating BC code make a mistake this file would have prevented?
|
||||
### Standalone plugin installation
|
||||
|
||||
If the answer is no — the advice is generic software-engineering guidance, or the LLM already knows the BC mechanic in question — the file does not belong here, regardless of how sound the content is. A file earns its place by encoding something BC-specific that LLMs demonstrably get wrong: a CodeCop rule number, a platform API whose semantics the training data gets backwards, a non-obvious ordering rule, a BC property whose default is a footgun.
|
||||
Run these commands in your terminal:
|
||||
|
||||
Good fit: "`SetLoadFields` must be called before filters, not after" (non-obvious ordering rule). "`FindSet(true)` takes a LockTable and the two-parameter signature is obsolete" (subtle platform behaviour + outdated training data). "CodeCop AA0233 flags `FindFirst … Next` loops" (rule-specific).
|
||||
|
||||
Poor fit: "Use HTTPS instead of HTTP." "Don't hardcode secrets." "Keep transactions short." These are true but any capable LLM already applies them without prompting.
|
||||
|
||||
The practical consequence: when a code-review agent flags something it shouldn't have, or misses something it should have caught, the remedy is a new knowledge file. When it already behaves correctly on a topic, no file is needed.
|
||||
|
||||
## What's in this repo
|
||||
|
||||
BCQuality contains **knowledge** and **skills**. It does not contain agents. Agents that consume BCQuality ship with [AL-Go](https://github.com/microsoft/AL-Go) and other orchestrators.
|
||||
|
||||
### Knowledge files
|
||||
|
||||
Atomic markdown files with YAML frontmatter. Each file covers one concern — one thing an agent would cite when reviewing or generating code. Knowledge files live in two layers:
|
||||
|
||||
- **`/microsoft/`** — Microsoft-endorsed layer.
|
||||
- `/microsoft/knowledge/` — Platform guardrails, official guidance.
|
||||
- `/microsoft/skills/` — Microsoft-endorsed action skills.
|
||||
- **`/community/`** — BC community layer.
|
||||
- `/community/knowledge/` — Community patterns and shared guidance.
|
||||
- `/community/skills/` — Community-contributed action skills.
|
||||
|
||||
- **`/custom/`** — Partner- and customer-specific overrides. Empty by default; populated in forks.
|
||||
- `/custom/knowledge/` — Organization-specific knowledge files.
|
||||
- `/custom/skills/` — Organization-specific action skills.
|
||||
|
||||
All three layers are enabled by default when an agent consumes BCQuality. Content can be promoted from Community to Microsoft-endorsed once it proves itself — this is a first-class concept, not an afterthought.
|
||||
|
||||
### Skills
|
||||
|
||||
Skills define how agents consume knowledge. They come in three flavors:
|
||||
|
||||
- **The entry-point skill** ([`skills/entry.md`](skills/entry.md)) — the first skill an agent invokes at runtime. Given a task context (goal, available inputs, technologies, BC version, etc.), it returns a **dispatch record** naming the action skill or skills to invoke next. Routing logic lives here, not in the orchestrator.
|
||||
|
||||
- **Meta-skill contracts** (`/skills/`) — three stable references that define the rest of the repo:
|
||||
1. **Schema + Use** (READ, [`skills/read.md`](skills/read.md)) — how to read a knowledge file: interpret frontmatter, parse sections, understand layer precedence. Any agent or skill that reads knowledge files depends on it.
|
||||
2. **Action Skill** (DO, [`skills/do.md`](skills/do.md)) — the template every action skill follows. Defines the four-step pattern (Source → Relevance → Worklist → Action) and the structured output format that orchestrators expect.
|
||||
3. **New Knowledge** (WRITE, [`skills/write.md`](skills/write.md)) — how to author a valid knowledge file. References Schema + Use for the format specification and adds authoring rules (atomicity, section guidance).
|
||||
|
||||
READ and DO are read on demand — typically when the first dispatched action skill runs. They are not prerequisites for invoking Entry. WRITE is only used when scaffolding new content.
|
||||
|
||||
- **Action skills** — concrete skills that follow the Action Skill template to do real work (review code, audit telemetry, etc.). Action skills live inside the layers that own them (`/microsoft/skills/`, `/community/skills/`, `/custom/skills/`). An action skill is either a **leaf** that evaluates knowledge files directly, or a **super-skill** that composes other action skills (declared via `sub-skills` in frontmatter). The canonical reference is [`microsoft/skills/review/al-code-review.md`](microsoft/skills/review/al-code-review.md) (super-skill), which composes the AL review leaf skills under [`microsoft/skills/review/`](microsoft/skills/review/) — one per knowledge domain.
|
||||
|
||||
### Agent bootstrapping
|
||||
|
||||
An orchestrator (such as AL-Go) points the agent at BCQuality's URL and provides a task context. The agent's first call is `/skills/entry.md`, which returns a dispatch record naming the action skill(s) to invoke. The agent then invokes each dispatched skill in turn, reading READ and DO on demand. No prior knowledge of BCQuality's structure is baked into the orchestrator — only the convention *"invoke `/skills/entry.md` first."*
|
||||
|
||||
## Knowledge file format
|
||||
|
||||
Every knowledge file is a markdown file with mandatory YAML frontmatter. Files target under 100 lines (ideal under 50). If two ideas would share a file, split them.
|
||||
|
||||
### Frontmatter schema (v1)
|
||||
|
||||
```yaml
|
||||
---
|
||||
bc-version: [all] # or [26..28], or [26..] for "26 and later"
|
||||
domain: performance # security | performance | ux | telemetry | ...
|
||||
keywords: [query, filtering, partial] # free-text tags for retrieval
|
||||
technologies: [al] # al | javascript | powershell | ...
|
||||
countries: [w1] # ISO codes, or [w1]
|
||||
application-area: [all] # finance | manufacturing | jobs | [all]
|
||||
---
|
||||
```powershell
|
||||
copilot plugin install microsoft/BCQuality
|
||||
copilot plugin list
|
||||
```
|
||||
|
||||
All six fields are required. The schema is locked — changes require a PR approved by both maintainers.
|
||||
The list should include `bcquality`. The plugin currently exposes the
|
||||
[`al-code-review`](skills/al-code-review/SKILL.md) skill. Installation and skill
|
||||
discovery are the general pattern; reviewing an app is one example of using it.
|
||||
|
||||
### Sections
|
||||
### Example: Review a complete app folder
|
||||
|
||||
Every knowledge file must contain a `## Description` section. The following sections are optional but recommended:
|
||||
Start a **new** CLI session in your own app folder, replacing the example path:
|
||||
|
||||
- **`## Best Practice`** — the recommended approach
|
||||
- **`## Anti Pattern`** — what to avoid and why
|
||||
```powershell
|
||||
cd "C:\Repos\MyBusinessCentralApp"
|
||||
copilot
|
||||
```
|
||||
|
||||
Code examples belong in separate files, not in the knowledge file itself. Knowledge files must not contain fenced code blocks.
|
||||
Approve access only to a project you trust, then ask:
|
||||
|
||||
> Use the installed al-code-review skill to review the complete Business Central
|
||||
> app in this folder without changing my source files. Return the complete
|
||||
> BCQuality findings report.
|
||||
|
||||
The folder should contain `app.json` and your AL source; it does **not** need
|
||||
to be a Git repository. On macOS or Linux, use your app's local path instead.
|
||||
|
||||
Expect a report for each selected review, with findings, source locations,
|
||||
severity, confidence, and references to the relevant guidance. Some hosts show
|
||||
the structured JSON directly. `completed` with no findings means nothing was
|
||||
flagged in that review's scope; `partial` or `failed` is **not** a clean result.
|
||||
See [reading your results](docs/using-bcquality.md#reading-your-results).
|
||||
|
||||
[PowerShell 7](https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell)
|
||||
(`pwsh`) is recommended for fast knowledge discovery. If it is unavailable,
|
||||
the review can still discover knowledge by reading the folders.
|
||||
|
||||
## Documentation
|
||||
|
||||
| I want to... | Start here |
|
||||
| --- | --- |
|
||||
| Choose direct reading, a supplied skill, or my own agent | [Ways to use BCQuality](docs/using-bcquality.md#choose-how-to-use-bcquality) |
|
||||
| Review a file, changes, a branch, or a particular concern | [Using BCQuality](docs/using-bcquality.md) |
|
||||
| Resolve setup problems, incomplete reviews, or incorrect findings | [Troubleshooting and support](docs/troubleshooting.md) |
|
||||
| Browse the available guidance | [Knowledge by domain](docs/using-bcquality.md#knowledge-by-domain) |
|
||||
| Configure the plugin or use my organization's rules | [Customizing BCQuality](docs/customizing-bcquality.md) |
|
||||
| Contribute knowledge or improve a rule | [Your first contribution](docs/contributing.md#your-first-contribution) |
|
||||
| Connect a host, agent, or CI integration | [Minimal integration example](docs/agent-consumption.md#try-a-minimal-integration) |
|
||||
|
||||
[All documentation and technical references](docs/README.md).
|
||||
|
||||
## Scope
|
||||
|
||||
BCQuality covers Business Central broadly — the application domains it supports, the technologies used to extend it, and the practices that keep implementations healthy. The scope includes:
|
||||
Today's curated content focuses on **technical AL code review**. It augments
|
||||
the agent's judgment; it is not an exhaustive BC manual or a substitute for
|
||||
compilation, analyzers, tests, or human review. See
|
||||
[coverage and limits](docs/using-bcquality.md#coverage-and-limits) for the
|
||||
available domains and the difference between a folder review and a comparison.
|
||||
Mechanical issues already enforced by the AL compiler or standard analyzers are
|
||||
intentionally left to those deterministic tools rather than duplicated here.
|
||||
|
||||
- **Business Central domains** — Finance, Supply Chain Management, Manufacturing, Jobs, Warehousing, Service, and the many other functional areas BC covers. Domain knowledge helps agents understand the business context they are working in.
|
||||
- AL language patterns and anti-patterns
|
||||
- PowerShell scripting for BC
|
||||
- Pipelines (AL-Go, GitHub Actions)
|
||||
- Business Central APIs
|
||||
- Power Platform integration
|
||||
- Telemetry and KQL
|
||||
- AppSource lifecycle
|
||||
Functional areas such as Finance, Supply Chain Management, Manufacturing, Jobs,
|
||||
Warehousing, and Service, and technologies such as PowerShell, pipelines, and
|
||||
Power Platform, remain valid future scope, **not current coverage claims**.
|
||||
|
||||
A BC developer's actual job spans all of this, and BCQuality reflects that.
|
||||
## What's in this repo
|
||||
|
||||
## How agents consume BCQuality
|
||||
Knowledge articles cover one concern each. Skills tell an agent how to find
|
||||
and apply the relevant knowledge. Both live in three layers:
|
||||
|
||||
Action skills follow a four-step pattern:
|
||||
| Layer | Purpose |
|
||||
| --- | --- |
|
||||
| [Microsoft](microsoft/) | Microsoft-endorsed skills and their knowledge. |
|
||||
| [Community](community/) | Community-owned skills and their knowledge. |
|
||||
| [Custom](custom/) | Organization-specific additions and overrides in your own fork. |
|
||||
|
||||
1. **Source** — which knowledge folders and tags to search
|
||||
2. **Relevance** — filter by frontmatter (version, technology, country, area)
|
||||
3. **Worklist** — narrow from N candidates to the M that apply to the current task
|
||||
4. **Action** — apply the relevant knowledge and produce structured output
|
||||
All three are enabled by default; Custom is empty upstream. You do not need
|
||||
to configure layers to get started.
|
||||
|
||||
Every action skill produces output in a common format that orchestrators can consume without skill-specific parsing. The format is JSON and includes an `outcome` (so a clean run, a not-applicable skill, and a partial failure are all distinguishable), `findings` (what the skill observed), structured `references` back to the knowledge files that informed each finding, per-finding `confidence`, and a `suppressed` list recording any knowledge files overridden by layer precedence. This contract is defined in the Action Skill meta-skill so that orchestrators and action skills remain independently evolvable.
|
||||
## Versioning
|
||||
|
||||
BCQuality is an **additive** knowledge layer: it augments the agent's review judgement, it does not replace it. Super-skills (such as `al-code-review`) run a self-review pass alongside their sub-skills and surface concerns the agent identified on its own, marked with `from-sub-skill: "agent"` and an empty `references: []` so consumers can render them distinctly from knowledge-backed findings. See [agent-consumption.md](agent-consumption.md) and [`skills/do.md`](skills/do.md) for the full contract.
|
||||
|
||||
The meta-skills in `/skills/` define this pattern. Every concrete action skill follows it.
|
||||
|
||||
For the end-to-end flow — from orchestrator trigger through to how output reaches developers — see [agent-consumption.md](agent-consumption.md).
|
||||
|
||||
## Repository structure
|
||||
Update the installed plugin from your terminal, then start a new session:
|
||||
|
||||
```powershell
|
||||
copilot plugin update bcquality
|
||||
```
|
||||
├── /skills/ # Global: entry-point skill + meta-skill contracts (READ, DO, WRITE)
|
||||
├── /.github/ # Actions and workflows
|
||||
├── /microsoft/ # Microsoft-endorsed layer
|
||||
│ ├── /knowledge/ # Knowledge files by domain
|
||||
│ │ └── /<domain>/ # Each article: <slug>.md + optional <slug>.good.al / <slug>.bad.al
|
||||
│ └── /skills/ # Microsoft-endorsed action skills
|
||||
├── /community/ # BC community layer
|
||||
│ ├── /knowledge/ # Knowledge files by domain
|
||||
│ │ └── /<domain>/ # Article + sibling samples, same convention
|
||||
│ └── /skills/ # Community action skills
|
||||
├── /custom/ # Partner/customer-specific overrides (empty; populated in forks)
|
||||
│ ├── /knowledge/
|
||||
│ └── /skills/
|
||||
```
|
||||
|
||||
Plugin versions and content-release tags are different. For reproducible runs
|
||||
and organization forks, see [updates and versions](docs/customizing-bcquality.md#updates-and-versions).
|
||||
|
||||
## What belongs here
|
||||
|
||||
Knowledge belongs here when it prevents a BC-specific mistake an otherwise
|
||||
capable agent would make, including false-positive findings. BC facts belong
|
||||
in knowledge articles, not skill instructions. See the
|
||||
[admission test and examples](docs/contributing.md#what-belongs-here).
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome. Before submitting a PR:
|
||||
|
||||
1. Read the knowledge file format above — frontmatter and sections are validated by CI.
|
||||
2. Keep files atomic: one concern per file, under 100 lines.
|
||||
3. Target your contribution to the right layer — most community contributions go in `/community/knowledge/`.
|
||||
|
||||
CI runs validation on every PR. If your knowledge file has schema violations, missing sections, code blocks, or exceeds 100 lines, the check will fail with a clear error message.
|
||||
Partners are welcome to contribute to the layer that owns the domain,
|
||||
regardless of affiliation. Start with the [contribution guide](docs/contributing.md).
|
||||
To report a problem without authoring a rule, see [support](docs/troubleshooting.md#reporting-a-problem).
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
|
|
@ -1,96 +0,0 @@
|
|||
# How agents consume BCQuality
|
||||
|
||||
BCQuality is content — knowledge files and skills. It is consumed by agents that live elsewhere (AL-Go, a VS Code extension, a GitHub Agent invocation, etc.). This document explains the end-to-end flow, so that skill authors, orchestrator maintainers, and contributors share one mental model.
|
||||
|
||||
For the high-level framing and repo structure, start with the [README](README.md). This document is the operational view.
|
||||
|
||||
## The actors
|
||||
|
||||
- **Orchestrator** — the tool that triggers work (e.g. AL-Go on a pull request, or a VS Code extension on save). Lives *outside* BCQuality. Knows *when* to run something, not *what* to run.
|
||||
- **Agent** — an LLM-driven process spawned by the orchestrator. The agent has no built-in knowledge of BC or of BCQuality's conventions. It knows how to read instructions and call tools.
|
||||
- **BCQuality repo** — two kinds of content:
|
||||
- **Global skills** in `/skills/` — the `entry.md` entry-point skill plus the READ · DO · WRITE contracts that govern the rest of the repo.
|
||||
- **Layer content** in `/microsoft/`, `/community/`, and `/custom/` — knowledge files and action skills grouped by authority.
|
||||
|
||||
## The flow
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
O[Orchestrator<br/>AL-Go] -->|1 trigger + task context| A[Agent]
|
||||
A -->|2 invoke entry.md| E[Entry<br/>routing skill]
|
||||
E -->|3 dispatch record| A
|
||||
A -->|4 invoke dispatched skill| S[Action skill<br/>e.g. al-code-review]
|
||||
S -->|5 execute| P[Source → Relevance<br/>→ Worklist → Action<br/>reading READ · DO on demand]
|
||||
P -->|6 emit| R[Findings · References<br/>· Confidence]
|
||||
R -->|7 integrate| O
|
||||
```
|
||||
|
||||
### 1. Orchestrator triggers
|
||||
The orchestrator has a URL setting that points at BCQuality (default: `github.com/microsoft/BCQuality`) and a task to perform. It hands the agent a **task context** — goal, inputs available (`pr-diff`, `file-path`, …), technologies, BC version, enabled layers — and says: *your source of truth lives at that URL; start by invoking `/skills/entry.md`*.
|
||||
|
||||
### 2. Agent invokes Entry
|
||||
The agent reads `/skills/entry.md` and runs it against the task context. Entry applies its Source → Relevance → Worklist → Action steps over the action skills under `*/skills/**/*.md` and returns a **dispatch record**: the set of action skills to invoke, plus a list of candidates it skipped (with reasons). Routing is a skill, not orchestrator logic.
|
||||
|
||||
### 3. Agent consumes the dispatch record
|
||||
The dispatch record names one or more action skills and the subset of inputs each should receive. If the outcome is `no-match` or `failed`, the agent returns the record to the orchestrator unchanged.
|
||||
|
||||
### 4. Agent invokes each dispatched action skill
|
||||
Action skills live inside the layers — `/microsoft/skills/`, `/community/skills/`, `/custom/skills/` — so their authority is carried by their location. For a PR review, Entry typically dispatches `microsoft/skills/review/al-code-review.md`. The agent reads the file and executes it.
|
||||
|
||||
### 5. Action skill executes the four-step pattern
|
||||
|
||||
Each action skill is a markdown file that specifies what to do at each step. The template is always the same:
|
||||
|
||||
| Step | What happens |
|
||||
| --- | --- |
|
||||
| **Source** | Declare which knowledge folders and tags to search. |
|
||||
| **Relevance** | Filter by frontmatter — `bc-version`, `technologies`, `countries`, `application-area`. |
|
||||
| **Worklist** | Narrow from N candidates to the M that apply to this specific task. |
|
||||
| **Action** | Apply the relevant knowledge and produce structured output. |
|
||||
|
||||
Example: a performance review skill sources from `/microsoft/knowledge/performance/` and `/community/knowledge/performance/`, filters to `bc-version: 26` and `technologies: [al]`, narrows the 25 candidate files to the 8 that apply to the 15 objects changed in the PR, and then evaluates each file against the diff.
|
||||
|
||||
At this point the agent reads READ and DO on demand — it needs READ to interpret each knowledge file's frontmatter and sections, and DO to shape its output. Those contracts are fetched when first needed, not as part of bootstrap.
|
||||
|
||||
### 5a. The knowledge index (Source acceleration)
|
||||
|
||||
Discovering candidates at the Source step naively means opening every file under a domain folder just to read its frontmatter `keywords` — on a large corpus that is hundreds of file reads per review. To avoid this, BCQuality maintains a **knowledge index**: a single artifact (`knowledge-index.json`) that lists every article surviving the consumer's layer/allow-deny filtering and carries, per article, the exact inputs the Source/Worklist steps consume — `path`, `layer`, `domain`, frontmatter dimensions, `keywords`, `title`, and a one-line `description` hint.
|
||||
|
||||
The index is **owned and produced by BCQuality**, not by each consumer: its generator (`tools/Build-KnowledgeIndex.ps1`) ships here, next to the skills and knowledge it derives from, so the index schema stays in lockstep with the Source contract and every consumer gets the same faithful index for free instead of re-implementing the parser. The consuming orchestrator does **not** build or invoke the index — it only prunes its clone to policy as it already does. The index is then (re)generated by BCQuality itself: **Entry's preparation step runs `Build-KnowledgeIndex.ps1` over the live, already-pruned clone** at the start of every run (see `skills/entry.md`), and BCQuality CI (`.github/workflows/knowledge-index.yml`) validates that the generator is healthy and deterministic. Building over the *pruned* clone — rather than shipping a committed full-corpus index that consumers trust — keeps the index exact for any consumer policy: it can never list an article the consumer denied, so policy-excluded rules cannot leak into discovery.
|
||||
|
||||
The index changes only *how candidates are discovered*, never *which are selected*. The Worklist predicate is unchanged — `keywords` still drive selection — and the agent still opens each worklisted article **in full** to read its `## Best Practice` / `## Anti Pattern` rule bodies; the index is discovery metadata only and never substitutes for the article body. When no index is present, skills fall back to path-based discovery (collect by domain folder), so review still works.
|
||||
|
||||
### 6. Agent emits structured output
|
||||
The output contract is defined in the DO meta-skill so that every action skill — today's and next year's — produces the same shape:
|
||||
|
||||
- **Outcome** — `completed`, `not-applicable`, `no-knowledge`, `partial`, or `failed`. An orchestrator can distinguish a clean run from a no-op from a failure without guessing.
|
||||
- **Findings** — what the skill observed (severity, message, optional location).
|
||||
- **References** — structured objects (`path` plus optional commit `sha`) pointing to the knowledge files that informed each finding.
|
||||
- **Confidence** — per-finding evidence strength.
|
||||
- **Suppressed** — knowledge files that were discarded by layer precedence or configuration, so reviewers can see what was overridden.
|
||||
|
||||
The orchestrator parses this **without skill-specific logic**. This is the point of the contract: orchestrators and action skills evolve independently.
|
||||
|
||||
### 7. Orchestrator integrates
|
||||
The orchestrator turns findings into PR comments, build gates, or IDE diagnostics, and links the references back to the knowledge files so the PR author — human or agent — can read the guidance.
|
||||
|
||||
## Knowledge-backed and agent findings
|
||||
|
||||
BCQuality is an **additive** knowledge layer. The agent surfaces two kinds of findings, both shaped to the same DO output contract:
|
||||
|
||||
- **Knowledge-backed findings** carry one or more entries in `references[]` pointing at BCQuality knowledge files. Their `id` is the primary file's repo-relative path. These are produced by leaf sub-skills and rolled up by super-skills.
|
||||
- **Agent findings** are surfaced by a super-skill from its own self-review pass when no BCQuality knowledge file backs the concern. They are tagged with `from-sub-skill: "agent"`, carry an empty `references: []`, use a slug `id` prefixed `agent:`, and have `confidence` capped at `medium`. Their `message` is self-contained because there is no knowledge-file footer to fall back on.
|
||||
|
||||
Before a super-skill emits an agent finding, it validates the candidate against the BCQuality knowledge already loaded for the task: a matching file upgrades the candidate to a knowledge-backed finding (and merges or deduplicates against the relevant sub-skill output); a contradicting file suppresses the candidate. Only candidates with no BCQuality coverage become agent findings.
|
||||
|
||||
Orchestrators MAY render the two kinds differently — for example, by labelling agent findings or routing them to a separate review domain — and MAY apply independent severity floors. The `from-sub-skill: "agent"` marker is the contract.
|
||||
|
||||
## Why this architecture
|
||||
|
||||
- **Entry is the only hardcoded thing.** Orchestrators ship with one convention — *"invoke `/skills/entry.md` first"* — and nothing else. New action skills and new knowledge files are picked up automatically because Entry discovers them at dispatch time.
|
||||
- **Layers decide authority, not code.** The agent sees `/microsoft/` and `/community/` together; if two files conflict, the precedence rule defined in READ resolves it. A partner fork can disable `/community/` — that's a config choice, not a code change.
|
||||
- **Knowledge and skills evolve independently.** A new knowledge file requires no skill changes — existing skills pick it up via frontmatter filters. A new skill requires no knowledge changes — it sources from what's already there.
|
||||
|
||||
## The mental model, in one sentence
|
||||
|
||||
The orchestrator knows **when** to run; Entry decides **which skill** to run; the action skills define **what** to do; the meta-skills teach the agent **how** to behave; the knowledge files are **what** the agent knows.
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
permissionset 50100 "SALES REVIEW AGENT"
|
||||
{
|
||||
Assignable = true;
|
||||
Permissions =
|
||||
tabledata "Sales Header" = RIM,
|
||||
tabledata Customer = R,
|
||||
tabledata User = RIMD,
|
||||
tabledata "Access Control" = RIMD,
|
||||
page "Sales Order" = X,
|
||||
page "User Card" = X;
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
permissionset 50100 "SALES REVIEW AGENT"
|
||||
{
|
||||
Assignable = true;
|
||||
Permissions =
|
||||
tabledata "Sales Header" = RIM,
|
||||
tabledata Customer = R,
|
||||
page "Sales Order" = X;
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
bc-version: [27..]
|
||||
domain: agents
|
||||
keywords: [permissions, assigner, intersection, user-card, least-privilege]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Agent permissions intersect the assigner's; agents cannot configure users
|
||||
|
||||
## Description
|
||||
|
||||
An agent is a user, but it cannot configure users or other agents, and it cannot open sensitive pages such as user cards or permission-set assignment. Effective rights are the intersection of the assigning user's permissions and the agent's permission sets. Granting the agent a wide set does not bypass the assigner's limits, and a wide assigner still cannot give the agent user-admin powers the platform forbids.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Document that intersection. Give the agent only the table and page rights its tasks need. Do not add user-setup or permission-assignment pages to the agent profile or permission sets; those operations will fail by design.
|
||||
|
||||
See sample: [`agent-permissions-intersect-with-assigner.good.al`](agent-permissions-intersect-with-assigner.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Permission sets or profiles that include User card, Permission Set Assignment, or agent-admin pages, or comments that the agent runs as SUPER regardless of who assigned it. Detection signal: default access controls or profile including user-administration objects.
|
||||
|
||||
See sample: [`agent-permissions-intersect-with-assigner.bad.al`](agent-permissions-intersect-with-assigner.bad.al).
|
||||
|
||||
## See also
|
||||
|
||||
`get-default-access-controls-least-privilege.md` covers the permission sets assigned when an agent instance is created.
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
codeunit 50100 "Sales Review Agent Factory"
|
||||
{
|
||||
procedure GetDefaultProfile(var TempAllProfile: Record "All Profile" temporary)
|
||||
begin
|
||||
TempAllProfile."Profile ID" := 'BUSINESS MANAGER';
|
||||
TempAllProfile.Insert();
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
profile "SALES REVIEW AGENT"
|
||||
{
|
||||
Caption = 'Sales Review Agent';
|
||||
Description = 'Restricted UI for the Sales Review Agent.';
|
||||
RoleCenter = "Order Processor Role Center";
|
||||
Customizations = "Sales Review Agent Sales Ord.";
|
||||
}
|
||||
|
||||
pagecustomization "Sales Review Agent Sales Ord." customizes "Sales Order"
|
||||
{
|
||||
layout
|
||||
{
|
||||
modify("Payment Terms Code")
|
||||
{
|
||||
Visible = false;
|
||||
}
|
||||
}
|
||||
|
||||
actions
|
||||
{
|
||||
modify(Post)
|
||||
{
|
||||
Visible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
bc-version: [27..]
|
||||
domain: agents
|
||||
keywords: [profile, page-customization, hidden-actions, tooltip, role-center]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Give the agent a dedicated profile that hides unrelated UI
|
||||
|
||||
## Description
|
||||
|
||||
The agent only sees what its profile shows. Extra actions, views, and Role Center tiles become extra tools and extra tokens. Accuracy and cost both get worse as the UI widens. A human Order Processor profile is usually far too broad. Tooltips on the remaining actions are part of the tool description.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Ship an agent-specific profile and page customizations: hide unrelated actions, keep descriptive tooltips, add Role Center links to the few pages the agent should open. Prefer fewer navigation hops.
|
||||
|
||||
See sample: [`agent-profile-narrows-visible-ui.good.al`](agent-profile-narrows-visible-ui.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Assigning `BUSINESS MANAGER` or `ORDER PROCESSOR` as `GetDefaultProfile` so the agent can do anything. Detection signal: default profile equal to a full-user role with no agent page customizations.
|
||||
|
||||
See sample: [`agent-profile-narrows-visible-ui.bad.al`](agent-profile-narrows-visible-ui.bad.al).
|
||||
|
||||
## See also
|
||||
|
||||
`get-default-profile-lives-in-the-app.md` covers packaging and assigning the profile that this rule narrows.
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
page 50100 "Sales Review Agent Setup"
|
||||
{
|
||||
PageType = Card;
|
||||
Caption = 'Set up Sales Review Agent';
|
||||
SourceTable = "Sales Review Agent Setup";
|
||||
|
||||
layout
|
||||
{
|
||||
area(Content)
|
||||
{
|
||||
field(ReviewThreshold; Rec."Review Threshold")
|
||||
{
|
||||
ApplicationArea = All;
|
||||
Caption = 'Review Threshold';
|
||||
ToolTip = 'Specifies the threshold used when the agent requests a review.';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
page 50100 "Sales Review Agent Setup"
|
||||
{
|
||||
PageType = ConfigurationDialog;
|
||||
Caption = 'Set up Sales Review Agent';
|
||||
SourceTable = "Sales Review Agent Setup";
|
||||
SourceTableTemporary = true;
|
||||
Extensible = false;
|
||||
|
||||
layout
|
||||
{
|
||||
area(Content)
|
||||
{
|
||||
part(AgentSetupPart; "Agent Setup Part")
|
||||
{
|
||||
ApplicationArea = All;
|
||||
UpdatePropagation = Both;
|
||||
}
|
||||
group(AdditionalConfiguration)
|
||||
{
|
||||
Caption = 'Additional Configuration';
|
||||
field(ReviewThreshold; Rec."Review Threshold")
|
||||
{
|
||||
ApplicationArea = All;
|
||||
Caption = 'Review Threshold';
|
||||
ToolTip = 'Specifies the threshold used when the agent requests a review.';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [27..]
|
||||
domain: agents
|
||||
keywords: [configurationdialog, agent-setup-part, setup-page, pagetype, system-actions]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Agent setup pages use ConfigurationDialog and the Agent Setup Part
|
||||
|
||||
## Description
|
||||
|
||||
Instance setup is not a Card or StandardDialog. The toolkit expects `PageType = ConfigurationDialog` so OK and Cancel are system actions, plus the built-in `Agent Setup Part` for name, display name, state, and access. A Card with custom fields only drops those shared controls and the AI-use notices the part carries.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Declare `PageType = ConfigurationDialog`, host `part(...; "Agent Setup Part")`, and put agent-specific fields in another group. Keep system OK/Cancel. Use a temporary source record and defer persistence until Update, as described in `agent-setup-source-table-is-temporary.md`. Following Microsoft's agent setup samples, set `Extensible = false`.
|
||||
|
||||
See sample: [`agent-setup-page-is-configuration-dialog.good.al`](agent-setup-page-is-configuration-dialog.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
A Card or StandardDialog setup page with no `Agent Setup Part`. Detection signal: setup page ID from `IAgentFactory` / `IAgentMetadata` whose page is not `ConfigurationDialog` or has no `Agent Setup Part`.
|
||||
|
||||
See sample: [`agent-setup-page-is-configuration-dialog.bad.al`](agent-setup-page-is-configuration-dialog.bad.al).
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
page 50100 "Sales Review Agent Setup"
|
||||
{
|
||||
PageType = ConfigurationDialog;
|
||||
SourceTable = "Sales Review Agent Setup";
|
||||
|
||||
layout
|
||||
{
|
||||
area(Content)
|
||||
{
|
||||
field(ReviewThreshold; Rec."Review Threshold")
|
||||
{
|
||||
ApplicationArea = All;
|
||||
Caption = 'Review Threshold';
|
||||
ToolTip = 'Specifies the threshold used when the agent requests a review.';
|
||||
|
||||
trigger OnValidate()
|
||||
begin
|
||||
Rec.Modify(true);
|
||||
end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trigger OnOpenPage()
|
||||
begin
|
||||
if Rec.IsEmpty() then
|
||||
Rec.Insert(true);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
page 50100 "Sales Review Agent Setup"
|
||||
{
|
||||
PageType = ConfigurationDialog;
|
||||
SourceTable = "Sales Review Agent Setup";
|
||||
SourceTableTemporary = true;
|
||||
Extensible = false;
|
||||
|
||||
layout
|
||||
{
|
||||
area(Content)
|
||||
{
|
||||
part(AgentSetupPart; "Agent Setup Part")
|
||||
{
|
||||
ApplicationArea = All;
|
||||
UpdatePropagation = Both;
|
||||
}
|
||||
group(AdditionalConfiguration)
|
||||
{
|
||||
Caption = 'Additional Configuration';
|
||||
field(ReviewThreshold; Rec."Review Threshold")
|
||||
{
|
||||
ApplicationArea = All;
|
||||
Caption = 'Review Threshold';
|
||||
ToolTip = 'Specifies the threshold used when the agent requests a review.';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trigger OnOpenPage()
|
||||
var
|
||||
SalesReviewAgentSetup: Record "Sales Review Agent Setup";
|
||||
begin
|
||||
if IsNullGuid(Rec."User Security ID") then
|
||||
exit;
|
||||
if SalesReviewAgentSetup.Get(Rec."User Security ID") then
|
||||
Rec := SalesReviewAgentSetup;
|
||||
end;
|
||||
|
||||
trigger OnQueryClosePage(CloseAction: Action): Boolean
|
||||
var
|
||||
AgentSetup: Codeunit "Agent Setup";
|
||||
AgentSetupBuffer: Record "Agent Setup Buffer";
|
||||
begin
|
||||
if CloseAction = CloseAction::Cancel then
|
||||
exit(true);
|
||||
CurrPage.AgentSetupPart.Page.GetAgentSetupBuffer(AgentSetupBuffer);
|
||||
if AgentSetup.GetChangesMade(AgentSetupBuffer) then
|
||||
Rec."User Security ID" := AgentSetup.SaveChanges(AgentSetupBuffer);
|
||||
if IsNullGuid(Rec."User Security ID") then
|
||||
exit(true);
|
||||
SaveCustomProperties();
|
||||
exit(true);
|
||||
end;
|
||||
|
||||
local procedure SaveCustomProperties()
|
||||
var
|
||||
SalesReviewAgentSetup: Record "Sales Review Agent Setup";
|
||||
begin
|
||||
if not SalesReviewAgentSetup.Get(Rec."User Security ID") then begin
|
||||
SalesReviewAgentSetup.Init();
|
||||
SalesReviewAgentSetup."User Security ID" := Rec."User Security ID";
|
||||
SalesReviewAgentSetup.Insert(true);
|
||||
end;
|
||||
SalesReviewAgentSetup."Review Threshold" := Rec."Review Threshold";
|
||||
SalesReviewAgentSetup.Modify(true);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
bc-version: [27..]
|
||||
domain: agents
|
||||
keywords: [sourcetabletemporary, configurationdialog, savechanges, cancel, draft]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Keep the agent setup page source temporary until Update
|
||||
|
||||
## Description
|
||||
|
||||
ConfigurationDialog setup is a draft: the user can Cancel without writing. That only works if `SourceTableTemporary = true` and custom fields stay in memory until Update. Writing the real table in OnValidate or OnOpenPage commits a partial agent when the dialog errors or is cancelled.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Mark the page `SourceTableTemporary = true`. Copy into the temp record on open. Persist the Agent Setup buffer and custom fields only from the close path when the action is not Cancel, using `Agent Setup.GetChangesMade` / `SaveChanges`.
|
||||
|
||||
See sample: [`agent-setup-source-table-is-temporary.good.al`](agent-setup-source-table-is-temporary.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
A non-temporary source table, or `Insert`/`Modify` on the persisted setup row from field OnValidate. Detection signal: agent `ConfigurationDialog` without `SourceTableTemporary = true`, or database writes before Update.
|
||||
|
||||
See sample: [`agent-setup-source-table-is-temporary.bad.al`](agent-setup-source-table-is-temporary.bad.al).
|
||||
|
||||
## See also
|
||||
|
||||
`agent-setup-page-is-configuration-dialog.md` defines the setup page shape that uses this draft lifecycle.
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
table 50100 "Sales Review Agent Setup"
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Primary Key"; Code[10])
|
||||
{
|
||||
Caption = 'Primary Key';
|
||||
}
|
||||
field(10; "Review Threshold"; Decimal)
|
||||
{
|
||||
Caption = 'Review Threshold';
|
||||
}
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Primary Key")
|
||||
{
|
||||
Clustered = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
table 50100 "Sales Review Agent Setup"
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "User Security ID"; Guid)
|
||||
{
|
||||
Caption = 'User Security ID';
|
||||
DataClassification = EndUserPseudonymousIdentifiers;
|
||||
}
|
||||
field(10; "Review Threshold"; Decimal)
|
||||
{
|
||||
Caption = 'Review Threshold';
|
||||
}
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "User Security ID")
|
||||
{
|
||||
Clustered = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [27..]
|
||||
domain: agents
|
||||
keywords: [user-security-id, setup-table, primary-key, agent-instance, guid]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Agent setup tables are keyed by User Security ID
|
||||
|
||||
## Description
|
||||
|
||||
Each agent instance is a user. Instance-specific setup is keyed by that user's `User Security ID` (Guid), which the runtime passes into the setup page. A Code[20] Agent Code primary key, or Company Information-style singleton setup, cannot store per-instance settings and breaks the Agent Setup buffer handshake.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Give the setup table a Guid field `User Security ID` as the clustered primary key. Other settings are attributes of that key. When the page opens, `Get` or insert by the Guid the Agent Setup part already holds.
|
||||
|
||||
See sample: [`agent-setup-table-keyed-by-user-security-id.good.al`](agent-setup-table-keyed-by-user-security-id.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
A setup table keyed by Code, Integer, or with no Guid user key, then mapping one row to every instance. Detection signal: source table of the agent setup page whose primary key is not `User Security ID`.
|
||||
|
||||
See sample: [`agent-setup-table-keyed-by-user-security-id.bad.al`](agent-setup-table-keyed-by-user-security-id.bad.al).
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
codeunit 50100 "Sales Review Agent Task"
|
||||
{
|
||||
procedure AnalyzeAgentTaskMessage(AgentTaskMessage: Record "Agent Task Message"; var Annotations: Record "Agent Annotation")
|
||||
begin
|
||||
// No validation. Combined with SetRequiresReview(false) this auto-runs
|
||||
// untrusted input. Warnings are the only way to force a review later.
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
codeunit 50100 "Sales Review Agent Task"
|
||||
{
|
||||
procedure AnalyzeAgentTaskMessage(AgentTaskMessage: Record "Agent Task Message"; var Annotations: Record "Agent Annotation")
|
||||
var
|
||||
AgentMessage: Codeunit "Agent Message";
|
||||
EmptyMessageMsg: Label 'Message is empty.';
|
||||
EmptyMessageDetailsTxt: Label 'Provide a sales order task before running the agent.';
|
||||
NotRelevantMsg: Label 'Message is not a sales order task.';
|
||||
NotRelevantDetailsTxt: Label 'Provide a message related to sales order review.';
|
||||
MessageText: Text;
|
||||
begin
|
||||
if AgentTaskMessage.Type = AgentTaskMessage.Type::Output then begin
|
||||
AgentMessage.UpdateText(AgentTaskMessage, AgentMessage.GetText(AgentTaskMessage) + #13#10 + #13#10 + 'Written with the help of AI');
|
||||
exit;
|
||||
end;
|
||||
|
||||
MessageText := AgentMessage.GetText(AgentTaskMessage);
|
||||
if MessageText = '' then begin
|
||||
Clear(Annotations);
|
||||
Annotations.Code := 'MESSAGE001';
|
||||
Annotations.Severity := Annotations.Severity::Error;
|
||||
Annotations.Message := EmptyMessageMsg;
|
||||
Annotations.Details := EmptyMessageDetailsTxt;
|
||||
Annotations.Insert();
|
||||
exit;
|
||||
end;
|
||||
if not IsRelevant(MessageText) then begin
|
||||
Clear(Annotations);
|
||||
Annotations.Code := 'RELEVANCE001';
|
||||
Annotations.Severity := Annotations.Severity::Warning;
|
||||
Annotations.Message := NotRelevantMsg;
|
||||
Annotations.Details := NotRelevantDetailsTxt;
|
||||
Annotations.Insert();
|
||||
end;
|
||||
end;
|
||||
|
||||
local procedure IsRelevant(MessageText: Text): Boolean
|
||||
begin
|
||||
exit(StrPos(LowerCase(MessageText), 'sales order') > 0);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [27..]
|
||||
domain: agents
|
||||
keywords: [analyzeagenttaskmessage, agent-annotation, error, warning, setrequiresreview]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# AnalyzeAgentTaskMessage: Error stops the task; Warning still requires review
|
||||
|
||||
## Description
|
||||
|
||||
`IAgentTaskExecution.AnalyzeAgentTaskMessage` runs on inbound and outbound messages. An Error annotation stops processing. A Warning annotation requests user intervention. If analysis returns Warning, the platform still requires approval even when the incoming message used `SetRequiresReview(false)`. Output text can be rewritten here (signature, redaction).
|
||||
|
||||
## Best Practice
|
||||
|
||||
Validate inbound payloads in analysis: Error when the task must not run; Warning when a human must confirm. For outbound messages, adjust text in this method rather than in a later subscriber. Do not rely on skip-review to bypass warnings.
|
||||
|
||||
See sample: [`analyze-message-error-stops-warning-forces-review.good.al`](analyze-message-error-stops-warning-forces-review.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Ignoring analysis entirely, or emitting Warning while documenting that `SetRequiresReview(false)` means unattended run. Detection signal: empty `AnalyzeAgentTaskMessage` plus skip-review on external input.
|
||||
|
||||
See sample: [`analyze-message-error-stops-warning-forces-review.bad.al`](analyze-message-error-stops-warning-forces-review.bad.al).
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
codeunit 50101 "Sales Review Agent Events"
|
||||
{
|
||||
[EventSubscriber(ObjectType::Table, Database::"Sales Header", OnAfterInsertEvent, '', false, false)]
|
||||
local procedure OnAfterInsertSalesHeader(var Rec: Record "Sales Header")
|
||||
begin
|
||||
// Runs for every user session, not only the agent.
|
||||
Message('Keep going, agent.');
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
codeunit 50101 "Sales Review Agent Subscribers"
|
||||
{
|
||||
Access = Internal;
|
||||
EventSubscriberInstance = Manual;
|
||||
SingleInstance = true;
|
||||
|
||||
[EventSubscriber(ObjectType::Table, Database::"Sales Header", OnAfterInsertEvent, '', false, false)]
|
||||
local procedure OnAfterInsertSalesHeader(var Rec: Record "Sales Header")
|
||||
begin
|
||||
Message('Keep going, agent.');
|
||||
end;
|
||||
}
|
||||
|
||||
codeunit 50102 "Agent Session Events"
|
||||
{
|
||||
Access = Internal;
|
||||
SingleInstance = true;
|
||||
InherentEntitlements = X;
|
||||
InherentPermissions = X;
|
||||
|
||||
var
|
||||
GlobalAgentSubscribers: Codeunit "Sales Review Agent Subscribers";
|
||||
|
||||
[EventSubscriber(ObjectType::Codeunit, Codeunit::"System Initialization", OnAfterInitialization, '', false, false)]
|
||||
local procedure RegisterSubscribersOnAfterInitialization()
|
||||
var
|
||||
AgentSession: Codeunit "Agent Session";
|
||||
AgentMetadataProvider: Enum "Agent Metadata Provider";
|
||||
begin
|
||||
if not AgentSession.IsAgentSession(AgentMetadataProvider) then
|
||||
exit;
|
||||
if BindSubscription(GlobalAgentSubscribers) then;
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [27..]
|
||||
domain: agents
|
||||
keywords: [agent-session, isagentsession, bindsubscription, system-initialization, singleinstance]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Bind extra agent subscribers only inside an agent session
|
||||
|
||||
## Description
|
||||
|
||||
Page-filter tweaks, extra validation, and prompt dialogs for the agent should not run for every user. `Agent Session.IsAgentSession` distinguishes agent UI sessions. Binding those subscribers on `System Initialization` only when the session is an agent session avoids global subscriber cost. Models register `SingleInstance` table subscribers unconditionally.
|
||||
|
||||
## Best Practice
|
||||
|
||||
On `OnAfterInitialization`, exit unless `Agent Session.IsAgentSession`. Then `BindSubscription` a single-instance codeunit that holds the current task id. Keep those subscribers internal.
|
||||
|
||||
See sample: [`bind-agent-subscribers-only-in-agent-session.good.al`](bind-agent-subscribers-only-in-agent-session.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Event subscribers on `Sales Header` OnAfterInsert that always `Message` the agent, with no `IsAgentSession` guard. Detection signal: agent-only behaviour in a static subscriber that is not bind-gated.
|
||||
|
||||
See sample: [`bind-agent-subscribers-only-in-agent-session.bad.al`](bind-agent-subscribers-only-in-agent-session.bad.al).
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
codeunit 50110 "Other App Agent Hook"
|
||||
{
|
||||
procedure RenameForeignAgent(AgentUserSecurityId: Guid)
|
||||
var
|
||||
Agent: Codeunit Agent;
|
||||
begin
|
||||
// Fails at runtime when the instance was defined in another app.
|
||||
Agent.SetDisplayName(AgentUserSecurityId, 'Updated Name');
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
codeunit 50110 "Sales Review Agent API"
|
||||
{
|
||||
Access = Public;
|
||||
|
||||
procedure SetDisplayName(AgentUserSecurityId: Guid; NewDisplayName: Text[80])
|
||||
var
|
||||
Agent: Codeunit Agent;
|
||||
begin
|
||||
Agent.SetDisplayName(AgentUserSecurityId, NewDisplayName);
|
||||
end;
|
||||
|
||||
procedure SetActiveState(AgentUserSecurityId: Guid; ActivateAgent: Boolean)
|
||||
var
|
||||
Agent: Codeunit Agent;
|
||||
begin
|
||||
if ActivateAgent then
|
||||
Agent.Activate(AgentUserSecurityId)
|
||||
else
|
||||
Agent.Deactivate(AgentUserSecurityId);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [27..]
|
||||
domain: agents
|
||||
keywords: [cross-app, public-api, agent-create, isolation, access-public]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Other apps cannot call the toolkit APIs on your agent; publish your own API
|
||||
|
||||
## Description
|
||||
|
||||
For isolation, `Agent`, `Agent Task Builder`, and related toolkit codeunits error when the target instance belongs to another app. There is no supported way to pass another extension's metadata provider into `SetInstructions` or `Create`. Partners who need to enqueue work must call a public API you own.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Expose a public codeunit in the agent app (`Access = Public`) whose procedures take `User Security ID` and forward to `Agent` / `Agent Task Builder`. Document that surface as the integration contract. Keep toolkit calls inside that app.
|
||||
|
||||
See sample: [`cross-app-agent-calls-need-your-public-api.good.al`](cross-app-agent-calls-need-your-public-api.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
From app B, calling `Agent.SetDisplayName` or `Agent.Create` with app A's metadata provider. Detection signal: toolkit agent APIs used with an `Agent Metadata Provider` value not declared in the same app.
|
||||
|
||||
See sample: [`cross-app-agent-calls-need-your-public-api.bad.al`](cross-app-agent-calls-need-your-public-api.bad.al).
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
codeunit 50100 "Sales Review Agent Install"
|
||||
{
|
||||
Subtype = Install;
|
||||
|
||||
trigger OnInstallAppPerCompany()
|
||||
var
|
||||
Agent: Codeunit Agent;
|
||||
TempAgentAccessControl: Record "Agent Access Control" temporary;
|
||||
AgentUserSecurityId: Guid;
|
||||
begin
|
||||
// Create requires an interactive session. Install is not one.
|
||||
AgentUserSecurityId := Agent.Create(
|
||||
Enum::"Agent Metadata Provider"::"Sales Review Agent",
|
||||
'SALESREVIEW',
|
||||
'Sales Review Agent',
|
||||
TempAgentAccessControl);
|
||||
Agent.Activate(AgentUserSecurityId);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
page 50100 "Sales Review Agent Setup"
|
||||
{
|
||||
PageType = ConfigurationDialog;
|
||||
ApplicationArea = All;
|
||||
SourceTable = "Sales Review Agent Setup";
|
||||
SourceTableTemporary = true;
|
||||
Extensible = false;
|
||||
|
||||
layout
|
||||
{
|
||||
area(Content)
|
||||
{
|
||||
part(AgentSetupPart; "Agent Setup Part")
|
||||
{
|
||||
ApplicationArea = All;
|
||||
UpdatePropagation = Both;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trigger OnQueryClosePage(CloseAction: Action): Boolean
|
||||
var
|
||||
Agent: Codeunit Agent;
|
||||
AgentSetup: Codeunit "Agent Setup";
|
||||
TempAgentSetupBuffer: Record "Agent Setup Buffer" temporary;
|
||||
AgentUserSecurityId: Guid;
|
||||
begin
|
||||
if CloseAction = CloseAction::Cancel then
|
||||
exit(true);
|
||||
|
||||
CurrPage.AgentSetupPart.Page.GetAgentSetupBuffer(TempAgentSetupBuffer);
|
||||
AgentUserSecurityId := AgentSetup.SaveChanges(TempAgentSetupBuffer);
|
||||
Agent.SetInstructions(AgentUserSecurityId, GetInstructions());
|
||||
Agent.Activate(AgentUserSecurityId);
|
||||
exit(true);
|
||||
end;
|
||||
|
||||
local procedure GetInstructions() Instructions: SecretText
|
||||
var
|
||||
InstructionsNameTxt: Label 'Instructions.txt', Locked = true;
|
||||
begin
|
||||
Instructions := NavApp.GetResourceAsText(InstructionsNameTxt);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [27..]
|
||||
domain: agents
|
||||
keywords: [agent-create, install, upgrade, job-queue, interactive-session, background]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Do not create agent instances from install, upgrade, or background sessions
|
||||
|
||||
## Description
|
||||
|
||||
`Agent.Create` requires an interactive user session. The platform blocks creation from install codeunits, upgrade codeunits, and background sessions (job queue, scheduled tasks). Packaging an agent in an app does not mean spinning up instances at install. Models still call `Create` from `OnInstallAppPerCompany` to activate the agent.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Create instances from a setup page, a wizard, or another UI-driven path after the user is in a client session. Apply instructions and `Activate` there. For existing companies after an upgrade, document that an admin must open setup; do not create from the upgrade codeunit.
|
||||
|
||||
See sample: [`do-not-create-agents-in-install-upgrade-or-background.good.al`](do-not-create-agents-in-install-upgrade-or-background.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
`Agent.Create` inside `OnInstallAppPerCompany`, `OnUpgradePerCompany`, or a job-queue codeunit. The call fails at runtime even if it compiles. Detection signal: `Agent.Create` in `Subtype = Install`, `Subtype = Upgrade`, or a non-UI session.
|
||||
|
||||
See sample: [`do-not-create-agents-in-install-upgrade-or-background.bad.al`](do-not-create-agents-in-install-upgrade-or-background.bad.al).
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
codeunit 50100 "Sales Review Agent Factory"
|
||||
{
|
||||
procedure GetDefaultAccessControls(var TempAccessControlBuffer: Record "Access Control Buffer" temporary)
|
||||
var
|
||||
BaseApplicationAppIdTok: Label '437dbf0e-84ff-417a-965d-ed2bb9650972', Locked = true;
|
||||
begin
|
||||
Clear(TempAccessControlBuffer);
|
||||
TempAccessControlBuffer."Company Name" := CopyStr(CompanyName(), 1, MaxStrLen(TempAccessControlBuffer."Company Name"));
|
||||
TempAccessControlBuffer.Scope := TempAccessControlBuffer.Scope::System;
|
||||
TempAccessControlBuffer."App ID" := BaseApplicationAppIdTok;
|
||||
TempAccessControlBuffer."Role ID" := 'D365 BUS FULL ACCESS';
|
||||
TempAccessControlBuffer.Insert();
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
permissionset 50100 "SALES REVIEW AGENT"
|
||||
{
|
||||
Assignable = true;
|
||||
Caption = 'Sales Review Agent';
|
||||
Permissions =
|
||||
tabledata "Sales Header" = R,
|
||||
tabledata "Sales Line" = R;
|
||||
}
|
||||
|
||||
codeunit 50100 "Sales Review Agent Factory"
|
||||
{
|
||||
procedure GetDefaultAccessControls(var TempAccessControlBuffer: Record "Access Control Buffer" temporary)
|
||||
var
|
||||
CurrentModuleInfo: ModuleInfo;
|
||||
RoleIdTok: Label 'SALES REVIEW AGENT', Locked = true;
|
||||
begin
|
||||
NavApp.GetCurrentModuleInfo(CurrentModuleInfo);
|
||||
Clear(TempAccessControlBuffer);
|
||||
TempAccessControlBuffer."Company Name" := CopyStr(CompanyName(), 1, MaxStrLen(TempAccessControlBuffer."Company Name"));
|
||||
TempAccessControlBuffer.Scope := TempAccessControlBuffer.Scope::System;
|
||||
TempAccessControlBuffer."App ID" := CurrentModuleInfo.Id;
|
||||
TempAccessControlBuffer."Role ID" := RoleIdTok;
|
||||
TempAccessControlBuffer.Insert();
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
bc-version: [27..]
|
||||
domain: agents
|
||||
keywords: [getdefaultaccesscontrols, access-control-buffer, permissionset, least-privilege, iagentfactory]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Default agent permission sets must exist in AL and stay least privilege
|
||||
|
||||
## Description
|
||||
|
||||
`IAgentFactory.GetDefaultAccessControls` fills a temporary `Access Control Buffer` used when an instance is created. Permission sets that exist only as user-created sets in a sandbox are missing in the next environment. Granting `D365 BUS FULL ACCESS` or SUPER gives the agent a user-sized blast radius. Effective rights are still the intersection with the assigning user's permissions.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Insert only the permission sets the agent needs. For an AL `permissionset` object, use `Scope::System` and the ID of the app that defines it. Recreate permission sets that exist only as user-defined configuration in Business Central as AL objects first. Prefer a dedicated permission set over a full-user role.
|
||||
|
||||
See sample: [`get-default-access-controls-least-privilege.good.al`](get-default-access-controls-least-privilege.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Empty `GetDefaultAccessControls`, or inserting `SUPER` / `D365 BUS FULL ACCESS` because it made the demo work. Detection signal: Role ID on the default buffer that is a full-user role, or a set that is not in the app.
|
||||
|
||||
See sample: [`get-default-access-controls-least-privilege.bad.al`](get-default-access-controls-least-privilege.bad.al).
|
||||
|
||||
## See also
|
||||
|
||||
`agent-permissions-intersect-with-assigner.md` explains the platform limits that still apply after default access controls are assigned.
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
codeunit 50100 "Sales Review Agent Factory"
|
||||
{
|
||||
procedure GetDefaultProfile(var TempAllProfile: Record "All Profile" temporary)
|
||||
begin
|
||||
// Profile exists only as a user personalization in the design sandbox.
|
||||
TempAllProfile."Profile ID" := 'SALES REVIEW SANDBOX';
|
||||
TempAllProfile.Insert();
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
profile "SALES REVIEW AGENT"
|
||||
{
|
||||
Caption = 'Sales Review Agent';
|
||||
Description = 'UI surface for the Sales Review Agent.';
|
||||
RoleCenter = "Order Processor Role Center";
|
||||
Customizations = "Sales Review Agent Sales Ord.";
|
||||
}
|
||||
|
||||
codeunit 50100 "Sales Review Agent Factory"
|
||||
{
|
||||
procedure GetDefaultProfile(var TempAllProfile: Record "All Profile" temporary)
|
||||
var
|
||||
Agent: Codeunit Agent;
|
||||
CurrentModuleInfo: ModuleInfo;
|
||||
DefaultProfileTok: Label 'SALES REVIEW AGENT', Locked = true;
|
||||
begin
|
||||
NavApp.GetCurrentModuleInfo(CurrentModuleInfo);
|
||||
Agent.PopulateDefaultProfile(DefaultProfileTok, CurrentModuleInfo.Id, TempAllProfile);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
bc-version: [27..]
|
||||
domain: agents
|
||||
keywords: [getdefaultprofile, profile, page-customization, populatedefaultprofile, role-center]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# The default agent profile must be an AL profile in the app
|
||||
|
||||
## Description
|
||||
|
||||
`IAgentFactory.GetDefaultProfile` assigns the Role Center and page customizations the agent UI-navigates. A profile built only in the client, or page personalization that was never exported, is absent after deploy. `Agent.PopulateDefaultProfile` still needs a profile ID that exists in the current module.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Ship a `profile` object (and page customizations) in the app. In `GetDefaultProfile`, call `Agent.PopulateDefaultProfile` with that profile ID and `NavApp.GetCurrentModuleInfo`. Include UI-exported customizations as AL.
|
||||
|
||||
See sample: [`get-default-profile-lives-in-the-app.good.al`](get-default-profile-lives-in-the-app.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Setting `TempAllProfile."Profile ID"` to a client-only profile, or skipping `GetDefaultProfile`. Detection signal: factory default profile ID with no matching `profile` object in the app.
|
||||
|
||||
See sample: [`get-default-profile-lives-in-the-app.bad.al`](get-default-profile-lives-in-the-app.bad.al).
|
||||
|
||||
## See also
|
||||
|
||||
`agent-profile-narrows-visible-ui.md` explains which UI the app-owned profile should expose.
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
codeunit 50100 "Sales Review Agent Instr."
|
||||
{
|
||||
procedure GetInstructions() Instructions: SecretText
|
||||
var
|
||||
PromptLbl: Label 'Check customer credit for the given sales order. Document the result.', Locked = true;
|
||||
begin
|
||||
Instructions := PromptLbl;
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
codeunit 50100 "Sales Review Agent Instr."
|
||||
{
|
||||
procedure GetInstructions() Instructions: SecretText
|
||||
var
|
||||
Builder: TextBuilder;
|
||||
begin
|
||||
Builder.AppendLine('# Responsibilities');
|
||||
Builder.AppendLine('You validate sales orders against customer credit and hold status.');
|
||||
Builder.AppendLine('# Guidelines');
|
||||
Builder.AppendLine('Always request a review before posting or sending external mail.');
|
||||
Builder.AppendLine('# Instructions');
|
||||
Builder.AppendLine('1. Open the sales order named in the task.');
|
||||
Builder.AppendLine('2. Check credit limit and overdue balance.');
|
||||
Builder.AppendLine('3. Document the result on the order and request a review.');
|
||||
Instructions := Builder.ToText();
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
bc-version: [27..]
|
||||
domain: agents
|
||||
keywords: [instructions, responsibilities, guidelines, steps, setinstructions]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Instruction documents use responsibilities, guidelines, then ordered steps
|
||||
|
||||
## Description
|
||||
|
||||
The runtime treats instructions as the agent's standing prompt. A one-line goal produces inconsistent navigation. Microsoft's instruction framework is three layers: responsibilities (what the agent owns), guidelines (rules for every task), and instructions (ordered steps per task, with substeps). That structure is BC-specific, not generic prompt flavour.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Store a document that states responsibilities, then non-negotiable guidelines (when to request a review, when not to post), then numbered steps for each task. Keep that text in the resource you pass to `SetInstructions`.
|
||||
|
||||
See sample: [`instruction-structure-is-role-rules-steps.good.al`](instruction-structure-is-role-rules-steps.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
A single sentence such as Check customer credit for the sales order. Detection signal: instruction resource or `SetInstructions` payload with no responsibilities / guidelines / steps sections.
|
||||
|
||||
See sample: [`instruction-structure-is-role-rules-steps.bad.al`](instruction-structure-is-role-rules-steps.bad.al).
|
||||
|
||||
## See also
|
||||
|
||||
`instructions-describe-work-not-tool-ids.md` and `use-documented-instruction-keywords.md` define how to write the steps inside this structure.
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
codeunit 50100 "Sales Review Agent Instr."
|
||||
{
|
||||
procedure GetInstructions() Instructions: SecretText
|
||||
begin
|
||||
Instructions := 'Open page 42. Invoke action Post_Promoted. Use tool SalesOrder.CreditCheck_v3.';
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
codeunit 50100 "Sales Review Agent Instr."
|
||||
{
|
||||
procedure GetInstructions() Instructions: SecretText
|
||||
var
|
||||
Builder: TextBuilder;
|
||||
begin
|
||||
Builder.AppendLine('Memorize the sales order number from the task.');
|
||||
Builder.AppendLine('Set the order on hold when credit fails, with a reason.');
|
||||
Builder.AppendLine('When credit passes, request a review before posting the order.');
|
||||
Instructions := Builder.ToText();
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
bc-version: [27..]
|
||||
domain: agents
|
||||
keywords: [instructions, tools, invoke-action, memorize, page-actions]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Instructions describe outcomes, not page action or tool names
|
||||
|
||||
## Description
|
||||
|
||||
Agent tools are the UI the profile exposes. Action names and tool ids change across pages and versions. Best-practice guidance is to say what to accomplish, not which tool to invoke. Page state is also not fully in history; values needed later must be memorized. Models paste Promoted action names into the prompt.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Write steps as business outcomes (release the order, set the hold reason). Tell the agent to memorize identifiers it must reuse. Do not hard-code action captions or tool ids.
|
||||
|
||||
See sample: [`instructions-describe-work-not-tool-ids.good.al`](instructions-describe-work-not-tool-ids.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Instructions that say invoke SalesOrder.Post_Promoted or use tool page-42-action-3. Detection signal: instruction text containing Promoted action names or tool identifiers.
|
||||
|
||||
See sample: [`instructions-describe-work-not-tool-ids.bad.al`](instructions-describe-work-not-tool-ids.bad.al).
|
||||
|
||||
## See also
|
||||
|
||||
`instruction-structure-is-role-rules-steps.md` defines the containing document structure, and `use-documented-instruction-keywords.md` identifies runtime-recognized phrases.
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
codeunit 50100 "Sales Review Agent Create"
|
||||
{
|
||||
procedure CreateWithInstructions()
|
||||
var
|
||||
Agent: Codeunit Agent;
|
||||
TempAgentAccessControl: Record "Agent Access Control" temporary;
|
||||
AgentUserSecurityId: Guid;
|
||||
InstructionsNameTxt: Label 'Instructions.txt', Locked = true;
|
||||
begin
|
||||
AgentUserSecurityId := Agent.Create(
|
||||
Enum::"Agent Metadata Provider"::"Sales Review Agent",
|
||||
'SALESREVIEW',
|
||||
'Sales Review Agent',
|
||||
TempAgentAccessControl);
|
||||
// Only new instances get the resource. Upgrades never re-apply it.
|
||||
Agent.SetInstructions(AgentUserSecurityId, NavApp.GetResourceAsText(InstructionsNameTxt));
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
codeunit 50100 "Sales Review Agent Upgrade"
|
||||
{
|
||||
Subtype = Upgrade;
|
||||
|
||||
trigger OnUpgradePerCompany()
|
||||
var
|
||||
Agent: Codeunit Agent;
|
||||
UpgradeTag: Codeunit "Upgrade Tag";
|
||||
Instructions: SecretText;
|
||||
AgentUserSecurityIds: List of [Guid];
|
||||
AgentUserSecurityId: Guid;
|
||||
TagTxt: Label 'SALESREVIEW-INSTR-2.0.0', Locked = true;
|
||||
InstructionsNameTxt: Label 'Instructions.txt', Locked = true;
|
||||
begin
|
||||
if UpgradeTag.HasUpgradeTag(TagTxt) then
|
||||
exit;
|
||||
Instructions := NavApp.GetResourceAsText(InstructionsNameTxt);
|
||||
AgentUserSecurityIds := GetExistingAgentUserIds();
|
||||
foreach AgentUserSecurityId in AgentUserSecurityIds do
|
||||
Agent.SetInstructions(AgentUserSecurityId, Instructions);
|
||||
UpgradeTag.SetUpgradeTag(TagTxt);
|
||||
end;
|
||||
|
||||
local procedure GetExistingAgentUserIds() AgentUserSecurityIds: List of [Guid]
|
||||
var
|
||||
SalesReviewAgentSetup: Record "Sales Review Agent Setup";
|
||||
begin
|
||||
if SalesReviewAgentSetup.FindSet() then
|
||||
repeat
|
||||
AgentUserSecurityIds.Add(SalesReviewAgentSetup."User Security ID");
|
||||
until SalesReviewAgentSetup.Next() = 0;
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [27..]
|
||||
domain: agents
|
||||
keywords: [upgrade, setinstructions, navapp-getresourceastext, existing-instances, upgrade-tag]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Reapply resource instructions to existing agent instances on upgrade
|
||||
|
||||
## Description
|
||||
|
||||
Static instructions stored as an app resource are copied onto an instance only when you call `SetInstructions`. Shipping a new `Instructions.txt` in version 2.0 does not update agents created under 1.0. Models change the resource and assume running instances pick it up.
|
||||
|
||||
## Best Practice
|
||||
|
||||
In the upgrade codeunit, find existing instances of your metadata provider and call `SetInstructions` again with `NavApp.GetResourceAsText`. Guard with an upgrade tag so the rewrite runs once per version that changes the file.
|
||||
|
||||
See sample: [`reapply-resource-instructions-on-upgrade.good.al`](reapply-resource-instructions-on-upgrade.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Editing only the resource file, or calling `SetInstructions` solely from the first-time setup path. Detection signal: instruction resource in `resourceFolders` with no upgrade procedure that re-applies it.
|
||||
|
||||
See sample: [`reapply-resource-instructions-on-upgrade.bad.al`](reapply-resource-instructions-on-upgrade.bad.al).
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
enumextension 50100 "Sales Review Agent Metadata" extends "Agent Metadata Provider"
|
||||
{
|
||||
value(50100; "Sales Review Agent")
|
||||
{
|
||||
Caption = 'Sales Review Agent';
|
||||
Implementation = IAgentFactory = "Sales Review Agent Factory",
|
||||
IAgentMetadata = "Sales Review Agent Metadata",
|
||||
IAgentTaskExecution = "Sales Review Agent Task";
|
||||
}
|
||||
}
|
||||
|
||||
codeunit 50101 "Sales Review Agent Install"
|
||||
{
|
||||
Subtype = Install;
|
||||
Access = Internal;
|
||||
|
||||
trigger OnInstallAppPerDatabase()
|
||||
begin
|
||||
// Agent type exists, but no Copilot Capability value and no RegisterCapability.
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
enumextension 50101 "Sales Review Agent Copilot" extends "Copilot Capability"
|
||||
{
|
||||
value(50101; "Sales Review Agent")
|
||||
{
|
||||
Caption = 'Sales Review Agent';
|
||||
}
|
||||
}
|
||||
|
||||
codeunit 50101 "Sales Review Agent Install"
|
||||
{
|
||||
Subtype = Install;
|
||||
Access = Internal;
|
||||
|
||||
trigger OnInstallAppPerDatabase()
|
||||
var
|
||||
CopilotCapability: Codeunit "Copilot Capability";
|
||||
LearnMoreUrlTxt: Label 'https://example.com/sales-review-agent', Locked = true;
|
||||
begin
|
||||
if not CopilotCapability.IsCapabilityRegistered(Enum::"Copilot Capability"::"Sales Review Agent") then
|
||||
CopilotCapability.RegisterCapability(
|
||||
Enum::"Copilot Capability"::"Sales Review Agent",
|
||||
Enum::"Copilot Availability"::Preview,
|
||||
Enum::"Copilot Billing Type"::"Microsoft Billed",
|
||||
LearnMoreUrlTxt);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
bc-version: [27..]
|
||||
domain: agents
|
||||
keywords: [copilot-capability, registercapability, install, feature-switch, enumextension]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Register a Copilot capability for the agent on install
|
||||
|
||||
## Description
|
||||
|
||||
Each agent type needs a `Copilot Capability` enum value that the factory links as the feature switch and billing surface. The capability is invisible on Copilot and agent capabilities until an install codeunit calls `RegisterCapability` when it is not already registered. Unique ordinals matter across installed apps. Models often extend `Agent Metadata Provider` and never register the capability.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Extend `Copilot Capability` with a unique value. In `OnInstallAppPerDatabase`, call `Copilot Capability.IsCapabilityRegistered` and, if false, `RegisterCapability` with availability, billing type, and a learn-more URL. Point `IAgentFactory` at that capability.
|
||||
|
||||
See sample: [`register-copilot-capability-for-the-agent.good.al`](register-copilot-capability-for-the-agent.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Shipping the agent enum without a `Copilot Capability` value, or adding the enum but never calling `RegisterCapability`. Duplicate ordinals across extensions also collide. Detection signal: agent metadata provider with no matching capability registration in an install codeunit.
|
||||
|
||||
See sample: [`register-copilot-capability-for-the-agent.bad.al`](register-copilot-capability-for-the-agent.bad.al).
|
||||
|
||||
## See also
|
||||
|
||||
`wire-all-three-agent-interfaces.md` covers registration of the provider implementation that references this capability.
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
codeunit 50100 "Sales Review Agent Create"
|
||||
{
|
||||
procedure CreateWithInstructions()
|
||||
var
|
||||
Agent: Codeunit Agent;
|
||||
TempAgentAccessControl: Record "Agent Access Control" temporary;
|
||||
AgentUserSecurityId: Guid;
|
||||
InstructionsLbl: Label 'You are a sales validation agent. Check credit.', Locked = true;
|
||||
begin
|
||||
AgentUserSecurityId := Agent.Create(
|
||||
Enum::"Agent Metadata Provider"::"Sales Review Agent",
|
||||
'SALESREVIEW',
|
||||
'Sales Review Agent',
|
||||
TempAgentAccessControl);
|
||||
// Label/text is not SecretText and is type-wide, not per instance.
|
||||
Agent.SetInstructions(AgentUserSecurityId, InstructionsLbl);
|
||||
Agent.Activate(AgentUserSecurityId);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
codeunit 50100 "Sales Review Agent Create"
|
||||
{
|
||||
procedure CreateWithInstructions()
|
||||
var
|
||||
Agent: Codeunit Agent;
|
||||
TempAgentAccessControl: Record "Agent Access Control" temporary;
|
||||
AgentUserSecurityId: Guid;
|
||||
Instructions: SecretText;
|
||||
InstructionsNameTxt: Label 'Instructions.txt', Locked = true;
|
||||
begin
|
||||
AgentUserSecurityId := Agent.Create(
|
||||
Enum::"Agent Metadata Provider"::"Sales Review Agent",
|
||||
'SALESREVIEW',
|
||||
'Sales Review Agent',
|
||||
TempAgentAccessControl);
|
||||
Instructions := NavApp.GetResourceAsText(InstructionsNameTxt);
|
||||
Agent.SetInstructions(AgentUserSecurityId, Instructions);
|
||||
Agent.Activate(AgentUserSecurityId);
|
||||
end;
|
||||
}
|
||||
26
community/knowledge/agents/set-instructions-as-secrettext.md
Normal file
26
community/knowledge/agents/set-instructions-as-secrettext.md
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [27..]
|
||||
domain: agents
|
||||
keywords: [setinstructions, secrettext, instructions, per-instance, resource]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Set agent instructions as SecretText on the instance
|
||||
|
||||
## Description
|
||||
|
||||
Instructions are instance data, not an enum caption. `Agent.SetInstructions` takes `SecretText` so the payload is not logged or copied as ordinary text. A Label or plaintext Text on the agent type is the wrong store: it leaks into telemetry-friendly strings and cannot vary per instance or company.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Load instruction text from a resource or builder into a `SecretText` variable and call `Agent.SetInstructions(AgentUserSecurityId, Instructions)` after `Create`. Keep one instruction document per instance.
|
||||
|
||||
See sample: [`set-instructions-as-secrettext.good.al`](set-instructions-as-secrettext.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Passing a `Label` or `Text` to `SetInstructions`, storing instructions in a setup Text field without wrapping as `SecretText`, or putting the prompt only in a code comment. Detection signal: `SetInstructions` with a non-`SecretText` argument, or no `SetInstructions` after `Create`.
|
||||
|
||||
See sample: [`set-instructions-as-secrettext.bad.al`](set-instructions-as-secrettext.bad.al).
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
codeunit 50100 "Sales Review Agent Factory"
|
||||
{
|
||||
procedure ShowCanCreateAgent(): Boolean
|
||||
begin
|
||||
// Author intends this to forbid all creates. It only hides the UI tile.
|
||||
exit(false);
|
||||
end;
|
||||
}
|
||||
|
||||
pageextension 50100 "Sales Order List Agent Create" extends "Sales Order List"
|
||||
{
|
||||
actions
|
||||
{
|
||||
addlast(Processing)
|
||||
{
|
||||
action(CreateAgent)
|
||||
{
|
||||
ApplicationArea = All;
|
||||
Caption = 'Create review agent';
|
||||
|
||||
trigger OnAction()
|
||||
var
|
||||
Agent: Codeunit Agent;
|
||||
TempAgentAccessControl: Record "Agent Access Control" temporary;
|
||||
begin
|
||||
// Still succeeds for any caller with permission to run this action.
|
||||
Agent.Create(
|
||||
Enum::"Agent Metadata Provider"::"Sales Review Agent",
|
||||
'SALESREVIEW',
|
||||
'Sales Review Agent',
|
||||
TempAgentAccessControl);
|
||||
end;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
codeunit 50100 "Sales Review Agent Factory"
|
||||
{
|
||||
procedure ShowCanCreateAgent(): Boolean
|
||||
var
|
||||
AgentSystemPermissions: Codeunit "Agent System Permissions";
|
||||
begin
|
||||
// Hides the type from non-admins in the UI. Does not block Agent.Create.
|
||||
exit(AgentSystemPermissions.CurrentUserHasCanManageAllAgentsPermission());
|
||||
end;
|
||||
|
||||
procedure CreateIfAllowed()
|
||||
var
|
||||
Agent: Codeunit Agent;
|
||||
AgentSystemPermissions: Codeunit "Agent System Permissions";
|
||||
TempAgentAccessControl: Record "Agent Access Control" temporary;
|
||||
begin
|
||||
if not AgentSystemPermissions.CurrentUserHasCanManageAllAgentsPermission() then
|
||||
Error('Only agent administrators can create this agent.');
|
||||
Agent.Create(
|
||||
Enum::"Agent Metadata Provider"::"Sales Review Agent",
|
||||
'SALESREVIEW',
|
||||
'Sales Review Agent',
|
||||
TempAgentAccessControl);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [28..]
|
||||
domain: agents
|
||||
keywords: [showcancreateagent, agent-discovery, agent-create, administrator, agent-configuration-rights]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# ShowCanCreateAgent only hides UI create, not programmatic create
|
||||
|
||||
## Description
|
||||
|
||||
`IAgentFactory.ShowCanCreateAgent` controls whether the type appears in the in-client create UI. Returning false does not stop `Agent.Create` from AL. From 28.1, non-admins can discover extension agents unless this method (and agent configuration rights) restrict them. Models treat a false return as a hard create lock.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Use `ShowCanCreateAgent` to decide discovery. If only agent administrators should see the type, return `Agent System Permissions.CurrentUserHasCanManageAllAgentsPermission`. Enforce extra policy inside your own create API. Never assume UI hiding blocks code.
|
||||
|
||||
See sample: [`show-can-create-agent-does-not-block-code-create.good.al`](show-can-create-agent-does-not-block-code-create.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Returning `exit(false)` from `ShowCanCreateAgent` and then documenting that instances cannot be created, while page actions or other apps still call `Agent.Create`. Detection signal: `ShowCanCreateAgent` always false with no matching guard on programmatic create.
|
||||
|
||||
See sample: [`show-can-create-agent-does-not-block-code-create.bad.al`](show-can-create-agent-does-not-block-code-create.bad.al).
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
codeunit 50100 "Sales Review Agent Tasks"
|
||||
{
|
||||
procedure EnqueueFromEmailBody(RawEmailBody: Text; AgentUserSecurityId: Guid)
|
||||
var
|
||||
AgentTaskBuilder: Codeunit "Agent Task Builder";
|
||||
AgentTaskMessageBuilder: Codeunit "Agent Task Message Builder";
|
||||
AgentTask: Record "Agent Task";
|
||||
begin
|
||||
AgentTaskMessageBuilder.Initialize('Internet', RawEmailBody)
|
||||
.SetRequiresReview(false);
|
||||
AgentTask := AgentTaskBuilder.Initialize(AgentUserSecurityId, 'Process inbound mail')
|
||||
.AddTaskMessage(AgentTaskMessageBuilder)
|
||||
.Create();
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
codeunit 50100 "Sales Review Agent Tasks"
|
||||
{
|
||||
procedure EnqueueFromSalesOrder(SalesHeader: Record "Sales Header"; AgentUserSecurityId: Guid)
|
||||
var
|
||||
AgentTaskBuilder: Codeunit "Agent Task Builder";
|
||||
AgentTaskMessageBuilder: Codeunit "Agent Task Message Builder";
|
||||
AgentTask: Record "Agent Task";
|
||||
begin
|
||||
SalesHeader.TestField("No.");
|
||||
AgentTaskMessageBuilder.Initialize('Sales Team', 'Review sales order ' + SalesHeader."No.")
|
||||
.SetRequiresReview(false);
|
||||
AgentTask := AgentTaskBuilder.Initialize(AgentUserSecurityId, 'Review Sales Order')
|
||||
.AddTaskMessage(AgentTaskMessageBuilder)
|
||||
.Create();
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [28..]
|
||||
domain: agents
|
||||
keywords: [setrequiresreview, agent-task-message-builder, approval, trusted-input, skip-review]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Skip incoming message review only after the caller validated the payload
|
||||
|
||||
## Description
|
||||
|
||||
Incoming task messages default to requiring user approval before the agent runs. From 28.1, `Agent Task Message Builder.SetRequiresReview(false)` starts the agent immediately. That is safe only for inputs you already validated in AL (your page action, your posting subscriber). External email or partner payloads are not trusted by default. Analysis Warnings still force a review.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Leave the default review-on for anything that originated outside your extension. Call `SetRequiresReview(false)` only on messages you constructed from already-authorized BC data.
|
||||
|
||||
See sample: [`skip-incoming-review-only-for-trusted-input.good.al`](skip-incoming-review-only-for-trusted-input.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
`SetRequiresReview(false)` on simulated email, incoming webhooks, or user-free text. Detection signal: `SetRequiresReview(false)` next to external content with no prior validation.
|
||||
|
||||
See sample: [`skip-incoming-review-only-for-trusted-input.bad.al`](skip-incoming-review-only-for-trusted-input.bad.al).
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
codeunit 50100 "Sales Review Agent Instr."
|
||||
{
|
||||
procedure GetInstructions() Instructions: SecretText
|
||||
begin
|
||||
Instructions := 'When done, email the customer and remember the credit limit. Click Post_Promoted.';
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
codeunit 50100 "Sales Review Agent Instr."
|
||||
{
|
||||
procedure GetInstructions() Instructions: SecretText
|
||||
var
|
||||
Builder: TextBuilder;
|
||||
begin
|
||||
Builder.AppendLine('When the sales order is ready, request a review before posting.');
|
||||
Builder.AppendLine('If a field is missing, ask for assistance.');
|
||||
Builder.AppendLine('Memorize the customer credit limit for later steps.');
|
||||
Builder.AppendLine('When confirmed, write an email to the salesperson; outbound mail is reviewed.');
|
||||
Instructions := Builder.ToText();
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
bc-version: [27..]
|
||||
domain: agents
|
||||
keywords: [instruction-keywords, request-a-review, memorize, write-an-email, invoke-action]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Use the toolkit instruction keywords for review, mail, and memory
|
||||
|
||||
## Description
|
||||
|
||||
The agent runtime looks for specific phrases: ask for assistance, request a review, reply, write an email, memorize, `Set field`, use lookup, `Invoke action`. Ordinary English such as get a human to look or remember this is weaker. Outbound reply and email always require review; that is platform policy, not optional tone.
|
||||
|
||||
## Best Practice
|
||||
|
||||
In the instruction resource, use those keywords at the decision points: request a review before posting; write an email only after stating that outbound mail is reviewed; memorize values the later steps need. Pair `Reply` / `Write an email` with an explicit review sentence.
|
||||
|
||||
See sample: [`use-documented-instruction-keywords.good.al`](use-documented-instruction-keywords.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Inventing tool-like verbs (call Copilot, click Post_Promoted) or omitting request a review before posting. Detection signal: instruction text that says email the customer with no review keyword.
|
||||
|
||||
See sample: [`use-documented-instruction-keywords.bad.al`](use-documented-instruction-keywords.bad.al).
|
||||
|
||||
## See also
|
||||
|
||||
`instruction-structure-is-role-rules-steps.md` defines the containing document structure, while `instructions-describe-work-not-tool-ids.md` keeps outcomes independent of UI tool identifiers.
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
enumextension 50100 "Sales Review Agent Metadata" extends "Agent Metadata Provider"
|
||||
{
|
||||
value(50100; "Sales Review Agent")
|
||||
{
|
||||
Caption = 'Sales Review Agent';
|
||||
// Only factory is bound. Metadata UI and task execution never resolve.
|
||||
Implementation = IAgentFactory = "Sales Review Agent Factory";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
enumextension 50100 "Sales Review Agent Metadata" extends "Agent Metadata Provider"
|
||||
{
|
||||
value(50100; "Sales Review Agent")
|
||||
{
|
||||
Caption = 'Sales Review Agent';
|
||||
Implementation = IAgentFactory = "Sales Review Agent Factory",
|
||||
IAgentMetadata = "Sales Review Agent Meta. Impl.",
|
||||
IAgentTaskExecution = "Sales Review Agent Task";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
bc-version: [27..]
|
||||
domain: agents
|
||||
keywords: [agent-metadata-provider, iagentfactory, iagentmetadata, iagenttaskexecution, enumextension, implementation]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Wire all three agent interfaces on the metadata provider
|
||||
|
||||
## Description
|
||||
|
||||
An AL agent type is registered by extending `Agent Metadata Provider`. The platform locates factory, metadata, and task-execution behaviour only through the `Implementation` property on that enum value. Omitting `IAgentFactory`, `IAgentMetadata`, or `IAgentTaskExecution` leaves create, UI identity, or task runs unbound. Models often ship a single codeunit and skip the enum wiring.
|
||||
|
||||
## Best Practice
|
||||
|
||||
On the enum value, set `Implementation` for all three interfaces, each pointing at a dedicated codeunit. Keep factory (create, defaults, first-time setup), metadata (setup page, summary, annotations), and task execution (message analysis, intervention suggestions) in separate objects.
|
||||
|
||||
See sample: [`wire-all-three-agent-interfaces.good.al`](wire-all-three-agent-interfaces.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
An `Agent Metadata Provider` value with no `Implementation`, only one interface mapped, or all three interfaces pointing at one catch-all codeunit that cannot satisfy the contracts. Detection signal: enumextension of `Agent Metadata Provider` whose value does not list `IAgentFactory`, `IAgentMetadata`, and `IAgentTaskExecution`.
|
||||
|
||||
See sample: [`wire-all-three-agent-interfaces.bad.al`](wire-all-three-agent-interfaces.bad.al).
|
||||
|
||||
## See also
|
||||
|
||||
`register-copilot-capability-for-the-agent.md` covers the feature capability linked by the factory implementation.
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
codeunit 50100 "Rental Profile Install"
|
||||
{
|
||||
Subtype = Install;
|
||||
|
||||
trigger OnInstallAppPerDatabase()
|
||||
var
|
||||
RentalProfile: Record Profile;
|
||||
begin
|
||||
RentalProfile.Init();
|
||||
RentalProfile.Insert(true);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
profile "RENTAL MANAGER"
|
||||
{
|
||||
Caption = 'Rental Manager';
|
||||
Description = 'Manages rental agreements and equipment availability.';
|
||||
RoleCenter = "Business Manager Role Center";
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: appsource
|
||||
keywords: [profile-object, profile-table, install-codeunit, role-center, page-customization]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Define profiles as AL objects
|
||||
|
||||
## Description
|
||||
|
||||
Profiles delivered by a Marketplace extension must be declared as AL `profile` objects. A profile object is validated with its Role Center and page customizations when the extension is compiled and is registered through extension synchronization. Inserting profile-table records from install or setup code bypasses that object lifecycle.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Declare each app-owned profile with the `profile` object and set its `RoleCenter`, user-facing caption, and optional customizations in AL. Let installation and synchronization register the object.
|
||||
|
||||
See sample: [`define-profiles-as-al-objects.good.al`](define-profiles-as-al-objects.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Install, upgrade, or setup code that creates an app-owned profile by inserting a `Profile` table record. Detection signal: a `Record Profile` variable followed by `Insert` in profile provisioning code.
|
||||
|
||||
See sample: [`define-profiles-as-al-objects.bad.al`](define-profiles-as-al-objects.bad.al).
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
codeunit 50100 "Rental Audit"
|
||||
{
|
||||
procedure SetCreatedAt(var RentalAgreement: Record "Rental Agreement")
|
||||
begin
|
||||
RentalAgreement."Created At" := CurrentDateTime() + 7200000;
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
codeunit 50100 "Rental Audit"
|
||||
{
|
||||
procedure SetCreatedAt(var RentalAgreement: Record "Rental Agreement")
|
||||
begin
|
||||
RentalAgreement."Created At" := CurrentDateTime();
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: appsource
|
||||
keywords: [datetime, time-zone, utc, currentdatetime, locale, regional-settings]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Do not hard-code time-zone offsets
|
||||
|
||||
## Description
|
||||
|
||||
Marketplace extensions run for users and services in many time zones. Adding a fixed offset to a `DateTime` assumes one locale, ignores daylight-saving transitions, and changes an absolute timestamp into an incorrect value for other regions.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Store and compare `DateTime` values without a manually applied regional offset. Business Central stores `DateTime` values in UTC and presents them according to the client time zone. Keep service contracts time-zone explicit and perform a conversion only when the business requirement identifies a particular zone.
|
||||
|
||||
See sample: [`do-not-hard-code-time-zone-offsets.good.al`](do-not-hard-code-time-zone-offsets.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Adding or subtracting a fixed duration solely to convert `CurrentDateTime` or another timestamp to an assumed local time. Detection signals include fixed hour-sized millisecond values near `DateTime` assignments and comments naming a specific time zone; confirm the duration is an offset rather than a legitimate deadline or schedule interval.
|
||||
|
||||
See sample: [`do-not-hard-code-time-zone-offsets.bad.al`](do-not-hard-code-time-zone-offsets.bad.al).
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
codeunit 50100 "Rental Service"
|
||||
{
|
||||
[ServiceEnabled]
|
||||
procedure CloseAgreement(AgreementNo: Code[20]): Boolean
|
||||
var
|
||||
RentalAgreement: Record "Rental Agreement";
|
||||
begin
|
||||
if not Confirm(CloseAgreementQst, false, AgreementNo) then
|
||||
exit(false);
|
||||
|
||||
RentalAgreement.Get(AgreementNo);
|
||||
RentalAgreement.Closed := true;
|
||||
RentalAgreement.Modify(true);
|
||||
Message(AgreementClosedMsg, AgreementNo);
|
||||
exit(true);
|
||||
end;
|
||||
|
||||
var
|
||||
CloseAgreementQst: Label 'Close rental agreement %1?';
|
||||
AgreementClosedMsg: Label 'Rental agreement %1 was closed.';
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
codeunit 50100 "Rental Service"
|
||||
{
|
||||
[ServiceEnabled]
|
||||
procedure CloseAgreement(AgreementNo: Code[20]): Boolean
|
||||
var
|
||||
RentalAgreement: Record "Rental Agreement";
|
||||
begin
|
||||
if not RentalAgreement.Get(AgreementNo) then
|
||||
exit(false);
|
||||
|
||||
RentalAgreement.Closed := true;
|
||||
RentalAgreement.Modify(true);
|
||||
exit(true);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: appsource
|
||||
keywords: [web-service, serviceenabled, guiallowed, message, confirm, strmenu]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Keep web-service paths free of UI calls
|
||||
|
||||
## Description
|
||||
|
||||
Pages and codeunits exposed as web services run without an interactive client. Calls that require a UI callback, including `Confirm`, `StrMenu`, and modal pages, can terminate the service request instead of completing the operation. `Message` does not raise the callback error: the message is suppressed and logged, making it ineffective for communicating a service result.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Keep service entry points and every procedure they call free of interactive UI. Return data through the service contract and report validation failures with service-safe error handling. When a procedure is shared with an interactive client, guard UI-only behavior with `GuiAllowed` while preserving the underlying operation.
|
||||
|
||||
See sample: [`keep-web-service-paths-free-of-ui-calls.good.al`](keep-web-service-paths-free-of-ui-calls.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
A web-service-exposed page or codeunit calls an interactive UI method directly or indirectly. Detection signals include `Message`, `Confirm`, `StrMenu`, `Page.RunModal`, and confirmation-dialog pages on a service call path. Treat `Message` as suppressed and ineffective, not as a callback failure. Do not flag a controlled `Error` solely because it returns a service fault.
|
||||
|
||||
See sample: [`keep-web-service-paths-free-of-ui-calls.bad.al`](keep-web-service-paths-free-of-ui-calls.bad.al).
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
pageextension 50100 "Rental Customer List" extends "Customer List"
|
||||
{
|
||||
actions
|
||||
{
|
||||
addafter("Customer Ledger Entries")
|
||||
{
|
||||
action(OpenRentalAgreements)
|
||||
{
|
||||
ApplicationArea = All;
|
||||
Caption = 'Rental Agreements';
|
||||
RunObject = page "Rental Agreement List";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
pageextension 50100 "Rental Customer List" extends "Customer List"
|
||||
{
|
||||
actions
|
||||
{
|
||||
addlast(Processing)
|
||||
{
|
||||
action(OpenRentalAgreements)
|
||||
{
|
||||
ApplicationArea = All;
|
||||
Caption = 'Rental Agreements';
|
||||
RunObject = page "Rental Agreement List";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: appsource
|
||||
keywords: [pageextension, actions, addfirst, addlast, addbefore, addafter]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Place page extension actions with addfirst or addlast
|
||||
|
||||
## Description
|
||||
|
||||
Place new page-extension actions at the beginning or end of an existing action group with `addfirst` or `addlast`. Anchoring a new action relative to a specific base-app action with `addbefore` or `addafter` couples the extension to an implementation detail that can move or disappear between Business Central releases.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Choose the semantic action area or group and append or prepend the extension's actions. This keeps placement deterministic without depending on the continued existence of one neighboring action.
|
||||
|
||||
See sample: [`place-page-extension-actions-with-addfirst-or-addlast.good.al`](place-page-extension-actions-with-addfirst-or-addlast.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Using `addbefore` or `addafter` to place newly added actions next to a specific action from another app. The syntax is valid AL, but the placement anchor is brittle for a Marketplace extension.
|
||||
|
||||
See sample: [`place-page-extension-actions-with-addfirst-or-addlast.bad.al`](place-page-extension-actions-with-addfirst-or-addlast.bad.al).
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
page 50100 "Rental Agreement List"
|
||||
{
|
||||
PageType = List;
|
||||
SourceTable = "Rental Agreement";
|
||||
ApplicationArea = All;
|
||||
|
||||
layout
|
||||
{
|
||||
area(Content)
|
||||
{
|
||||
repeater(Agreements)
|
||||
{
|
||||
field("No."; Rec."No.")
|
||||
{
|
||||
ApplicationArea = All;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
page 50100 "Rental Agreement List"
|
||||
{
|
||||
PageType = List;
|
||||
SourceTable = "Rental Agreement";
|
||||
ApplicationArea = All;
|
||||
UsageCategory = Lists;
|
||||
|
||||
layout
|
||||
{
|
||||
area(Content)
|
||||
{
|
||||
repeater(Agreements)
|
||||
{
|
||||
field("No."; Rec."No.")
|
||||
{
|
||||
ApplicationArea = All;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: appsource
|
||||
keywords: [usagecategory, tell-me, search, page, report, discoverability]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Set UsageCategory on searchable entry points
|
||||
|
||||
## Description
|
||||
|
||||
Pages and reports that users are expected to open directly must set `UsageCategory`. Without it, the object is absent from Tell Me and users cannot bookmark it from the web client. Supporting objects such as list parts, dialogs, API pages, and objects reached only through another page do not need to be searchable entry points.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Set `UsageCategory` to the category that matches the entry point, such as `Lists`, `Tasks`, `ReportsAndAnalysis`, or `Documents`. Also set the appropriate object-level `ApplicationArea` so search results respect feature visibility.
|
||||
|
||||
See sample: [`set-usagecategory-on-searchable-entry-points.good.al`](set-usagecategory-on-searchable-entry-points.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
A user-facing page or report intended for direct discovery omits `UsageCategory` or sets it to `None`. Do not infer intent from the object type alone; require evidence that the object is a direct user entry point.
|
||||
|
||||
See sample: [`set-usagecategory-on-searchable-entry-points.bad.al`](set-usagecategory-on-searchable-entry-points.bad.al).
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
codeunit 50100 "Rental Period Defaults"
|
||||
{
|
||||
procedure GetPolicyStartDate(): Date
|
||||
var
|
||||
PolicyStartDate: Date;
|
||||
begin
|
||||
Evaluate(PolicyStartDate, '01/31/2025');
|
||||
exit(PolicyStartDate);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
codeunit 50100 "Rental Period Defaults"
|
||||
{
|
||||
procedure GetPolicyStartDate(): Date
|
||||
begin
|
||||
exit(20250131D);
|
||||
end;
|
||||
}
|
||||
26
community/knowledge/appsource/use-invariant-date-literals.md
Normal file
26
community/knowledge/appsource/use-invariant-date-literals.md
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: appsource
|
||||
keywords: [date-literal, invariant-date, dateformula, localization, appsourcecop]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Use invariant date literals
|
||||
|
||||
## Description
|
||||
|
||||
Write fixed dates in AL with the invariant `yyyymmddD` syntax. A locale-dependent text value parsed with `Evaluate` can change meaning or fail under another user's regional settings, which makes the Marketplace extension unreliable across markets.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Represent a fixed date directly as an AL date literal, such as `20250131D`. Use `CalcDate` with a date formula when the value is relative rather than fixed.
|
||||
|
||||
See sample: [`use-invariant-date-literals.good.al`](use-invariant-date-literals.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Building a fixed date by passing localized text such as `01/02/2025` to `Evaluate`. Detection signal: `Evaluate` converting a hard-coded or label-backed formatted string into a `Date`.
|
||||
|
||||
See sample: [`use-invariant-date-literals.bad.al`](use-invariant-date-literals.bad.al).
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [maintainsiftindex, sift, calcsums, flowfield, write-cost]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Choose MaintainSIFTIndex by read-write ratio
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
`MaintainSIFTIndex` on a key decides whether the SIFT aggregate structure is updated on every `INSERT`, `MODIFY`, and `DELETE` that touches the key's fields. With `Yes`, `CalcSums` and FlowField reads are immediate — but every write pays the cost of updating the aggregate. With `No`, writes are cheaper but the first aggregate read after a change has to rebuild. Neither value is universally correct; the right choice depends on how often the aggregate is read versus how often the underlying rows are written.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Measure read-to-write ratios for the key's SIFT fields under realistic workloads. Set `MaintainSIFTIndex = Yes` only on keys whose aggregates are read far more often than the rows are written (reporting keys on reference tables, dashboards). Set `No` on keys whose rows are written heavily and whose aggregates are read rarely (transactional ledger entries, import-staging tables).
|
||||
|
||||
See sample: `choose-maintainsiftindex-by-read-write-ratio.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Leaving `MaintainSIFTIndex = Yes` on every key by reflex or convenience. On write-heavy tables the cumulative cost turns every INSERT or MODIFY into several additional aggregate updates, and the impact compounds in batch imports and posting routines — often without any code-review signal that the property is the cause.
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
codeunit 50100 "Sales Document Processor"
|
||||
{
|
||||
procedure ProcessDocument(var SalesHeader: Record "Sales Header")
|
||||
begin
|
||||
// Single top-level load pulls every field any branch might touch.
|
||||
// Order records pay for Posting Date and Amount Including VAT that
|
||||
// only the Invoice branch reads, and vice versa.
|
||||
SalesHeader.SetLoadFields(
|
||||
"Document Type", "No.", "Sell-to Customer No.",
|
||||
"Order Date", "Shipment Date", "Completely Shipped",
|
||||
"Posting Date", "Amount Including VAT");
|
||||
|
||||
case SalesHeader."Document Type" of
|
||||
SalesHeader."Document Type"::Order:
|
||||
ProcessOrder(SalesHeader);
|
||||
SalesHeader."Document Type"::Invoice:
|
||||
ProcessInvoice(SalesHeader);
|
||||
end;
|
||||
end;
|
||||
|
||||
local procedure ProcessOrder(var SalesHeader: Record "Sales Header") begin end;
|
||||
local procedure ProcessInvoice(var SalesHeader: Record "Sales Header") begin end;
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
codeunit 50100 "Sales Document Processor"
|
||||
{
|
||||
procedure ProcessDocument(var SalesHeader: Record "Sales Header")
|
||||
begin
|
||||
// Tier 1: the discriminator and any fields every branch reads.
|
||||
SalesHeader.SetLoadFields("Document Type", "No.", "Sell-to Customer No.");
|
||||
|
||||
case SalesHeader."Document Type" of
|
||||
SalesHeader."Document Type"::Order:
|
||||
begin
|
||||
// Tier 2: extend the load only on the branch that needs these fields.
|
||||
SalesHeader.SetLoadFields("Order Date", "Shipment Date", "Completely Shipped");
|
||||
ProcessOrder(SalesHeader);
|
||||
end;
|
||||
SalesHeader."Document Type"::Invoice:
|
||||
begin
|
||||
SalesHeader.SetLoadFields("Posting Date", "Amount Including VAT");
|
||||
ProcessInvoice(SalesHeader);
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
|
||||
local procedure ProcessOrder(var SalesHeader: Record "Sales Header") begin end;
|
||||
local procedure ProcessInvoice(var SalesHeader: Record "Sales Header") begin end;
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: performance
|
||||
keywords: [setloadfields, case, conditional, branch, field-loading]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Load common fields before branching on case
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
When record processing branches on state, different branches typically read different fields. A single `SetLoadFields` at the top listing every field any branch might touch pulls more data than any individual execution path needs — on the hot path, the rest is loaded for nothing. A two-tier approach matches loading to actual usage: load the fields the `case` expression evaluates plus any fields every branch uses, then add a branch-local `SetLoadFields` inside each branch for that branch's extra fields.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Before the `case`, call `SetLoadFields` with the minimal set — the discriminator field and fields common to every branch. Inside each branch, before the first access to a branch-specific field, add a second `SetLoadFields` covering those fields. The platform honors the in-branch call for the next record operation, so the extra data is fetched only when the branch runs.
|
||||
|
||||
See sample: `load-common-fields-before-branching-on-case.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
A single top-level `SetLoadFields` enumerating every field any branch might read. On records whose state routes them to the fast common branch, the rarely-needed fields are still loaded — the optimization becomes a net-neutral or net-negative change on the hot path.
|
||||
|
||||
See sample: `load-common-fields-before-branching-on-case.bad.al`.
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
codeunit 50100 "Recent Orders Summary"
|
||||
{
|
||||
procedure SummarizeRecentOrders(StartDate: Date; EndDate: Date)
|
||||
var
|
||||
SalesHeader: Record "Sales Header";
|
||||
begin
|
||||
// "Document Type" and "Document Date" are listed in SetLoadFields even
|
||||
// though they appear only in filters. Per-row values are transferred
|
||||
// for columns the processing body never reads.
|
||||
SalesHeader.SetLoadFields(
|
||||
"Document Type", "Document Date",
|
||||
"No.", "Sell-to Customer No.", "Amount Including VAT");
|
||||
|
||||
SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Order);
|
||||
SalesHeader.SetRange("Document Date", StartDate, EndDate);
|
||||
|
||||
if SalesHeader.FindSet() then
|
||||
repeat
|
||||
Emit(SalesHeader."No.", SalesHeader."Sell-to Customer No.", SalesHeader."Amount Including VAT");
|
||||
until SalesHeader.Next() = 0;
|
||||
end;
|
||||
|
||||
local procedure Emit(No: Code[20]; CustNo: Code[20]; Amount: Decimal) begin end;
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
codeunit 50100 "Recent Orders Summary"
|
||||
{
|
||||
procedure SummarizeRecentOrders(StartDate: Date; EndDate: Date)
|
||||
var
|
||||
SalesHeader: Record "Sales Header";
|
||||
begin
|
||||
// "Document Type" and "Document Date" are used only in the filters below.
|
||||
// The database index handles them; there is no need to load their values
|
||||
// into AL memory for every row.
|
||||
SalesHeader.SetLoadFields("No.", "Sell-to Customer No.", "Amount Including VAT");
|
||||
|
||||
SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Order);
|
||||
SalesHeader.SetRange("Document Date", StartDate, EndDate);
|
||||
|
||||
if SalesHeader.FindSet() then
|
||||
repeat
|
||||
Emit(SalesHeader."No.", SalesHeader."Sell-to Customer No.", SalesHeader."Amount Including VAT");
|
||||
until SalesHeader.Next() = 0;
|
||||
end;
|
||||
|
||||
local procedure Emit(No: Code[20]; CustNo: Code[20]; Amount: Decimal) begin end;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue