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 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 diff --git a/samples/hosted-agent/dotnet/agent/Program.cs b/samples/hosted-agent/dotnet/agent/Program.cs index 5b28596..cca8996 100644 --- a/samples/hosted-agent/dotnet/agent/Program.cs +++ b/samples/hosted-agent/dotnet/agent/Program.cs @@ -5,9 +5,7 @@ using System.ComponentModel; using System.Globalization; using System.Text; -using System.ClientModel.Primitives; using Azure.AI.AgentServer.AgentFramework.Extensions; -using Azure.AI.OpenAI; using Azure.AI.Projects; using Azure.Identity; using Microsoft.Agents.AI; @@ -17,8 +15,10 @@ var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("MODEL_DEPLOYMENT_NAME") ?? "gpt-4.1-mini"; + Console.WriteLine($"Project Endpoint: {endpoint}"); Console.WriteLine($"Model Deployment: {deploymentName}"); + // Simulated hotel data for Seattle var seattleHotels = new[] { @@ -92,26 +92,10 @@ string GetAvailableHotels( var credential = new DefaultAzureCredential(); AIProjectClient projectClient = new AIProjectClient(new Uri(endpoint), credential); -// Get the OpenAI connection from the project -ClientConnection connection = projectClient.GetConnection(typeof(AzureOpenAIClient).FullName!); - -if (!connection.TryGetLocatorAsUri(out Uri? openAiEndpoint) || openAiEndpoint is null) -{ - throw new InvalidOperationException("Failed to get OpenAI endpoint from project connection."); -} -openAiEndpoint = new Uri($"https://{openAiEndpoint.Host}"); -Console.WriteLine($"OpenAI Endpoint: {openAiEndpoint}"); - -var chatClient = new AzureOpenAIClient(openAiEndpoint, credential) - .GetChatClient(deploymentName) - .AsIChatClient() - .AsBuilder() - .UseOpenTelemetry(sourceName: "Agents", configure: cfg => cfg.EnableSensitiveData = false) - .Build(); - -var agent = new ChatClientAgent(chatClient, - name: "SeattleHotelAgent", - instructions: """ +var agent = (await projectClient.CreateAIAgentAsync( + model: deploymentName, + name: "SeattleHotelAgent", + instructions: """ You are a helpful travel assistant specializing in finding hotels in Seattle, Washington. When a user asks about hotels in Seattle: @@ -124,10 +108,14 @@ 5. Offer to help with additional questions about the hotels or Seattle Be conversational and helpful. If users ask about things outside of Seattle hotels, politely let them know you specialize in Seattle hotel recommendations. """, - tools: [AIFunctionFactory.Create(GetAvailableHotels)]) + tools: [AIFunctionFactory.Create(GetAvailableHotels)], + clientFactory: client => client.AsBuilder() + .UseOpenTelemetry(sourceName: "Agents", configure: cfg => cfg.EnableSensitiveData = false) + .Build() + )) .AsBuilder() - .UseOpenTelemetry(sourceName: "Agents", configure: cfg => cfg.EnableSensitiveData = false) - .Build(); + .UseOpenTelemetry(sourceName: "Agents", configure: cfg => cfg.EnableSensitiveData = false) + .Build(); Console.WriteLine("Seattle Hotel Agent Server running on http://localhost:8088"); await agent.RunAIAgentAsync(telemetrySourceName: "Agents"); 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/agent/{{SafeProjectName}}.csproj b/samples/hosted-agent/dotnet/agent/{{SafeProjectName}}.csproj index d714c09..8d96a1e 100644 --- a/samples/hosted-agent/dotnet/agent/{{SafeProjectName}}.csproj +++ b/samples/hosted-agent/dotnet/agent/{{SafeProjectName}}.csproj @@ -7,10 +7,9 @@ true - - - - - + + + + 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/.dockerignore b/samples/hosted-agent/python/langgraph-agent/.dockerignore new file mode 100644 index 0000000..04d0a41 --- /dev/null +++ b/samples/hosted-agent/python/langgraph-agent/.dockerignore @@ -0,0 +1,65 @@ +# Virtual environments +.venv/ +venv/ +env/ +.env +.python-version + +# Environment files with secrets +*.local + +# Python build artifacts +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Testing +.tox/ +.nox/ +.coverage +.coverage.* +htmlcov/ +.pytest_cache/ +.mypy_cache/ + +# IDE and OS files +.DS_Store +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Foundry config +.foundry/ +build-source-*/ + +# Git +.git/ +.gitignore + +# Docker +.dockerignore + +# Documentation +docs/ +*.md +!README.md +LICENSE diff --git a/samples/hosted-agent/python/langgraph-agent/.env b/samples/hosted-agent/python/langgraph-agent/.env new file mode 100644 index 0000000..5df5f96 --- /dev/null +++ b/samples/hosted-agent/python/langgraph-agent/.env @@ -0,0 +1,5 @@ +# IMPORTANT: Never commit .env to version control - add it to .gitignore + +AZURE_AI_PROJECT_ENDPOINT={{{AzureAIProjectEndpoint}}} +AZURE_AI_MODEL_DEPLOYMENT_NAME={{ModelDeploymentName}} + diff --git a/samples/hosted-agent/python/langgraph-agent/.gitignore b/samples/hosted-agent/python/langgraph-agent/.gitignore new file mode 100644 index 0000000..5628e6d --- /dev/null +++ b/samples/hosted-agent/python/langgraph-agent/.gitignore @@ -0,0 +1,17 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv + +.env + +.foundry + +# LangGraph temp files +.langgraph_api \ No newline at end of file diff --git a/samples/hosted-agent/python/langgraph-agent/.vscode/launch.json b/samples/hosted-agent/python/langgraph-agent/.vscode/launch.json new file mode 100644 index 0000000..fe92f4a --- /dev/null +++ b/samples/hosted-agent/python/langgraph-agent/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug Local Workflow HTTP Server", + "type": "debugpy", + "request": "attach", + "connect": { + "host": "localhost", + "port": 5679 + }, + "preLaunchTask": "Open Agent Inspector", + "internalConsoleOptions": "neverOpen", + "postDebugTask": "Terminate All Tasks" + } + ] +} diff --git a/samples/hosted-agent/python/langgraph-agent/.vscode/tasks.json b/samples/hosted-agent/python/langgraph-agent/.vscode/tasks.json new file mode 100644 index 0000000..9a7b498 --- /dev/null +++ b/samples/hosted-agent/python/langgraph-agent/.vscode/tasks.json @@ -0,0 +1,70 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Validate prerequisites", + "type": "aitk", + "command": "debug-check-prerequisites", + "args": { + "portOccupancy": [5679, 8088] + } + }, + { + "label": "Run Agent/Workflow HTTP Server", + "type": "shell", + "command": "${command:python.interpreterPath} -m debugpy --listen 127.0.0.1:5679 main.py --port 8088", + "isBackground": true, + "options": { + "cwd": "${workspaceFolder}" + }, + "dependsOn": ["Validate prerequisites"], + "problemMatcher": { + "pattern": [ + { + "regexp": "^.*$", + "file": 0, + "location": 1, + "message": 2 + } + ], + "background": { + "activeOnStart": true, + "beginsPattern": ".*", + "endsPattern": "Application startup complete|running on|Started server process" + } + } + }, + { + "label": "Open Agent Inspector", + "type": "shell", + "command": "echo '${input:openAgentInspector}'", + "presentation": { + "reveal": "never" + }, + "dependsOn": ["Run Agent/Workflow HTTP Server"] + }, + { + "label": "Terminate All Tasks", + "command": "echo ${input:terminate}", + "type": "shell", + "problemMatcher": [] + } + ], + "inputs": [ + { + "id": "openAgentInspector", + "type": "command", + "command": "ai-mlstudio.openTestTool", + "args": { + "triggeredFrom": "tasks", + "port": 8088 + } + }, + { + "id": "terminate", + "type": "command", + "command": "workbench.action.tasks.terminate", + "args": "terminateAll" + } + ] +} diff --git a/samples/hosted-agent/python/langgraph-agent/Dockerfile b/samples/hosted-agent/python/langgraph-agent/Dockerfile new file mode 100644 index 0000000..077fb78 --- /dev/null +++ b/samples/hosted-agent/python/langgraph-agent/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.14-slim + +WORKDIR /app + +COPY ./ . + +RUN pip install --upgrade pip && \ + if [ -f requirements.txt ]; then \ + pip install -r requirements.txt; \ + else \ + echo "No requirements.txt found"; \ + fi + +EXPOSE 8088 + +CMD ["python", "main.py"] diff --git a/samples/hosted-agent/python/langgraph-agent/README.md b/samples/hosted-agent/python/langgraph-agent/README.md new file mode 100644 index 0000000..c46cf4e --- /dev/null +++ b/samples/hosted-agent/python/langgraph-agent/README.md @@ -0,0 +1,201 @@ +**IMPORTANT!** All samples and other resources made available in this GitHub repository ("samples") are designed to assist in accelerating development of agents, solutions, and agent workflows for various scenarios. Review all provided resources and carefully test output behavior in the context of your use case. AI responses may be inaccurate and AI actions should be monitored with human oversight. Learn more in the transparency documents for [Agent Service](https://learn.microsoft.com/en-us/azure/ai-foundry/responsible-ai/agents/transparency-note) and [Agent Framework](https://github.com/microsoft/agent-framework/blob/main/TRANSPARENCY_FAQ.md). + +Agents, solutions, or other output you create may be subject to legal and regulatory requirements, may require licenses, or may not be suitable for all industries, scenarios, or use cases. By using any sample, you are acknowledging that any output created using those samples are solely your responsibility, and that you will comply with all applicable laws, regulations, and relevant safety standards, terms of service, and codes of conduct. + +Third-party samples contained in this folder are subject to their own designated terms, and they have not been tested or verified by Microsoft or its affiliates. + +Microsoft has no responsibility to you or others with respect to any of these samples or any resulting output. + +# What this sample demonstrates + +This sample demonstrates how to build a **LangGraph-based hosted agent** with: + +- **LangGraph ReAct agent** - Uses LangGraph's `create_react_agent` for tool-calling orchestration +- **Local Python tool execution** - Run custom Python functions as agent tools + +Code-based agents can execute **any Python code** you write. This sample includes a Seattle Hotel Agent with a `get_available_hotels` tool that searches for available hotels based on check-in/check-out dates and budget preferences. + +The agent is hosted using the [Azure AI AgentServer SDK](https://pypi.org/project/azure-ai-agentserver-agentframework/) and can be deployed to Microsoft Foundry. + +## How It Works + +### LangGraph Agent + +In [main.py](main.py), the agent is built using [LangGraph](https://langchain-ai.github.io/langgraph/)'s `create_react_agent`, which creates a ReAct-style agent that can reason about when to call tools and how to use their results. The agent uses `AzureChatOpenAI` from LangChain as the underlying LLM and `MemorySaver` for in-memory conversation checkpointing. + +### Local Tools Integration + +The agent uses a local Python function (`get_available_hotels`) decorated with LangChain's `@tool` decorator. This simulates a hotel availability API and demonstrates how code-based agents can execute custom server-side logic that prompt agents cannot access. + +The tool accepts: + +- **check_in_date** - Check-in date in YYYY-MM-DD format +- **check_out_date** - Check-out date in YYYY-MM-DD format +- **max_price** - Maximum price per night in USD (optional, defaults to $500) + +### Agent Hosting + +The LangGraph agent is served using the [`azure-ai-agentserver-langgraph`](https://pypi.org/project/azure-ai-agentserver-langgraph/) adapter via the `from_langgraph()` function, which wraps the LangGraph agent into a REST API endpoint compatible with the OpenAI Responses protocol. + +## Running the Agent Locally + +### Prerequisites + +Before running this sample, ensure you have: + +1. **Microsoft Foundry Project** + - A Microsoft Project created. + - Chat model deployed (e.g., `gpt-4o` or `gpt-4.1`). + - Note your project endpoint URL and model deployment name. + +2. **Azure CLI** + - Installed and authenticated + - Run `az login` and verify with `az account show` + +3. **Python 3.10 or higher** + - Verify your version: `python --version` + - If you have Python 3.9 or older, install a newer version: + - Windows: `winget install Python.Python.3.12` + - macOS: `brew install python@3.12` + - Linux: Use your package manager + +### Environment Variables + +Set the following environment variables: + +- `AZURE_AI_PROJECT_ENDPOINT` - Your Microsoft Foundry project endpoint URL (required) +- `AZURE_AI_MODEL_DEPLOYMENT_NAME` - The deployment name for your chat model (defaults to `gpt-4.1-mini`) + +This sample loads environment variables from a local `.env` file if present. + +Create a `.env` file in this directory with the following content: + +``` +AZURE_AI_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4.1-mini +``` + +Or set them via PowerShell: + +```powershell +# Replace with your actual values +$env:AZURE_AI_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4.1-mini" +``` + +### Setting Up a Virtual Environment + +It's recommended to use a virtual environment to isolate project dependencies: + +**macOS/Linux:** + +```bash +python -m venv .venv +source .venv/bin/activate +``` + +**Windows (PowerShell):** + +```powershell +python -m venv .venv +.\.venv\Scripts\Activate.ps1 +``` + +### Installing Dependencies + +Install the required Python dependencies using pip: + +```bash +pip install -r requirements.txt +``` + +The required packages are: + +- `azure-ai-agentserver-langgraph` - AgentServer adapter for LangGraph agents +- `azure-identity` - Azure authentication via DefaultAzureCredential + +### Running the Sample + +#### Option 1: Press F5 (Recommended) + +Press **F5** in VS Code to start debugging. Alternatively, you can use the VS Code debug menu: + +1. Open the **Run and Debug** view (Ctrl+Shift+D / Cmd+Shift+D) +2. Select **"Debug Local Workflow HTTP Server"** from the dropdown +3. Click the green **Start Debugging** button (or press F5) + +This will: + +1. Start the HTTP server with debugging enabled +2. Open the AI Toolkit Agent Inspector for interactive testing +3. Allow you to set breakpoints and inspect the workflow + +#### Option 2: Run in Terminal + +Run as HTTP server (default): + +```bash +python main.py +``` + +This will start the hosted agent locally on `http://localhost:8088/`. + +**PowerShell (Windows):** + +```powershell +$body = @{ + input = "I need a hotel in Seattle from 2025-03-15 to 2025-03-18, budget under `$200 per night" + stream = $false +} | ConvertTo-Json + +Invoke-RestMethod -Uri http://localhost:8088/responses -Method Post -Body $body -ContentType "application/json" +``` + +**Bash/curl (Linux/macOS):** + +```bash +curl -sS -H "Content-Type: application/json" -X POST http://localhost:8088/responses \ + -d '{"input": "Find me hotels in Seattle for March 20-23, 2025 under $200 per night","stream":false}' +``` + +The agent will use the `get_available_hotels` tool to search for available hotels matching your criteria. + +## Deploying the Agent to Microsoft Foundry + +To deploy the hosted agent: + +1. Open the VS Code Command Palette and run the `Microsoft Foundry: Deploy Hosted Agent` command. + +2. Follow the interactive deployment prompts. The extension will help you select or create the container files it needs: + - It first looks for a Dockerfile at the repository root. If not found, you can select an existing Dockerfile or generate a new one. + - If you choose to generate a Dockerfile, the extension will place the files at the repo root and open the Dockerfile in the editor; the deployment flow is intentionally cancelled in that case so you can review and edit the generated files before re-running the deploy command. + +3. After deployment completes, the hosted agent appears under the `Hosted Agents (Preview)` section of the extension tree. You can select the agent there to view details and test it using the integrated playground. + +**What the deploy flow does for you:** + +- Creates or obtains an Azure Container Registry for the target project. +- Builds and pushes a container image from your workspace (the build packages the workspace respecting `.dockerignore`). +- Creates an agent version in Microsoft Foundry using the built image. If a `.env` file exists at the workspace root, the extension will parse it and include its key/value pairs as the hosted agent's environment variables in the create request (these variables will be available to the agent runtime). +- Starts the agent container on the project's capability host. If the capability host is not provisioned, the extension will prompt you to enable it and will guide you through creating it. + +### MSI Configuration in the Azure Portal + +This sample requires the Microsoft Foundry Project to authenticate using a Managed Identity when running remotely in Azure. Grant the project's managed identity the required permissions by assigning the built-in [Azure AI User](https://aka.ms/foundry-ext-project-role) role. + +To configure the Managed Identity: + +1. In the Azure Portal, open the **Foundry resource** (name ends with `-resource`). +2. Select "Access control (IAM)" from the left-hand menu. +3. Click "Add" and choose "Add role assignment". +4. In the role selection, search for and select "Azure AI User", then click "Next". +5. For "Assign access to", choose "Managed identity". +6. Click "Select members", locate the managed identity associated with your **Foundry Project** (you can search by the project name, the one without `-resource` ending), then click "Select". +7. Click "Review + assign" to complete the assignment. +8. Allow a few minutes for the role assignment to propagate before running the application. + +## Additional Resources + +- [LangGraph Documentation](https://langchain-ai.github.io/langgraph/) +- [LangChain Azure OpenAI Integration](https://python.langchain.com/docs/integrations/chat/azure_chat_openai/) +- [Managed Identities for Azure Resources](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/) diff --git a/samples/hosted-agent/python/langgraph-agent/agent.py b/samples/hosted-agent/python/langgraph-agent/agent.py new file mode 100644 index 0000000..2753832 --- /dev/null +++ b/samples/hosted-agent/python/langgraph-agent/agent.py @@ -0,0 +1,122 @@ +"""Seattle Hotel Agent — LangGraph implementation. + +A travel assistant that helps users find hotels in Seattle. +Uses LangGraph's create_react_agent with a local hotel search tool. + +Requires AZURE_AI_PROJECT_ENDPOINT and AZURE_AI_MODEL_DEPLOYMENT_NAME in a .env file. +Uses DefaultAzureCredential for authentication. +""" + +import os +from datetime import datetime +from importlib.metadata import version +from typing import Annotated + +from azure.identity import DefaultAzureCredential, get_bearer_token_provider +from dotenv import load_dotenv +from langchain_core.tools import tool +from langchain_openai import AzureChatOpenAI + +load_dotenv() + +endpoint = os.environ["AZURE_AI_PROJECT_ENDPOINT"] +deployment_name = os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4o") +azure_endpoint = endpoint.split("/api/projects")[0] + +credential = DefaultAzureCredential() +token_provider = get_bearer_token_provider(credential, "https://ai.azure.com/.default") +model = AzureChatOpenAI( + model=deployment_name, + api_version="2025-04-01-preview", + azure_endpoint=azure_endpoint, + azure_ad_token_provider=token_provider, +) + +# --------------------------------------------------------------------------- +# Simulated hotel data for Seattle +# --------------------------------------------------------------------------- +SEATTLE_HOTELS = [ + {"name": "Contoso Suites", "price_per_night": 189, "rating": 4.5, "location": "Downtown"}, + {"name": "Fabrikam Residences", "price_per_night": 159, "rating": 4.2, "location": "Pike Place Market"}, + {"name": "Alpine Ski House", "price_per_night": 249, "rating": 4.7, "location": "Seattle Center"}, + {"name": "Margie's Travel Lodge", "price_per_night": 219, "rating": 4.4, "location": "Waterfront"}, + {"name": "Northwind Inn", "price_per_night": 139, "rating": 4.0, "location": "Capitol Hill"}, + {"name": "Relecloud Hotel", "price_per_night": 99, "rating": 3.8, "location": "University District"}, +] + +# --------------------------------------------------------------------------- +# Tools +# --------------------------------------------------------------------------- + +@tool +def get_available_hotels( + check_in_date: Annotated[str, "Check-in date in YYYY-MM-DD format"], + check_out_date: Annotated[str, "Check-out date in YYYY-MM-DD format"], + max_price: Annotated[int, "Maximum price per night in USD (optional)"] = 500, +) -> str: + """Get available hotels in Seattle for the specified dates. + This simulates a call to a fake hotel availability API. + """ + try: + check_in = datetime.strptime(check_in_date, "%Y-%m-%d") + check_out = datetime.strptime(check_out_date, "%Y-%m-%d") + + if check_out <= check_in: + return "Error: Check-out date must be after check-in date." + + nights = (check_out - check_in).days + + available_hotels = [ + hotel for hotel in SEATTLE_HOTELS if hotel["price_per_night"] <= max_price + ] + + if not available_hotels: + return f"No hotels found in Seattle within your budget of ${max_price}/night." + + result = ( + f"Available hotels in Seattle from {check_in_date} to " + f"{check_out_date} ({nights} nights):\n\n" + ) + + for hotel in available_hotels: + total_cost = hotel["price_per_night"] * nights + result += f"**{hotel['name']}**\n" + result += f" Location: {hotel['location']}\n" + result += f" Rating: {hotel['rating']}/5\n" + result += f" ${hotel['price_per_night']}/night (Total: ${total_cost})\n\n" + + return result + + except ValueError as e: + return f"Error parsing dates. Please use YYYY-MM-DD format. Details: {str(e)}" + +# --------------------------------------------------------------------------- +# Agent setup +# --------------------------------------------------------------------------- + +SYSTEM_INSTRUCTIONS = """You are a helpful travel assistant specializing in finding hotels in Seattle, Washington. + +When a user asks about hotels in Seattle: +1. Ask for their check-in and check-out dates if not provided +2. Ask about their budget preferences if not mentioned +3. Use the get_available_hotels tool to find available options +4. Present the results in a friendly, informative way +5. Offer to help with additional questions about the hotels or Seattle + +Be conversational and helpful. +If users ask about things outside of Seattle hotels, politely let them know you specialize in Seattle hotel recommendations.""" + +tools = [get_available_hotels] + + +def create_agent(llm, agent_tools, checkpointer=None): + langgraph_version = version("langgraph") + if langgraph_version < "1.0.0": + from langgraph.prebuilt import create_react_agent + return create_react_agent(llm, agent_tools, checkpointer=checkpointer, prompt=SYSTEM_INSTRUCTIONS) + else: + from langgraph.prebuilt import create_react_agent + return create_react_agent(llm, agent_tools, checkpointer=checkpointer, prompt=SYSTEM_INSTRUCTIONS) + + +agent = create_agent(model, tools) diff --git a/samples/hosted-agent/python/langgraph-agent/agent.yaml b/samples/hosted-agent/python/langgraph-agent/agent.yaml new file mode 100644 index 0000000..a9adc1f --- /dev/null +++ b/samples/hosted-agent/python/langgraph-agent/agent.yaml @@ -0,0 +1,27 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml + +kind: hosted +name: {{AgentName}} +description: > + A travel assistant agent that helps users find hotels in Seattle. + Demonstrates LangGraph agent with local Python tool execution. +metadata: + authors: + - Microsoft + tags: + - Azure AI AgentServer + - LangGraph + - Local Tools + - Travel Assistant + - Hotel Search +protocols: + - protocol: responses + version: v1 +environment_variables: + - name: AZURE_AI_PROJECT_ENDPOINT + 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/langgraph-agent/langgraph.json b/samples/hosted-agent/python/langgraph-agent/langgraph.json new file mode 100644 index 0000000..61db2dd --- /dev/null +++ b/samples/hosted-agent/python/langgraph-agent/langgraph.json @@ -0,0 +1,7 @@ +{ + "dependencies": ["."], + "graphs": { + "agent": "./agent.py:agent" + }, + "env": ".env" +} diff --git a/samples/hosted-agent/python/langgraph-agent/main.py b/samples/hosted-agent/python/langgraph-agent/main.py new file mode 100644 index 0000000..93ee304 --- /dev/null +++ b/samples/hosted-agent/python/langgraph-agent/main.py @@ -0,0 +1,29 @@ +"""AgentServer hosting entrypoint for the Seattle Hotel Agent.""" + +import argparse +from azure.identity import DefaultAzureCredential +from langgraph.checkpoint.memory import MemorySaver +from starlette.middleware.cors import CORSMiddleware + +from azure.ai.agentserver.langgraph import from_langgraph + +from agent import create_agent, model, tools + +agent_executor = create_agent(model, tools, checkpointer=MemorySaver()) + +credential = DefaultAzureCredential() +server = from_langgraph(agent_executor, credentials=credential) + +# Enable CORS to be used with the Agent Inspector +server.app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--port", type=int, default=8088) + args = parser.parse_args() + server.run(port=args.port) diff --git a/samples/hosted-agent/python/langgraph-agent/requirements.txt b/samples/hosted-agent/python/langgraph-agent/requirements.txt new file mode 100644 index 0000000..1ffd060 --- /dev/null +++ b/samples/hosted-agent/python/langgraph-agent/requirements.txt @@ -0,0 +1,3 @@ +azure-ai-agentserver-langgraph==1.0.0b16 +debugpy +python-dotenv \ No newline at end of file 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: ""