From f971bfca9572f9a1f01a96fd979db70bcbc91e34 Mon Sep 17 00:00:00 2001 From: Yimin Jin Date: Fri, 3 Apr 2026 16:03:41 +0800 Subject: [PATCH 1/6] fix: add missing required 'resources' field to all hosted agent templates --- samples/hosted-agent/dotnet/agent/agent.yaml | 3 +++ samples/hosted-agent/dotnet/workflow/agent.yaml | 3 +++ samples/hosted-agent/python/agent/agent.yaml | 5 ++++- samples/hosted-agent/python/langgraph-agent/agent.yaml | 3 +++ samples/hosted-agent/python/workflow/agent.yaml | 3 +++ 5 files changed, 16 insertions(+), 1 deletion(-) diff --git a/samples/hosted-agent/dotnet/agent/agent.yaml b/samples/hosted-agent/dotnet/agent/agent.yaml index 4a6df12..5bcca46 100644 --- a/samples/hosted-agent/dotnet/agent/agent.yaml +++ b/samples/hosted-agent/dotnet/agent/agent.yaml @@ -23,3 +23,6 @@ environment_variables: value: ${AZURE_AI_PROJECT_ENDPOINT} - name: MODEL_DEPLOYMENT_NAME value: ${MODEL_DEPLOYMENT_NAME} +resources: + cpu: "" + memory: "" diff --git a/samples/hosted-agent/dotnet/workflow/agent.yaml b/samples/hosted-agent/dotnet/workflow/agent.yaml index 6298971..f74ee5a 100644 --- a/samples/hosted-agent/dotnet/workflow/agent.yaml +++ b/samples/hosted-agent/dotnet/workflow/agent.yaml @@ -22,3 +22,6 @@ environment_variables: value: ${PROJECT_ENDPOINT} - name: MODEL_DEPLOYMENT_NAME value: ${MODEL_DEPLOYMENT_NAME} +resources: + cpu: "" + memory: "" diff --git a/samples/hosted-agent/python/agent/agent.yaml b/samples/hosted-agent/python/agent/agent.yaml index 9ea4878..533ad25 100644 --- a/samples/hosted-agent/python/agent/agent.yaml +++ b/samples/hosted-agent/python/agent/agent.yaml @@ -22,4 +22,7 @@ environment_variables: - name: PROJECT_ENDPOINT value: ${PROJECT_ENDPOINT} - name: MODEL_DEPLOYMENT_NAME - value: ${MODEL_DEPLOYMENT_NAME} \ No newline at end of file + value: ${MODEL_DEPLOYMENT_NAME} +resources: + cpu: "" + memory: "" \ No newline at end of file diff --git a/samples/hosted-agent/python/langgraph-agent/agent.yaml b/samples/hosted-agent/python/langgraph-agent/agent.yaml index 986ddc9..a9adc1f 100644 --- a/samples/hosted-agent/python/langgraph-agent/agent.yaml +++ b/samples/hosted-agent/python/langgraph-agent/agent.yaml @@ -22,3 +22,6 @@ environment_variables: value: "${AZURE_AI_PROJECT_ENDPOINT}" - name: AZURE_AI_MODEL_DEPLOYMENT_NAME value: "${AZURE_AI_MODEL_DEPLOYMENT_NAME}" +resources: + cpu: "" + memory: "" diff --git a/samples/hosted-agent/python/workflow/agent.yaml b/samples/hosted-agent/python/workflow/agent.yaml index 6298971..f74ee5a 100644 --- a/samples/hosted-agent/python/workflow/agent.yaml +++ b/samples/hosted-agent/python/workflow/agent.yaml @@ -22,3 +22,6 @@ environment_variables: value: ${PROJECT_ENDPOINT} - name: MODEL_DEPLOYMENT_NAME value: ${MODEL_DEPLOYMENT_NAME} +resources: + cpu: "" + memory: "" From e9f882443031584ebc9291069813490460587442 Mon Sep 17 00:00:00 2001 From: "sync-hosted-agent-sample-bot[bot]" <266662545+sync-hosted-agent-sample-bot[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 21:39:31 +0800 Subject: [PATCH 2/6] chore: sync dev to main (#481) --- samples/hosted-agent/dotnet/agent/Dockerfile | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/samples/hosted-agent/dotnet/agent/Dockerfile b/samples/hosted-agent/dotnet/agent/Dockerfile index 6a9cd12..3545893 100644 --- a/samples/hosted-agent/dotnet/agent/Dockerfile +++ b/samples/hosted-agent/dotnet/agent/Dockerfile @@ -5,9 +5,8 @@ WORKDIR /src # Copy files from the current directory on the host to the working directory in the container COPY . . -RUN dotnet restore -RUN dotnet build -c Release --no-restore -RUN dotnet publish -c Release --no-build -o /app +RUN dotnet restore {{SafeProjectName}}.csproj +RUN dotnet publish -c Release --no-restore -o /app # Run the application FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final From dd5970b995c574c7a0a589c481b7a5aa5c29111a Mon Sep 17 00:00:00 2001 From: Hui Miao Date: Tue, 7 Apr 2026 15:14:50 +0800 Subject: [PATCH 3/6] ci: add some github actions (#484) --- .../validate-samples-failure.html | 332 ++++++++++++++++++ .../scripts/sync_agent_framework_samples.py | 237 +++++++++++++ .../sync-agent-framework-samples.yml | 210 +++++++++++ .github/workflows/sync-dev-to-main.yaml | 96 +++++ .github/workflows/sync-main-to-dev.yaml | 96 +++++ .../validate-hosted-agent-samples.yml | 276 +++++++++++++++ 6 files changed, 1247 insertions(+) create mode 100644 .github/email-templates/validate-samples-failure.html create mode 100644 .github/scripts/sync_agent_framework_samples.py create mode 100644 .github/workflows/sync-agent-framework-samples.yml create mode 100644 .github/workflows/sync-dev-to-main.yaml create mode 100644 .github/workflows/sync-main-to-dev.yaml create mode 100644 .github/workflows/validate-hosted-agent-samples.yml diff --git a/.github/email-templates/validate-samples-failure.html b/.github/email-templates/validate-samples-failure.html new file mode 100644 index 0000000..2482e00 --- /dev/null +++ b/.github/email-templates/validate-samples-failure.html @@ -0,0 +1,332 @@ + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+   +
+

+ GitHub Actions +

+

+ Hosted Agent Sample Validation +

+ + ${EMAIL_OVERALL} + ${EMAIL_PASSED} / ${EMAIL_TOTAL} samples + passed +
+
+
+

+ Sample Results +

+ + + + + + + + + + ${EMAIL_ROWS} + +
+ Branch + + Sample + + Result +
+
+
+
+

+ Run Details +

+ + + + + + + + + + + + + + + + + + + + + +
+ Workflow + + ${EMAIL_WORKFLOW} +
+ Branch + + ${EMAIL_BRANCH} +
+ Commit + + ${EMAIL_COMMIT} +
+ Triggered by + + ${EMAIL_TRIGGERED_BY} · ${EMAIL_ACTOR} +
+ Run + + #${EMAIL_RUN_NUMBER} · + ${EMAIL_RUN_ID} +
+
+ View Run Log → +
+

+ Sent by GitHub Actions · + ${EMAIL_REPOSITORY} +

+
+ +
+ + + diff --git a/.github/scripts/sync_agent_framework_samples.py b/.github/scripts/sync_agent_framework_samples.py new file mode 100644 index 0000000..e26eb37 --- /dev/null +++ b/.github/scripts/sync_agent_framework_samples.py @@ -0,0 +1,237 @@ +from __future__ import annotations + +from copy import deepcopy +import json +import os +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from pathlib import Path +from urllib.request import Request, urlopen + + +REPO_ROOT = Path(os.environ["REPO_ROOT"]) if "REPO_ROOT" in os.environ else Path(__file__).resolve().parents[2] +PLACEHOLDER = "{{SafeProjectName}}" +UPSTREAM_REPO_API = "https://api.github.com/repos/microsoft/agent-framework/contents" +ALLOWED_SUFFIXES = {".cs", ".csproj"} + + +@dataclass(frozen=True) +class SampleConfig: + upstream_name: str + target_dir: Path + + @property + def upstream_path(self) -> str: + return f"dotnet/samples/05-end-to-end/HostedAgents/{self.upstream_name}" + + +SAMPLES = ( + SampleConfig( + upstream_name="FoundrySingleAgent", + target_dir=REPO_ROOT / "samples" / "hosted-agent" / "dotnet" / "agent", + ), + SampleConfig( + upstream_name="FoundryMultiAgent", + target_dir=REPO_ROOT / "samples" / "hosted-agent" / "dotnet" / "workflow", + ), +) + + +GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "") + + +def _auth_headers() -> dict[str, str]: + headers: dict[str, str] = {"User-Agent": "microsoft-foundry-for-vscode-sync"} + if GITHUB_TOKEN: + headers["Authorization"] = f"Bearer {GITHUB_TOKEN}" + return headers + + +def fetch_json(url: str) -> list[dict[str, object]]: + headers = _auth_headers() + headers["Accept"] = "application/vnd.github+json" + request = Request(url, headers=headers) + with urlopen(request) as response: + return json.load(response) + + +def fetch_text(url: str) -> str: + request = Request(url, headers=_auth_headers()) + with urlopen(request) as response: + return response.read().decode("utf-8") + + +def normalize_content(content: str, upstream_name: str) -> str: + return content.replace(upstream_name, PLACEHOLDER) + + +def normalize_relative_path(relative_path: Path, upstream_name: str) -> Path: + normalized_parts = [part.replace(upstream_name, PLACEHOLDER) for part in relative_path.parts] + return Path(*normalized_parts) + + +def list_relevant_files(upstream_path: str) -> list[dict[str, object]]: + items = fetch_json(f"{UPSTREAM_REPO_API}/{upstream_path}?ref=main") + relevant_files: list[dict[str, object]] = [] + + for item in items: + item_type = item.get("type") + item_path = str(item["path"]) + + if item_type == "dir": + relevant_files.extend(list_relevant_files(item_path)) + continue + + if item_type != "file": + continue + + if Path(str(item["name"])).suffix not in ALLOWED_SUFFIXES: + continue + + relevant_files.append(item) + + return relevant_files + + +def replace_element_contents(target: ET.Element, source: ET.Element) -> None: + target.attrib.clear() + target.attrib.update(source.attrib) + target.text = source.text + target.tail = source.tail + + for child in list(target): + target.remove(child) + + for child in source: + target.append(deepcopy(child)) + + +def element_attribute_key(element: ET.Element) -> tuple[tuple[str, str], ...]: + return tuple(sorted(element.attrib.items())) + + +def find_or_create_matching_group(root: ET.Element, source_group: ET.Element, tag: str) -> ET.Element: + group_key = element_attribute_key(source_group) + + for existing_group in root.findall(tag): + if element_attribute_key(existing_group) == group_key: + return existing_group + + new_group = ET.Element(tag, source_group.attrib) + insert_at = sum(1 for child in root if child.tag == tag) + root.insert(insert_at, new_group) + return new_group + + +def merge_csproj_content(upstream_content: str, local_path: Path) -> str: + """Merge upstream .csproj changes into the local file, preserving local additions. + + Only PropertyGroup properties and ItemGroup PackageReferences from upstream + are compared and updated. Local-only properties and packages are kept as-is. + If the local file does not exist or cannot be parsed, the full upstream + content is returned unchanged. + """ + if not local_path.exists(): + return upstream_content + + local_content = local_path.read_text(encoding="utf-8") + + try: + upstream_root = ET.fromstring(upstream_content) + local_root = ET.fromstring(local_content) + except ET.ParseError: + return upstream_content + + # --- Merge PropertyGroup --- + for upstream_pg in upstream_root.findall("PropertyGroup"): + local_pg = find_or_create_matching_group(local_root, upstream_pg, "PropertyGroup") + local_props = {prop.tag: prop for prop in local_pg} + + for upstream_prop in upstream_pg: + if upstream_prop.tag in local_props: + replace_element_contents(local_props[upstream_prop.tag], upstream_prop) + else: + local_pg.append(deepcopy(upstream_prop)) + + # --- Merge PackageReference items --- + upstream_packages: dict[str, ET.Element] = {} + for ig in upstream_root.findall("ItemGroup"): + for pr in ig.findall("PackageReference"): + include = pr.get("Include") + if include: + upstream_packages[include] = deepcopy(pr) + + if upstream_packages: + # Find the first ItemGroup that already contains PackageReferences + local_pkg_ig: ET.Element | None = None + for ig in local_root.findall("ItemGroup"): + if ig.findall("PackageReference"): + local_pkg_ig = ig + break + + if local_pkg_ig is None: + local_pkg_ig = ET.SubElement(local_root, "ItemGroup") + + local_pkg_map: dict[str, ET.Element] = {} + for ig in local_root.findall("ItemGroup"): + for pr in ig.findall("PackageReference"): + include = pr.get("Include") + if include: + local_pkg_map[include] = pr + + for include, upstream_package in upstream_packages.items(): + if include in local_pkg_map: + replace_element_contents(local_pkg_map[include], upstream_package) + else: + local_pkg_ig.append(deepcopy(upstream_package)) + + ET.indent(local_root, space=" ") + return ET.tostring(local_root, encoding="unicode", xml_declaration=False) + "\n" + + +def sync_sample(config: SampleConfig) -> list[Path]: + items = list_relevant_files(config.upstream_path) + expected_paths: list[Path] = [] + + for item in items: + download_url = str(item["download_url"]) + relative_upstream_path = Path(str(item["path"])).relative_to(config.upstream_path) + target_relative_path = normalize_relative_path(relative_upstream_path, config.upstream_name) + target_path = config.target_dir / target_relative_path + normalized_content = normalize_content(fetch_text(download_url), config.upstream_name) + + target_path.parent.mkdir(parents=True, exist_ok=True) + + if target_path.suffix == ".csproj": + merged_content = merge_csproj_content(normalized_content, target_path) + target_path.write_text(merged_content, encoding="utf-8", newline="\n") + else: + target_path.write_text(normalized_content, encoding="utf-8", newline="\n") + + expected_paths.append(target_path) + + expected_path_set = set(expected_paths) + for existing_path in config.target_dir.rglob("*"): + if existing_path.suffix not in ALLOWED_SUFFIXES: + continue + if existing_path not in expected_path_set: + existing_path.unlink() + + for existing_dir in sorted((path for path in config.target_dir.rglob("*") if path.is_dir()), reverse=True): + if not any(existing_dir.iterdir()): + existing_dir.rmdir() + + return expected_paths + + +def main() -> None: + synced_paths: list[Path] = [] + for sample in SAMPLES: + synced_paths.extend(sync_sample(sample)) + + for path in sorted(synced_paths): + print(path.relative_to(REPO_ROOT).as_posix()) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/sync-agent-framework-samples.yml b/.github/workflows/sync-agent-framework-samples.yml new file mode 100644 index 0000000..7b55e99 --- /dev/null +++ b/.github/workflows/sync-agent-framework-samples.yml @@ -0,0 +1,210 @@ +name: Sync Agent Framework Samples + +on: + workflow_dispatch: + schedule: + - cron: "0 6 * * 1" + +permissions: + contents: write + pull-requests: write + +jobs: + sync: + runs-on: ubuntu-latest + env: + PR_LABEL_CANDIDATES: | + automated-pr + area:samples + area:dotnet + area:hosted-agent + strategy: + fail-fast: false + matrix: + base_branch: + - stable + - pre-release + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.SYNC_APP_ID }} + private-key: ${{ secrets.SYNC_APP_PRIVATE_KEY }} + + - name: Stage sync script + shell: bash + run: | + set -euo pipefail + cp .github/scripts/sync_agent_framework_samples.py "$RUNNER_TEMP/sync_agent_framework_samples.py" + + - name: Prepare target branch + id: prepare + shell: bash + run: | + set -euo pipefail + target_branch="${{ matrix.base_branch }}" + + if ! git ls-remote --exit-code --heads origin "$target_branch" >/dev/null 2>&1; then + echo "Branch $target_branch does not exist. Skipping." + echo "branch_exists=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + git fetch origin "$target_branch" + git checkout -B "$target_branch" "origin/$target_branch" + + echo "branch_exists=true" >> "$GITHUB_OUTPUT" + + - name: Set up Python + if: steps.prepare.outputs.branch_exists == 'true' + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Sync tracked sample files + if: steps.prepare.outputs.branch_exists == 'true' + env: + REPO_ROOT: ${{ github.workspace }} + GITHUB_TOKEN: ${{ github.token }} + run: python "$RUNNER_TEMP/sync_agent_framework_samples.py" + + - name: Detect sample changes + if: steps.prepare.outputs.branch_exists == 'true' + id: diff + shell: bash + run: | + set -euo pipefail + + checked=$(find samples/hosted-agent/dotnet/agent samples/hosted-agent/dotnet/workflow \ + -type f \( -name '*.cs' -o -name '*.csproj' \) | wc -l) + mapfile -t changed_files < <( + git diff --name-only -- \ + samples/hosted-agent/dotnet/agent \ + samples/hosted-agent/dotnet/workflow | sort + ) + unchanged=$(( checked - ${#changed_files[@]} )) + + echo "Checked $checked files, ${#changed_files[@]} changed, $unchanged unchanged." + if [[ ${#changed_files[@]} -gt 0 ]]; then + echo "Changed files:" + printf ' - %s\n' "${changed_files[@]}" + fi + + { + echo "## Sample Sync Diff" + echo + echo "- Checked: $checked" + echo "- Changed: ${#changed_files[@]}" + echo "- Unchanged: $unchanged" + echo + if [[ ${#changed_files[@]} -gt 0 ]]; then + echo "### Changed files" + printf -- '- \`%s\`\n' "${changed_files[@]}" + echo + fi + } >> "$GITHUB_STEP_SUMMARY" + + if [[ ${#changed_files[@]} -eq 0 ]]; then + echo "has_changes=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + git diff -- samples/hosted-agent/dotnet/agent samples/hosted-agent/dotnet/workflow + echo "has_changes=true" >> "$GITHUB_OUTPUT" + + - name: Resolve pull request labels + if: steps.prepare.outputs.branch_exists == 'true' && steps.diff.outputs.has_changes == 'true' + id: labels + shell: bash + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + set -euo pipefail + + existing_labels=$(gh api --paginate repos/${{ github.repository }}/labels --jq '.[].name') + matched_labels="" + + while IFS= read -r candidate; do + if [[ -z "$candidate" ]]; then + continue + fi + + if grep -Fqx "$candidate" <<< "$existing_labels"; then + matched_labels+="$candidate" + matched_labels+=$'\n' + fi + done <<< "$PR_LABEL_CANDIDATES" + + { + echo "labels<> "$GITHUB_OUTPUT" + + - name: Generate PR branch name + if: steps.prepare.outputs.branch_exists == 'true' && steps.diff.outputs.has_changes == 'true' + id: branch + shell: bash + run: | + date_suffix=$(date -u +%Y%m%d) + echo "name=ci/sync-af_hosted-agent-samples-${{ matrix.base_branch }}-${date_suffix}" >> "$GITHUB_OUTPUT" + + - name: Create draft pull request + if: steps.prepare.outputs.branch_exists == 'true' && steps.diff.outputs.has_changes == 'true' + id: cpr + uses: peter-evans/create-pull-request@v8 + with: + token: ${{ steps.app-token.outputs.token }} + branch: ${{ steps.branch.outputs.name }} + base: ${{ matrix.base_branch }} + delete-branch: true + draft: always-true + commit-message: | + ci: sync Agent Framework hosted-agent samples (${{ matrix.base_branch }}) + title: "ci: sync Agent Framework hosted-agent samples (${{ matrix.base_branch }})" + body: | + ## Summary + This automated draft PR syncs the hosted-agent sample sources from `microsoft/agent-framework` `main` into `${{ matrix.base_branch }}`. + + ## Upstream Sources + - `samples/hosted-agent/dotnet/agent` <- `dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent` + - `samples/hosted-agent/dotnet/workflow` <- `dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent` + + ## Sync Rules + - Only `.cs` and `.csproj` files are compared and updated. + - Upstream concrete project names are converted back to the local `{{SafeProjectName}}` placeholder. + - Placeholder replacement also applies to file names when needed. + - The sync scope is limited to the local `agent` and `workflow` sample folders. + + ## Reviewer Checks + - Confirm the diff only contains `.cs` and `.csproj` changes under the expected sample folders. + - Confirm any namespace, type name, or project file rename still maps correctly to `{{SafeProjectName}}`. + - Confirm package reference or SDK changes are acceptable for this repo's release branch. + - Confirm no repo-specific customization was unintentionally overwritten. + + ## Labels + Preferred labels for this automation are: + - `automated-pr` + + This workflow only applies labels that already exist in the repository. + labels: ${{ steps.labels.outputs.labels }} + add-paths: | + samples/hosted-agent/dotnet/agent/*.cs + samples/hosted-agent/dotnet/agent/*.csproj + samples/hosted-agent/dotnet/workflow/*.cs + samples/hosted-agent/dotnet/workflow/*.csproj + + - name: Report PR result + if: steps.prepare.outputs.branch_exists == 'true' && steps.diff.outputs.has_changes == 'true' + shell: bash + run: | + echo "PR operation: ${{ steps.cpr.outputs.pull-request-operation }}" + echo "PR URL: ${{ steps.cpr.outputs.pull-request-url }}" diff --git a/.github/workflows/sync-dev-to-main.yaml b/.github/workflows/sync-dev-to-main.yaml new file mode 100644 index 0000000..5c32043 --- /dev/null +++ b/.github/workflows/sync-dev-to-main.yaml @@ -0,0 +1,96 @@ +name: Sync Dev to Main + +on: + workflow_dispatch: + schedule: + - cron: "0 1 * * *" + +permissions: + contents: write + pull-requests: write + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.SYNC_APP_ID }} + private-key: ${{ secrets.SYNC_APP_PRIVATE_KEY }} + + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Sync dev to main (preserve version-manifest.json) + id: sync + shell: bash + run: | + set -euo pipefail + + EXCLUDED_FILE="samples/hosted-agent/version-manifest.json" + + git fetch origin dev main + + # Save the current version-manifest.json from main + git show origin/main:"$EXCLUDED_FILE" > /tmp/version-manifest.json + + SYNC_BRANCH="sync/dev-to-main-$(date +%Y%m%d%H%M%S)" + git checkout -b "$SYNC_BRANCH" origin/main + + # Merge dev, automatically resolving conflicts by preferring dev + git merge origin/dev --no-edit -X theirs || true + + # Restore the excluded file from main + cp /tmp/version-manifest.json "$EXCLUDED_FILE" + git add "$EXCLUDED_FILE" + + # Check if there are any staged changes to the excluded file + if git diff --cached --quiet "$EXCLUDED_FILE"; then + echo "version-manifest.json unchanged after restore, no extra commit needed" + else + git commit --amend --no-edit + fi + + # Check if there is any diff between the sync branch and main + if git diff --quiet origin/main; then + echo "No differences between dev and main (after exclusions). Skipping PR creation." + echo "has_changes=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "has_changes=true" >> "$GITHUB_OUTPUT" + git push origin "$SYNC_BRANCH" + echo "sync_branch=$SYNC_BRANCH" >> "$GITHUB_OUTPUT" + + - name: Close previous sync PRs and clean up branches + if: steps.sync.outputs.has_changes == 'true' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + gh pr list --base main --state open --json number,headRefName --jq '.[] | select(.headRefName | startswith("sync/dev-to-main")) | (.number | tostring)' | while read -r pr_number; do + echo "Closing PR #$pr_number" + gh pr close "$pr_number" --comment "Superseded by a newer sync PR." --delete-branch + done + + - name: Create Pull Request + if: steps.sync.outputs.has_changes == 'true' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + gh pr create \ + --base main \ + --head "${{ steps.sync.outputs.sync_branch }}" \ + --title "ci: sync dev to main" \ + --body "Automated sync from \`dev\` to \`main\`. + + This PR is created by an automated script. Please review carefully before merging." diff --git a/.github/workflows/sync-main-to-dev.yaml b/.github/workflows/sync-main-to-dev.yaml new file mode 100644 index 0000000..8f84ec3 --- /dev/null +++ b/.github/workflows/sync-main-to-dev.yaml @@ -0,0 +1,96 @@ +name: Sync Main to Dev + +on: + workflow_dispatch: + schedule: + - cron: "0 2 * * *" + +permissions: + contents: write + pull-requests: write + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.SYNC_APP_ID }} + private-key: ${{ secrets.SYNC_APP_PRIVATE_KEY }} + + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Sync main to dev (preserve version-manifest.json) + id: sync + shell: bash + run: | + set -euo pipefail + + EXCLUDED_FILE="samples/hosted-agent/version-manifest.json" + + git fetch origin main dev + + # Save the current version-manifest.json from dev + git show origin/dev:"$EXCLUDED_FILE" > /tmp/version-manifest.json + + SYNC_BRANCH="sync/main-to-dev-$(date +%Y%m%d%H%M%S)" + git checkout -b "$SYNC_BRANCH" origin/dev + + # Merge main, automatically resolving conflicts by preferring main + git merge origin/main --no-edit -X theirs || true + + # Restore the excluded file from dev + cp /tmp/version-manifest.json "$EXCLUDED_FILE" + git add "$EXCLUDED_FILE" + + # Check if there are any staged changes to the excluded file + if git diff --cached --quiet "$EXCLUDED_FILE"; then + echo "version-manifest.json unchanged after restore, no extra commit needed" + else + git commit --amend --no-edit + fi + + # Check if there is any diff between the sync branch and dev + if git diff --quiet origin/dev; then + echo "No differences between main and dev (after exclusions). Skipping PR creation." + echo "has_changes=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "has_changes=true" >> "$GITHUB_OUTPUT" + git push origin "$SYNC_BRANCH" + echo "sync_branch=$SYNC_BRANCH" >> "$GITHUB_OUTPUT" + + - name: Close previous sync PRs and clean up branches + if: steps.sync.outputs.has_changes == 'true' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + gh pr list --base dev --state open --json number,headRefName --jq '.[] | select(.headRefName | startswith("sync/main-to-dev")) | (.number | tostring)' | while read -r pr_number; do + echo "Closing PR #$pr_number" + gh pr close "$pr_number" --comment "Superseded by a newer sync PR." --delete-branch + done + + - name: Create Pull Request + if: steps.sync.outputs.has_changes == 'true' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + gh pr create \ + --base dev \ + --head "${{ steps.sync.outputs.sync_branch }}" \ + --title "ci: sync main to dev" \ + --body "Automated sync from \`main\` to \`dev\`. + + This PR is created by an automated script. Please review carefully before merging." diff --git a/.github/workflows/validate-hosted-agent-samples.yml b/.github/workflows/validate-hosted-agent-samples.yml new file mode 100644 index 0000000..9b93cbb --- /dev/null +++ b/.github/workflows/validate-hosted-agent-samples.yml @@ -0,0 +1,276 @@ +name: Validate Hosted Agent Samples + +on: + schedule: + - cron: "0 1 * * *" # Daily at 9:00 AM Beijing time (UTC+8 → 01:00 UTC) + workflow_dispatch: # Allow manual trigger + +permissions: + contents: read + +jobs: + validate: + name: "[${{ matrix.branch }}] ${{ matrix.language }}/${{ matrix.type }}" + runs-on: ubuntu-latest + strategy: + fail-fast: false # Run all matrix jobs even if one fails + matrix: + branch: [dev, main, pre-release, stable] + language: [dotnet, python] + type: [agent, workflow] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ matrix.branch }} + + - name: Prepare sample directory + id: prepare + env: + SAFE_PROJECT_NAME: SampleAgent + AGENT_NAME: sample-agent + run: | + WORK_DIR=$(mktemp -d) + cp -r "samples/hosted-agent/${{ matrix.language }}/${{ matrix.type }}/." "$WORK_DIR/" + + # Rename any files whose names contain the SafeProjectName placeholder + find "$WORK_DIR" -name '*{{SafeProjectName}}*' | while read -r f; do + mv "$f" "$(echo "$f" | sed "s/{{SafeProjectName}}/$SAFE_PROJECT_NAME/g")" + done + + # Replace placeholder values inside all text files + find "$WORK_DIR" -type f -print0 \ + | xargs -0 sed -i \ + -e "s/{{SafeProjectName}}/$SAFE_PROJECT_NAME/g" \ + -e "s/{{AgentName}}/$AGENT_NAME/g" + + echo "work_dir=$WORK_DIR" >> "$GITHUB_OUTPUT" + + - name: Build Docker image + run: | + docker build "${{ steps.prepare.outputs.work_dir }}" \ + -t "sample-${{ matrix.language }}-${{ matrix.type }}:ci-test" + + - name: Run container smoke test + run: | + CONTAINER="test-${{ matrix.language }}-${{ matrix.type }}" + + docker run -d \ + --name "$CONTAINER" \ + -p 8088:8088 \ + -e AZURE_AI_PROJECT_ENDPOINT="https://fake.services.ai.azure.com" \ + -e PROJECT_ENDPOINT="https://fake.services.ai.azure.com" \ + -e MODEL_DEPLOYMENT_NAME="gpt-4o" \ + "sample-${{ matrix.language }}-${{ matrix.type }}:ci-test" + + # Poll port 8088 for up to 30 seconds + echo "Waiting for server to start..." + for i in $(seq 1 10); do + http_code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 2 http://localhost:8088/ || echo "000") + if [ "$http_code" != "000" ]; then + echo "✅ Server responded with HTTP $http_code — container is healthy" + break + fi + if [ "$i" -eq 10 ]; then + echo "❌ Server did not start within 30 seconds" + docker logs "$CONTAINER" + docker rm -f "$CONTAINER" || true + exit 1 + fi + echo " attempt $i/10 — not yet ready, retrying in 3s..." + sleep 3 + done + + # Confirm the container is still running (didn't crash after responding) + running=$(docker inspect -f '{{.State.Running}}' "$CONTAINER") + if [ "$running" != "true" ]; then + echo "❌ Container exited unexpectedly" + docker logs "$CONTAINER" + docker rm -f "$CONTAINER" || true + exit 1 + fi + + docker rm -f "$CONTAINER" + + - name: Save result artifact + if: always() + run: | + mkdir -p /tmp/ci-result + echo "${{ job.status }}" > /tmp/ci-result/status.txt + echo "${{ matrix.branch }}/${{ matrix.language }}/${{ matrix.type }}" > /tmp/ci-result/sample.txt + + - name: Upload result artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: result-${{ matrix.branch }}-${{ matrix.language }}-${{ matrix.type }} + path: /tmp/ci-result/ + + notify: + name: Send notification + needs: [validate] + if: always() + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download all results + uses: actions/download-artifact@v4 + with: + pattern: result-* + path: results/ + + - name: Compute summary and render email + id: email + env: + EMAIL_WORKFLOW: "${{ github.workflow }}" + EMAIL_BRANCH: "${{ github.ref_name }}" + EMAIL_COMMIT: "${{ github.sha }}" + EMAIL_TRIGGERED_BY: "${{ github.event_name }}" + EMAIL_ACTOR: "${{ github.actor }}" + EMAIL_RUN_NUMBER: "${{ github.run_number }}" + EMAIL_RUN_ID: "${{ github.run_id }}" + EMAIL_RUN_URL: "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + EMAIL_REPOSITORY: "${{ github.repository }}" + EMAIL_SERVER_URL: "${{ github.server_url }}" + run: | + total=0 + passed=0 + + # Collect results into a temp file: "sample|status" + tmpdata=$(mktemp) + for dir in results/result-*/; do + status=$(cat "$dir/status.txt" 2>/dev/null || echo "unknown") + sample=$(cat "$dir/sample.txt" 2>/dev/null || echo "unknown") + total=$((total + 1)) + [ "$status" = "success" ] && passed=$((passed + 1)) + printf '%s|%s\n' "$sample" "$status" >> "$tmpdata" + done + + # Sort by branch (alphabetical on full "branch/language/type" line) + tmpsorted=$(mktemp) + sort "$tmpdata" > "$tmpsorted" + rm -f "$tmpdata" + + # Count rows per branch + declare -A branch_count=() + while IFS='|' read -r sample _; do + b="${sample%%/*}" + branch_count["$b"]=$((${branch_count["$b"]:-0} + 1)) + done < "$tmpsorted" + + # Build rows with rowspan on the Branch column + rows="" + prev_branch="" + cell_base="padding:8px 12px;border-bottom:1px solid #eeeeee;font-size:13px;color:#111111;" + while IFS='|' read -r sample status; do + branch="${sample%%/*}" + rest="${sample#*/}" + if [ "$status" = "success" ]; then + icon="✅" + bg="" + else + icon="❌" + bg="background-color:#fff8f8;" + fi + if [ "$branch" != "$prev_branch" ]; then + count=${branch_count["$branch"]} + branch_cell="$branch" + prev_branch="$branch" + else + branch_cell="" + fi + rows="${rows}${branch_cell}$rest$icon $status" + done < "$tmpsorted" + rm -f "$tmpsorted" + failed=$((total - passed)) + if [ "$failed" -eq 0 ]; then + overall="PASSED" + header_color="#137333" + else + overall="FAILED" + header_color="#d93025" + fi + export EMAIL_OVERALL="$overall" + export EMAIL_PASSED="$passed" + export EMAIL_TOTAL="$total" + export EMAIL_ROWS="$rows" + export EMAIL_HEADER_COLOR="$header_color" + body=$(envsubst \ + '${EMAIL_OVERALL} ${EMAIL_PASSED} ${EMAIL_TOTAL} ${EMAIL_ROWS} ${EMAIL_HEADER_COLOR} + ${EMAIL_WORKFLOW} ${EMAIL_BRANCH} ${EMAIL_COMMIT} + ${EMAIL_TRIGGERED_BY} ${EMAIL_ACTOR} ${EMAIL_RUN_NUMBER} ${EMAIL_RUN_ID} + ${EMAIL_RUN_URL} ${EMAIL_REPOSITORY} ${EMAIL_SERVER_URL}' \ + < .github/email-templates/validate-samples-failure.html) + body=$(printf '%s' "$body" | tr -d '\n' | sed 's/>[[:space:]]\+> "$GITHUB_OUTPUT" + echo "passed=$passed" >> "$GITHUB_OUTPUT" + echo "total=$total" >> "$GITHUB_OUTPUT" + echo "body<> "$GITHUB_OUTPUT" + echo "$body" >> "$GITHUB_OUTPUT" + echo "DELIM" >> "$GITHUB_OUTPUT" + + - name: Send notification + env: + TO: ${{ secrets.MAIL_TO }} + SUBJECT: "[${{ steps.email.outputs.overall }}] Validate Hosted Agent Samples (${{ steps.email.outputs.passed }}/${{ steps.email.outputs.total }} Passed)" + BODY: ${{ steps.email.outputs.body }} + LOGIC_APP_URL: ${{ secrets.LOGIC_APP_URL }} + MAIL_CLIENT_ID: ${{ secrets.MAIL_CLIENT_ID }} + MAIL_CLIENT_SECRET: ${{ secrets.MAIL_CLIENT_SECRET }} + MAIL_TENANT_ID: ${{ secrets.MAIL_TENANT_ID }} + run: | + if [ -z "$TO" ] || [ -z "$LOGIC_APP_URL" ]; then + echo "⚠️ MAIL_TO or LOGIC_APP_URL not configured. Skipping email send." + exit 0 + fi + + auth_header="" + if [ -n "$MAIL_CLIENT_ID" ] && [ -n "$MAIL_CLIENT_SECRET" ] && [ -n "$MAIL_TENANT_ID" ]; then + echo "🔐 Getting Azure AD access token..." + response=$(curl -s \ + --request POST \ + --header "Content-Type: application/x-www-form-urlencoded" \ + --data "grant_type=client_credentials&client_id=${MAIL_CLIENT_ID}&client_secret=${MAIL_CLIENT_SECRET}&resource=https://management.core.windows.net" \ + "https://login.microsoftonline.com/${MAIL_TENANT_ID}/oauth2/token") + access_token=$(echo "$response" | jq -r '. | select(.access_token) | .access_token') + if [ -z "$access_token" ] || [ "$access_token" = "null" ]; then + echo "⚠️ Failed to get access token. Skipping email send." + exit 0 + fi + echo "✅ Got access token" + auth_header="Authorization: Bearer $access_token" + fi + + body_file=$(mktemp) + printf '%s' "$BODY" > "$body_file" + payload=$(jq -n \ + --arg to "$TO" \ + --arg subject "$SUBJECT" \ + --rawfile body "$body_file" \ + '{to: $to, subject: $subject, body: $body, bodyHtml: $body, contentType: "text/html"}') + rm -f "$body_file" + + if [ -n "$auth_header" ]; then + http_code=$(curl -s -o /tmp/email_response.txt -w "%{http_code}" \ + --request POST \ + --header "Content-Type: application/json" \ + --header "$auth_header" \ + --data "$payload" \ + "$LOGIC_APP_URL") + else + http_code=$(curl -s -o /tmp/email_response.txt -w "%{http_code}" \ + --request POST \ + --header "Content-Type: application/json" \ + --data "$payload" \ + "$LOGIC_APP_URL") + fi + + if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then + echo "✅ Email sent successfully! (HTTP $http_code)" + else + echo "⚠️ Email send failed with HTTP $http_code" + cat /tmp/email_response.txt 2>/dev/null || true + fi From b77c8ee7de6f45837883cb225458d786fbc89475 Mon Sep 17 00:00:00 2001 From: "sync-hosted-agent-sample-bot[bot]" <266662545+sync-hosted-agent-sample-bot[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 15:38:52 +0800 Subject: [PATCH 4/6] ci: sync dev to main (#576) --- .github/scripts/generate_sample_catalog.mjs | 543 ++++++++++++++++++++ .github/workflows/sync-sample-catalog.yml | 160 ++++++ 2 files changed, 703 insertions(+) create mode 100644 .github/scripts/generate_sample_catalog.mjs create mode 100644 .github/workflows/sync-sample-catalog.yml diff --git a/.github/scripts/generate_sample_catalog.mjs b/.github/scripts/generate_sample_catalog.mjs new file mode 100644 index 0000000..94fa851 --- /dev/null +++ b/.github/scripts/generate_sample_catalog.mjs @@ -0,0 +1,543 @@ +// @ts-check +/** + * Generate samples/hosted-agent/sample-catalog.json from the foundry-samples repository. + * + * Scans the hosted-agents directory tree in microsoft-foundry/foundry-samples, + * parses each sample's agent.yaml to extract protocol and model requirements, + * fetches README.md to auto-fill displayName and description (only for empty fields), + * and writes a flat catalog JSON that the VS Code extension consumes for + * template selection. + * + * Usage: + * node generate_sample_catalog.mjs + * + * Environment variables: + * GITHUB_TOKEN Optional GitHub token for API authentication. + * REPO_ROOT Repository root (defaults to two levels up from this script). + * SAMPLES_REPO_URL Source repo URL (defaults to https://github.com/microsoft-foundry/foundry-samples/). + */ + +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; +import { join, dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const REPO_ROOT = process.env.REPO_ROOT || resolve(__dirname, '..', '..'); +const SAMPLES_REPO_URL = process.env.SAMPLES_REPO_URL || 'https://github.com/microsoft-foundry/foundry-samples/'; +const SAMPLES_REPO_API = 'https://api.github.com/repos/microsoft-foundry/foundry-samples'; +const OUTPUT_PATH = join(REPO_ROOT, 'samples', 'hosted-agent', 'sample-catalog.json'); + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN || ''; + +const LANGUAGES = ['python', 'csharp']; +const FRAMEWORKS = ['agent-framework', 'bring-your-own']; + +/** @type {Record}>} */ +const DIMENSION_DEFAULTS = { + language: { + title: 'Select a Language', + placeholder: 'Choose the language for your agent', + options: { + python: 'Python', + csharp: 'C# (.NET)', + }, + }, + framework: { + title: 'Select a Framework', + placeholder: 'Choose the framework for your agent', + options: { + 'agent-framework': 'Agent Framework', + 'bring-your-own': 'Bring Your Own', + }, + }, + protocol: { + title: 'Select a Protocol', + placeholder: 'Choose the protocol for your agent', + options: { + responses: 'Responses API', + invocations: 'Invocations API', + }, + }, +}; + +const TEMPLATE_SELECTION = { + title: 'Select a Template', + placeholder: 'Choose a template for your agent', +}; + +// Path segments must be alphanumeric, hyphens, underscores, or dots +const SAFE_PATH_SEGMENT = /^[a-zA-Z0-9._-]+$/; + +/** + * @param {string} url + * @returns {Promise} + */ +async function fetchJson(url) { + /** @type {Record} */ + const headers = { + 'User-Agent': 'microsoft-foundry-for-vscode-sync', + Accept: 'application/vnd.github+json', + }; + if (GITHUB_TOKEN) { + headers['Authorization'] = `Bearer ${GITHUB_TOKEN}`; + } + const response = await fetch(url, { headers }); + if (!response.ok) { + throw new Error(`HTTP ${response.status} for ${url}`); + } + return response.json(); +} + +/** + * @param {string} url + * @returns {Promise} + */ +async function fetchText(url) { + /** @type {Record} */ + const headers = { 'User-Agent': 'microsoft-foundry-for-vscode-sync' }; + if (GITHUB_TOKEN) { + headers['Authorization'] = `Bearer ${GITHUB_TOKEN}`; + } + const response = await fetch(url, { headers }); + if (!response.ok) { + throw new Error(`HTTP ${response.status} for ${url}`); + } + return response.text(); +} + +/** + * Validate that a path segment is safe (no traversal, no special chars). + * @param {string} segment + * @returns {boolean} + */ +function isSafePathSegment(segment) { + return SAFE_PATH_SEGMENT.test(segment) && segment !== '..' && segment !== '.'; +} + +/** + * Parse the commit SHA from the CLI argument. + * @returns {string} + */ +function parseCommitShaArg() { + const sha = process.argv[2] || ''; + if (!sha) { + console.error('Usage: node generate_sample_catalog.mjs '); + process.exit(1); + } + if (!/^[0-9a-f]{7,40}$/i.test(sha)) { + throw new Error(`Invalid commit SHA: "${sha}"`); + } + return sha; +} + +/** + * List subdirectory names under a given API path. + * @param {string} apiPath + * @param {string} ref + * @returns {Promise} + */ +async function listDirs(apiPath, ref) { + try { + const items = await fetchJson(`${SAMPLES_REPO_API}/contents/${apiPath}?ref=${ref}`); + if (!Array.isArray(items)) { + return []; + } + return items + .filter((item) => item.type === 'dir' && isSafePathSegment(String(item.name))) + .map((item) => String(item.name)); + } catch { + return []; + } +} + +/** + * Minimal parser for agent.yaml — extracts protocol and environment_variables. + * Does NOT use eval or dynamic code execution. + * @param {string} content + * @returns {{ protocols: Array<'responses' | 'invocations'>, hasModelEnv: boolean }} + */ +function parseAgentYaml(content) { + /** @type {{ protocols: Array<'responses' | 'invocations'>, hasModelEnv: boolean }} */ + const result = { protocols: [], hasModelEnv: false }; + + for (const line of content.split('\n')) { + const stripped = line.trim(); + if (stripped.startsWith('- protocol:')) { + const value = stripped.split(':')[1]?.trim(); + if (value === 'responses' || value === 'invocations') { + result.protocols.push(value); + } + } + if (stripped.startsWith('- name:') && stripped.includes('AZURE_AI_MODEL_DEPLOYMENT_NAME')) { + result.hasModelEnv = true; + } + } + + return result; +} + +/** + * Fetch and parse agent.yaml for a sample directory. + * @param {string} samplePath + * @param {string} ref + * @returns {Promise<{ protocols: Array<'responses' | 'invocations'>, hasModelEnv: boolean } | null>} + */ +async function fetchAgentYaml(samplePath, ref) { + const rawUrl = `https://raw.githubusercontent.com/microsoft-foundry/foundry-samples/${ref}/${samplePath}/agent.yaml`; + try { + const content = await fetchText(rawUrl); + return parseAgentYaml(content); + } catch { + return null; + } +} + +// Azure OpenAI configuration (from GitHub Secrets via env vars) +const AZURE_OPENAI_ENDPOINT = process.env.AZURE_OPENAI_ENDPOINT || ''; +const AZURE_OPENAI_API_KEY = process.env.AZURE_OPENAI_API_KEY || ''; +const AZURE_OPENAI_DEPLOYMENT = process.env.AZURE_OPENAI_DEPLOYMENT || 'gpt-4o-mini'; + +/** + * Fetch README.md content for a sample directory. + * @param {string} samplePath + * @param {string} ref + * @returns {Promise} + */ +async function fetchReadme(samplePath, ref) { + const rawUrl = `https://raw.githubusercontent.com/microsoft-foundry/foundry-samples/${ref}/${samplePath}/README.md`; + try { + return await fetchText(rawUrl); + } catch { + return null; + } +} + +/** + * Call Azure OpenAI to generate displayName and description from README content. + * @param {string} readmeContent + * @param {string} samplePath + * @returns {Promise<{ displayName: string, description: string } | null>} + */ +async function generateWithLLM(readmeContent, samplePath) { + if (!AZURE_OPENAI_ENDPOINT || !AZURE_OPENAI_API_KEY) { + return null; + } + + const apiUrl = `${AZURE_OPENAI_ENDPOINT}/openai/deployments/${AZURE_OPENAI_DEPLOYMENT}/chat/completions?api-version=2024-08-01-preview`; + + const systemPrompt = `You generate metadata for a VS Code template picker. +The user has already selected language, framework, and protocol before seeing these items. + +Rules for displayName: +- 1-3 words, max 4 words +- Describe the core capability only (e.g. "Hello World", "Multi-Turn Chat", "Local Tools", "MCP Tools", "Note-Taking") +- Do NOT include: language names, protocol names, framework names, "Agent", "Hosted", "Sample", "Demo" +- Use Title Case + +Rules for description: +- One sentence, max 100 characters +- Plain text, no markdown +- Describe what the sample does, not how + +Examples: + {"displayName": "Hello World", "description": "Minimal agent that echoes a response from a Foundry model."} + {"displayName": "Multi-Turn Chat", "description": "Conversational agent with multi-turn session history."} + {"displayName": "Local Tools", "description": "Agent with local function tools for hotel search."} + {"displayName": "MCP Tools", "description": "Agent that discovers and invokes tools from a remote MCP server."} + {"displayName": "Note-Taking", "description": "Agent that saves and retrieves notes using function calling."} + +Respond ONLY with a JSON object: {"displayName": "...", "description": "..."}`; + + const userPrompt = `Path: ${samplePath} + +README.md: +${readmeContent.substring(0, 2000)}`; + + const body = { + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + temperature: 0, + max_tokens: 150, + }; + + try { + const response = await fetch(apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'api-key': AZURE_OPENAI_API_KEY, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + console.warn(`LLM API returned ${response.status} for ${samplePath}`); + return null; + } + + const data = await response.json(); + const content = data.choices?.[0]?.message?.content?.trim(); + if (!content) { + return null; + } + + // Parse JSON response — strip markdown code fences if present + const jsonStr = content.replace(/^```json\s*/, '').replace(/\s*```$/, ''); + const parsed = JSON.parse(jsonStr); + + const displayName = typeof parsed.displayName === 'string' ? parsed.displayName.trim() : ''; + const description = typeof parsed.description === 'string' ? parsed.description.trim() : ''; + + if (!displayName && !description) { + return null; + } + + return { displayName, description }; + } catch (/** @type {any} */ err) { + console.warn(`LLM call failed for ${samplePath}: ${err.message}`); + return null; + } +} + +/** + * Fallback: derive displayName from directory name. + * @param {string} samplePath + * @returns {string} + */ +function displayNameFromPath(samplePath) { + const dirName = samplePath.split('/').pop() || ''; + return dirName + .replace(/^\d+-/, '') + .split('-') + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '); +} + +/** + * Scan the foundry-samples repo and build the flat template list. + * @param {string} commitSha + * @returns {Promise>} + */ +async function scanTemplates(commitSha) { + /** @type {Array<{language: string, framework: string, protocol: string, displayName: string, description: string, path: string, requiresModel: boolean}>} */ + const templates = []; + + for (const language of LANGUAGES) { + const hostedRoot = `samples/${language}/hosted-agents`; + + for (const framework of FRAMEWORKS) { + const frameworkPath = `${hostedRoot}/${framework}`; + const subdirs = await listDirs(frameworkPath, commitSha); + + const protocolDirs = subdirs.filter((d) => d === 'responses' || d === 'invocations'); + const directTemplateDirs = subdirs.filter( + (d) => d !== 'responses' && d !== 'invocations' && d !== 'README.md' + ); + + // Templates grouped by protocol subdirectories + for (const protocolDir of protocolDirs) { + const protocolPath = `${frameworkPath}/${protocolDir}`; + const templateDirs = await listDirs(protocolPath, commitSha); + + for (const templateDir of templateDirs) { + const templatePath = `${protocolPath}/${templateDir}`; + const agentInfo = await fetchAgentYaml(templatePath, commitSha); + + /** @type {'responses' | 'invocations'} */ + let protocol = /** @type {'responses' | 'invocations'} */ (protocolDir); + let requiresModel = true; + if (agentInfo) { + requiresModel = agentInfo.hasModelEnv; + if (agentInfo.protocols.length > 0) { + protocol = agentInfo.protocols[0]; + } + } + + templates.push({ + language, + framework, + protocol, + displayName: '', + description: '', + path: templatePath, + requiresModel, + }); + } + } + + // Templates directly under framework dir (e.g. csharp/agent-framework/hello-world) + for (const templateDir of directTemplateDirs) { + const templatePath = `${frameworkPath}/${templateDir}`; + const agentInfo = await fetchAgentYaml(templatePath, commitSha); + + /** @type {'responses' | 'invocations'} */ + let protocol = 'responses'; + let requiresModel = true; + if (agentInfo) { + requiresModel = agentInfo.hasModelEnv; + if (agentInfo.protocols.length > 0) { + protocol = agentInfo.protocols[0]; + } + } + + templates.push({ + language, + framework, + protocol, + displayName: '', + description: '', + path: templatePath, + requiresModel, + }); + } + } + } + + return templates; +} + +/** + * Build the dimensions section from discovered templates and defaults. + * @param {Array<{language: string, framework: string, protocol: string}>} templates + */ +function buildDimensions(templates) { + /** @type {Record}>} */ + const dimensions = {}; + + for (const dimKey of /** @type {const} */ (['language', 'framework', 'protocol'])) { + const defaults = DIMENSION_DEFAULTS[dimKey]; + const seenIds = [...new Set(templates.map((t) => t[dimKey]))].sort(); + const options = seenIds.map((id) => ({ + id, + displayName: defaults.options[id] || id, + })); + dimensions[dimKey] = { + title: defaults.title, + placeholder: defaults.placeholder, + options, + }; + } + + return dimensions; +} + +/** + * Preserve existing displayName and description from the current catalog. + * Only non-empty existing values are kept — never overwritten. + * @param {Array<{displayName: string, description: string, path: string}>} templates + */ +function mergeExistingDisplayFields(templates) { + if (!existsSync(OUTPUT_PATH)) { + return; + } + + try { + const existing = JSON.parse(readFileSync(OUTPUT_PATH, 'utf-8')); + /** @type {Map} */ + const existingByPath = new Map(); + for (const t of existing.templates || []) { + if (t.path) { + existingByPath.set(t.path, t); + } + } + + for (const template of templates) { + const prev = existingByPath.get(template.path); + if (prev) { + if (prev.displayName) { + template.displayName = prev.displayName; + } + if (prev.description) { + template.description = prev.description; + } + } + } + } catch (/** @type {any} */ err) { + console.warn(`Warning: could not read existing catalog: ${err.message}`); + } +} + +/** + * Auto-fill empty displayName and description using LLM (with README as context). + * Falls back to directory-name-based displayName if LLM is unavailable. + * Only fills fields that are still empty after merging existing values. + * @param {Array<{displayName: string, description: string, path: string}>} templates + * @param {string} commitSha + */ +async function autoFillDisplayFields(templates, commitSha) { + const emptyTemplates = templates.filter((t) => !t.displayName || !t.description); + if (emptyTemplates.length === 0) { + console.log('All templates already have displayName and description.'); + return; + } + + const hasLLM = Boolean(AZURE_OPENAI_ENDPOINT && AZURE_OPENAI_API_KEY); + console.log( + `Auto-filling ${emptyTemplates.length} templates${hasLLM ? ' using LLM' : ' using directory name fallback'}...` + ); + + for (const template of emptyTemplates) { + if (hasLLM) { + const readme = await fetchReadme(template.path, commitSha); + if (readme) { + const result = await generateWithLLM(readme, template.path); + if (result) { + if (!template.displayName && result.displayName) { + template.displayName = result.displayName; + } + if (!template.description && result.description) { + template.description = result.description; + } + } + } + } + + // Fallback: derive displayName from directory name if still empty + if (!template.displayName) { + template.displayName = displayNameFromPath(template.path); + } + } +} + +async function main() { + const commitSha = parseCommitShaArg(); + console.log(`Using commit: ${commitSha}`); + + console.log('Scanning templates...'); + const templates = await scanTemplates(commitSha); + console.log(`Found ${templates.length} templates`); + + // Step 1: Preserve existing PM-edited or previously auto-filled values + mergeExistingDisplayFields(templates); + + // Step 2: Auto-fill remaining empty fields (LLM if configured, else directory name fallback) + await autoFillDisplayFields(templates, commitSha); + + const dimensions = buildDimensions(templates); + + const catalog = { + commitSha, + repo: SAMPLES_REPO_URL, + generatedAt: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'), + dimensions, + templateSelection: TEMPLATE_SELECTION, + templates, + }; + + const outputDir = dirname(OUTPUT_PATH); + if (!existsSync(outputDir)) { + mkdirSync(outputDir, { recursive: true }); + } + writeFileSync(OUTPUT_PATH, JSON.stringify(catalog, null, 4) + '\n', 'utf-8'); + + console.log(`Wrote ${OUTPUT_PATH}`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/.github/workflows/sync-sample-catalog.yml b/.github/workflows/sync-sample-catalog.yml new file mode 100644 index 0000000..5f285d9 --- /dev/null +++ b/.github/workflows/sync-sample-catalog.yml @@ -0,0 +1,160 @@ +name: Sync Sample Catalog + +on: + workflow_dispatch: + inputs: + commit_sha: + description: "Commit SHA from microsoft-foundry/foundry-samples to pin to" + required: true + type: string + +permissions: + contents: write + pull-requests: write + +jobs: + sync: + runs-on: ubuntu-latest + env: + PR_LABEL_CANDIDATES: | + automated-pr + area:samples + area:hosted-agent + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.SYNC_APP_ID }} + private-key: ${{ secrets.SYNC_APP_PRIVATE_KEY }} + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Generate sample catalog + env: + REPO_ROOT: ${{ github.workspace }} + GITHUB_TOKEN: ${{ github.token }} + AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }} + AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }} + AZURE_OPENAI_DEPLOYMENT: ${{ secrets.AZURE_OPENAI_DEPLOYMENT }} + run: node .github/scripts/generate_sample_catalog.mjs "${{ inputs.commit_sha }}" + + - name: Detect catalog changes + id: diff + shell: bash + run: | + set -euo pipefail + + catalog_file="samples/hosted-agent/sample-catalog.json" + + if [[ ! -f "$catalog_file" ]]; then + echo "Catalog file not generated." + echo "has_changes=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if git diff --quiet -- "$catalog_file"; then + echo "No changes to sample catalog." + echo "has_changes=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "Catalog has changes:" + git diff --stat -- "$catalog_file" + + { + echo "## Sample Catalog Sync" + echo + echo "The sample catalog was regenerated from \`microsoft-foundry/foundry-samples\`." + echo + echo "### Changed files" + echo "- \`$catalog_file\`" + } >> "$GITHUB_STEP_SUMMARY" + + echo "has_changes=true" >> "$GITHUB_OUTPUT" + + - name: Resolve pull request labels + if: steps.diff.outputs.has_changes == 'true' + id: labels + shell: bash + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + set -euo pipefail + + existing_labels=$(gh api --paginate repos/${{ github.repository }}/labels --jq '.[].name') + matched_labels="" + + while IFS= read -r candidate; do + if [[ -z "$candidate" ]]; then + continue + fi + + if grep -Fqx "$candidate" <<< "$existing_labels"; then + matched_labels+="$candidate" + matched_labels+=$'\n' + fi + done <<< "$PR_LABEL_CANDIDATES" + + { + echo "labels<> "$GITHUB_OUTPUT" + + - name: Generate PR branch name + if: steps.diff.outputs.has_changes == 'true' + id: branch + shell: bash + run: | + date_suffix=$(date -u +%Y%m%d) + echo "name=ci/sync-sample-catalog-${{ github.ref_name }}-${date_suffix}" >> "$GITHUB_OUTPUT" + + - name: Create draft pull request + if: steps.diff.outputs.has_changes == 'true' + id: cpr + uses: peter-evans/create-pull-request@v8 + with: + token: ${{ steps.app-token.outputs.token }} + branch: ${{ steps.branch.outputs.name }} + base: ${{ github.ref_name }} + delete-branch: true + draft: always-true + commit-message: | + ci: sync sample catalog from foundry-samples (${{ github.ref_name }}) + title: "ci: sync sample catalog from foundry-samples (${{ github.ref_name }})" + body: | + ## Summary + This automated draft PR updates `samples/hosted-agent/sample-catalog.json` by scanning + the `microsoft-foundry/foundry-samples` repository for hosted-agent sample templates. + + ## What changed + - New templates added or removed based on the upstream repo structure. + - `commitSha` updated to the latest commit on `main`. + - `requiresModel` and `protocol` values re-derived from each sample's `agent.yaml`. + - PM-edited `displayName` and `description` values are preserved from the previous catalog. + + ## Reviewer Checks + - Confirm new templates have correct `language`, `framework`, and `protocol` values. + - Fill in `displayName` and `description` for any new templates (empty by default). + - Confirm no PM-edited display fields were accidentally lost. + labels: ${{ steps.labels.outputs.labels }} + add-paths: | + samples/hosted-agent/sample-catalog.json + + - name: Report PR result + if: steps.diff.outputs.has_changes == 'true' + shell: bash + run: | + echo "PR operation: ${{ steps.cpr.outputs.pull-request-operation }}" + echo "PR URL: ${{ steps.cpr.outputs.pull-request-url }}" From fe5e810f7b281e929a9c5f9959d1ceb996b4d440 Mon Sep 17 00:00:00 2001 From: "sync-hosted-agent-sample-bot[bot]" <266662545+sync-hosted-agent-sample-bot[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 16:35:12 +0800 Subject: [PATCH 5/6] ci: sync dev to main (#578) --- .github/workflows/sync-sample-catalog.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sync-sample-catalog.yml b/.github/workflows/sync-sample-catalog.yml index 5f285d9..5e40113 100644 --- a/.github/workflows/sync-sample-catalog.yml +++ b/.github/workflows/sync-sample-catalog.yml @@ -62,14 +62,17 @@ jobs: exit 0 fi - if git diff --quiet -- "$catalog_file"; then + # Stage the file so git diff detects both new and modified files + git add "$catalog_file" + + if git diff --cached --quiet -- "$catalog_file"; then echo "No changes to sample catalog." echo "has_changes=false" >> "$GITHUB_OUTPUT" exit 0 fi echo "Catalog has changes:" - git diff --stat -- "$catalog_file" + git diff --cached --stat -- "$catalog_file" { echo "## Sample Catalog Sync" From 037794ab17419f5948a8f31532b4d75b74918b0b Mon Sep 17 00:00:00 2001 From: "sync-hosted-agent-sample-bot[bot]" <266662545+sync-hosted-agent-sample-bot[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 15:07:01 +0800 Subject: [PATCH 6/6] ci: sync dev to main (#653) --- .github/scripts/generate_sample_catalog.mjs | 568 +++++++++++++++----- .github/workflows/sync-sample-catalog.yml | 31 +- samples/hosted-agent/sample-catalog.json | 527 ++++++++++++++++++ samples/hosted-agent/sample-overrides.json | 8 + 4 files changed, 995 insertions(+), 139 deletions(-) create mode 100644 samples/hosted-agent/sample-catalog.json create mode 100644 samples/hosted-agent/sample-overrides.json diff --git a/.github/scripts/generate_sample_catalog.mjs b/.github/scripts/generate_sample_catalog.mjs index 94fa851..2042068 100644 --- a/.github/scripts/generate_sample_catalog.mjs +++ b/.github/scripts/generate_sample_catalog.mjs @@ -2,11 +2,12 @@ /** * Generate samples/hosted-agent/sample-catalog.json from the foundry-samples repository. * - * Scans the hosted-agents directory tree in microsoft-foundry/foundry-samples, - * parses each sample's agent.yaml to extract protocol and model requirements, - * fetches README.md to auto-fill displayName and description (only for empty fields), - * and writes a flat catalog JSON that the VS Code extension consumes for - * template selection. + * Walks the hosted-agents tree in microsoft-foundry/foundry-samples once via + * the git-tree API, parses each sample's agent.yaml (+ optional + * agent.manifest.yaml) for protocol and model requirements, derives + * displayName from the directory name, and — when AZURE_OPENAI_* secrets are + * set — fills the description with a short LLM-generated sentence sourced + * from the sample's README.md. * * Usage: * node generate_sample_catalog.mjs @@ -15,9 +16,10 @@ * GITHUB_TOKEN Optional GitHub token for API authentication. * REPO_ROOT Repository root (defaults to two levels up from this script). * SAMPLES_REPO_URL Source repo URL (defaults to https://github.com/microsoft-foundry/foundry-samples/). + * AZURE_OPENAI_* Optional; when set, descriptions are LLM-generated. */ -import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; +import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync } from 'node:fs'; import { join, dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -28,6 +30,7 @@ const REPO_ROOT = process.env.REPO_ROOT || resolve(__dirname, '..', '..'); const SAMPLES_REPO_URL = process.env.SAMPLES_REPO_URL || 'https://github.com/microsoft-foundry/foundry-samples/'; const SAMPLES_REPO_API = 'https://api.github.com/repos/microsoft-foundry/foundry-samples'; const OUTPUT_PATH = join(REPO_ROOT, 'samples', 'hosted-agent', 'sample-catalog.json'); +const OVERRIDES_PATH = join(REPO_ROOT, 'samples', 'hosted-agent', 'sample-overrides.json'); const GITHUB_TOKEN = process.env.GITHUB_TOKEN || ''; @@ -47,7 +50,10 @@ const DIMENSION_DEFAULTS = { framework: { title: 'Select a Framework', placeholder: 'Choose the framework for your agent', + // Insertion order here drives the option order in the generated catalog + // (see buildDimensions). Keep the most actively promoted framework first. options: { + 'copilot-sdk': 'Copilot SDK', 'agent-framework': 'Agent Framework', 'bring-your-own': 'Bring Your Own', }, @@ -70,6 +76,24 @@ const TEMPLATE_SELECTION = { // Path segments must be alphanumeric, hyphens, underscores, or dots const SAFE_PATH_SEGMENT = /^[a-zA-Z0-9._-]+$/; +/** + * Collected anomaly messages surfaced in CI step summary so reviewers do not + * silently merge a catalog with missing/derived data. Always populated, even + * when running locally without a step-summary file. + * @type {string[]} + */ +const warnings = []; + +/** + * Record a warning that should appear in the CI step summary, and mirror it + * to stderr for live log visibility. + * @param {string} message + */ +function warn(message) { + warnings.push(message); + console.warn(`WARN: ${message}`); +} + /** * @param {string} url * @returns {Promise} @@ -108,12 +132,17 @@ async function fetchText(url) { } /** - * Validate that a path segment is safe (no traversal, no special chars). + * Validate that a path segment is safe: no traversal, no special chars, no + * leading-dot directories (e.g. `.claude`, `.github`) which are tooling + * metadata, not sample templates. * @param {string} segment * @returns {boolean} */ function isSafePathSegment(segment) { - return SAFE_PATH_SEGMENT.test(segment) && segment !== '..' && segment !== '.'; + if (segment === '.' || segment === '..' || segment.startsWith('.')) { + return false; + } + return SAFE_PATH_SEGMENT.test(segment); } /** @@ -133,28 +162,95 @@ function parseCommitShaArg() { } /** - * List subdirectory names under a given API path. - * @param {string} apiPath + * Fetch the full recursive git tree for a ref. One API call covers the entire + * `samples/` hierarchy, which is much cheaper (and more accurate) than walking + * the `/contents/` endpoint level-by-level — and it lets us discover samples + * at arbitrary nesting depths (e.g. `bring-your-own/voicelive/