diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..dfdb8b771 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..2e3730df7 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,49 @@ +# See for instructions on this file https://help.github.com/articles/about-codeowners/ + +#### Team-owned sample areas ############################################################################ +# Each contributing team owns their sample paths for review routing. See CONTRIBUTING.md for setup guide. +/samples/python/hosted-agents/ @microsoft-foundry/hosted-agents +/samples/csharp/hosted-agents/ @microsoft-foundry/hosted-agents + +#### files referenced in docs (DO NOT EDIT, except for Docs team!!!) ########################################## +/infrastructure/infrastructure-setup-bicep/01-connections/connection-key-vault.bicep @microsoft-foundry/AI-Platform-Docs +/infrastructure/infrastructure-setup-bicep/05-custom-policy-definitions/deny-disallowed-connections.json @microsoft-foundry/AI-Platform-Docs +/infrastructure/infrastructure-setup-terraform/00-basic-azurerm/code/main.tf @microsoft-foundry/AI-Platform-Docs +/infrastructure/infrastructure-setup-terraform/00-basic-azurerm/code/providers.tf @microsoft-foundry/AI-Platform-Docs +/infrastructure/infrastructure-setup-terraform/00-basic-azurerm/code/variables.tf @microsoft-foundry/AI-Platform-Docs +/infrastructure/infrastructure-setup-terraform/00-basic/code/main.tf @microsoft-foundry/AI-Platform-Docs +/infrastructure/infrastructure-setup-terraform/00-basic/code/providers.tf @microsoft-foundry/AI-Platform-Docs +/infrastructure/infrastructure-setup-terraform/00-basic/code/variables.tf @microsoft-foundry/AI-Platform-Docs +/samples-classic/REST/quickstart/quickstart.sh @microsoft-foundry/AI-Platform-Docs +/samples-classic/csharp/quickstart/Samples/.env.example @microsoft-foundry/AI-Platform-Docs +/samples-classic/csharp/quickstart/Samples/AgentFileSearch.cs @microsoft-foundry/AI-Platform-Docs +/samples-classic/csharp/quickstart/Samples/AgentService.cs @microsoft-foundry/AI-Platform-Docs +/samples-classic/csharp/quickstart/Samples/SimpleInference.cs @microsoft-foundry/AI-Platform-Docs +/samples-classic/java/quickstart/src/main/java/com/azure/ai/foundry/samples/AgentSample.java @microsoft-foundry/AI-Platform-Docs +/samples-classic/java/quickstart/src/main/java/com/azure/ai/foundry/samples/ChatCompletionSample.java @microsoft-foundry/AI-Platform-Docs +/samples-classic/java/quickstart/src/main/java/com/azure/ai/foundry/samples/FileSearchAgentSample.java @microsoft-foundry/AI-Platform-Docs +/samples-classic/python/quickstart/create_project.py @microsoft-foundry/AI-Platform-Docs +/samples-classic/python/quickstart/quickstart.py @microsoft-foundry/AI-Platform-Docs +/samples-classic/typescript/quickstart/.env.template @microsoft-foundry/AI-Platform-Docs +/samples-classic/typescript/quickstart/src/quickstart.ts @microsoft-foundry/AI-Platform-Docs +/samples/REST/quickstart/quickstart-chat-with-agent.sh @microsoft-foundry/AI-Platform-Docs +/samples/REST/quickstart/quickstart-create-agent.sh @microsoft-foundry/AI-Platform-Docs +/samples/REST/quickstart/quickstart-responses.sh @microsoft-foundry/AI-Platform-Docs +/samples/csharp/enterprise-agent-tutorial/1-idea-to-prototype/Evaluate/Program.cs @microsoft-foundry/AI-Platform-Docs +/samples/csharp/enterprise-agent-tutorial/1-idea-to-prototype/ModernWorkplaceAssistant/Program.cs @microsoft-foundry/AI-Platform-Docs +/samples/csharp/quickstart/chat-with-agent/quickstart-chat-with-agent.cs @microsoft-foundry/AI-Platform-Docs +/samples/csharp/quickstart/create-agent/quickstart-create-agent.cs @microsoft-foundry/AI-Platform-Docs +/samples/csharp/quickstart/responses/quickstart-responses.cs @microsoft-foundry/AI-Platform-Docs +/samples/java/quickstart/chat-with-agent/src/main/java/com/azure/ai/agents/ChatWithAgent.java @microsoft-foundry/AI-Platform-Docs +/samples/java/quickstart/create-agent/src/main/java/com/azure/ai/agents/CreateAgent.java @microsoft-foundry/AI-Platform-Docs +/samples/python/enterprise-agent-tutorial/1-idea-to-prototype/evaluate.py @microsoft-foundry/AI-Platform-Docs +/samples/python/enterprise-agent-tutorial/1-idea-to-prototype/main.py @microsoft-foundry/AI-Platform-Docs +/samples/python/foundry-models/model-router/model-router-chat-completions.py @microsoft-foundry/AI-Platform-Docs +/samples/python/foundry-models/model-router/model-router-foundry-responses.py @microsoft-foundry/AI-Platform-Docs +/samples/python/quickstart/chat-with-agent/quickstart-chat-with-agent.py @microsoft-foundry/AI-Platform-Docs +/samples/python/quickstart/create-agent/quickstart-create-agent.py @microsoft-foundry/AI-Platform-Docs +/samples/python/quickstart/responses/quickstart-responses.py @microsoft-foundry/AI-Platform-Docs +/samples/typescript/quickstart/chat-with-agent/src/quickstart-chat-with-agent.ts @microsoft-foundry/AI-Platform-Docs +/samples/typescript/quickstart/create-agent/src/quickstart-create-agent.ts @microsoft-foundry/AI-Platform-Docs +/samples/typescript/quickstart/responses/src/quickstart-responses.ts @microsoft-foundry/AI-Platform-Docs + diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..4e306b3cd --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,12 @@ +## Files owned by the AI Platform Docs team + +If a file is listed in the CODEOWNERS file with @azure-ai-foundry/ai-platform-docs as the owner, it is owned by the AI Platform Docs team. For these files: + +- Do not change the filename or move the file. +- Do not remove any comments which contain or (for any text in between the tags) +- Do not remove any cell in a notebook if it contains metadata with "name:" in it. + +In a code review, if any of the above rules are broken, please add the following text to your review: +πŸ›‘STOP! This PR contains changes that may break documentation. Please post a message on [ai-platform-docs](https://teams.microsoft.com/l/team/19%3AHhf4F_YfPn3kYGdmWvePNwlbF5-RR8wciQEUwwrcggw1%40thread.tacv2/conversations?groupId=fdaf4412-8993-4ea6-a7d4-aeaded7fc854&tenantId=72f988bf-86f1-41af-91ab-2d7cd011db47) to request help. + +Only files owned by the AI Platform Docs team are subject to these rules. diff --git a/.github/scripts/commit-filesize-diff-summary.py b/.github/scripts/commit-filesize-diff-summary.py new file mode 100644 index 000000000..075bc93fa --- /dev/null +++ b/.github/scripts/commit-filesize-diff-summary.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +import argparse +import subprocess +import sys +from dataclasses import dataclass +from itertools import chain +from pathlib import Path +from typing import Dict, Iterable, List, Literal, Optional, Tuple + + +@dataclass +class GitDiffTreeRecord: + """Represents a line of output from 'git diff-tree'""" + + src_mode: str + src_hash: str + dst_mode: str + dst_hash: str + src_path: Path + dst_path: Optional[Path] + status: Literal["A", "C", "D", "M", "R", "T", "U", "X"] + score: Optional[int] + + +@dataclass +class GitChange: + diff_record: GitDiffTreeRecord + bytes_changed: int + + +def parse_git_diff_tree_output(output: str) -> List[GitDiffTreeRecord]: + """Parses the output of `git diff-tree` as described in the "Raw Output" section + of the man page + """ + + def make_record(line: str) -> GitDiffTreeRecord: + src_mode, dst_mode, src_hash, dst_hash, rest = line[1:].split(" ", maxsplit=4) + status_score_and_paths = rest.split("\t") + return GitDiffTreeRecord( + src_mode=src_mode, + src_hash=src_hash, + dst_mode=dst_mode, + dst_hash=dst_hash, + status=status_score_and_paths[0][0], + score=int(status_score_and_paths[0][1:]) if len(status_score_and_paths[0]) > 1 else None, + src_path=Path(status_score_and_paths[1]), + dst_path=Path(status_score_and_paths[2]) if len(status_score_and_paths) >= 3 else None, + ) + + return [make_record(line) for line in output.splitlines(keepends=False)] + + +def get_blob_sizes(hashes: Iterable[str]) -> Dict[str, Optional[int]]: + """Fetches the sizes, in bytes, of git blobs + + :param hashes: A iterable of git blob hashes + :type hashes: Iterable[str] + + :return: A dictionary that mapping blob hashes to their size if the blob exists, + or None otherwise + :rtype: Dict[str, Optional[int]] + """ + input = "\n".join(set(hashes)) + cat_file_output = subprocess.run( + ["git", "cat-file", "--batch-check"], + input=input, + check=True, + text=True, + capture_output=True, + ).stdout + + def make_object_size_tuple(line: str) -> Tuple[str, Optional[int]]: + hash, *_, size = line.split() + return (hash, int(size) if size != "missing" else None) + + return dict(make_object_size_tuple(line) for line in cat_file_output.splitlines(keepends=False)) + + +def get_file_size_differences(commit_range: str) -> Dict[Path, GitChange]: + """Computes the size difference, in bytes, of files changed between two commits + + :param commit_range: A git commit range (e.g. HEAD~3..HEAD) + :type commit_range: str + + :return: A dictionary mapping paths (relative to repository root) to size + differences. + :rtype: dict[Path, GitChange] + """ + changed_records = parse_git_diff_tree_output( + subprocess.run( + ["git", "diff-tree", "-r", commit_range], + capture_output=True, + text=True, + check=True, + ).stdout + ) + + sizes = get_blob_sizes(chain.from_iterable((idx.src_hash, idx.dst_hash) for idx in changed_records)) + + assert {"A", "D", "M"}.issuperset(idx.status for idx in changed_records) + + def as_int(maybe_num: Optional[int]) -> int: + return maybe_num or 0 + + return { + x.src_path: GitChange( + diff_record=x, + bytes_changed=as_int(sizes[x.dst_hash]) - as_int(sizes[x.src_hash]), + ) + for x in changed_records + } + + +def main( + commit_range: str, + quiet: bool = False, + limit: Optional[int] = None, + show_n_largest_files: int = 30, +) -> Literal[0, 1]: + size_differences = get_file_size_differences(commit_range) + cumulative_size_difference = sum(x.bytes_changed for x in size_differences.values()) + exceeds_limit = limit is not None and cumulative_size_difference > limit + + def bytes_diff(num: int) -> str: + return ("+" if num >= 0 else "") + human_friendly_bytes(num) + + if not quiet: + print(f"Total file size difference for commit range '{commit_range}': ") + print(f"\t{bytes_diff(cumulative_size_difference)}", end="") + print(f" (Exceeds set limit of {bytes_diff(limit)})" if exceeds_limit else "") + + largest_n_sizes = sorted(size_differences.items(), key=lambda x: x[1].bytes_changed, reverse=True)[ + :show_n_largest_files + ] + + if largest_n_sizes: + print("") + print(f"Largest {len(largest_n_sizes)} filesize differences:") + + for path, val in largest_n_sizes: + print(f"\t{bytes_diff(val.bytes_changed)}\t{path}") + + return 1 if exceeds_limit else 0 + + +def num_bytes(arg: str) -> int: + """Converts a string to a number of bytes""" + error = argparse.ArgumentTypeError(f"'{arg}' cannot be parsed into a number of bytes") + try: + return int(arg) + except ValueError: + pass + + if len(arg) < 3: + raise error + + num, suffix = (arg[:-2], arg[-2:]) + shift_values = { + "KB": 1, + "MB": 2, + "GB": 3, + "TB": 4, + "PB": 5, + "EB": 6, + "ZB": 7, + "YB": 8, + } + + shift = shift_values.get(suffix) + + if shift is None: + raise error + try: + return int(num) << (shift * 10) + except ValueError as e: + raise error from e + + +def human_friendly_bytes(num: int) -> str: + """Prints a number of bytes as a human friendly string""" + for prefix in ["", "K", "M", "G", "T", "P", "E", "Z"]: + if abs(num) < 1024.0: + return f"{num:.1f}{prefix}B" + num /= 1024.0 + return f"{num:.1f}YB" + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="A program that summarizes the file size " + "differences between two git commits." + ) + parser.add_argument( + "commit_range", + default="HEAD^..HEAD", + type=str, + help="Commit range to commit the diff for (e.g. HEAD~3..HEAD)", + ) + parser.add_argument("--quiet", action="store_true", help="Silence all output") + parser.add_argument( + "--limit", + type=num_bytes, + help="Exit non-zero if total changes exceeds this value. " + + "Can be a raw number of bytes (e.g. 65536) or a suffixed value (e.g 2MB)", + ) + parser.add_argument( + "--show-n-largest-files", + type=int, + help="Show this many of the largest files in diff", + default=30, + ) + sys.exit(main(**vars(parser.parse_args()))) diff --git a/.github/workflows/ado-automation.yml b/.github/workflows/ado-automation.yml new file mode 100644 index 000000000..52a5d9931 --- /dev/null +++ b/.github/workflows/ado-automation.yml @@ -0,0 +1,52 @@ +name: Create ADO user story from GitHub issue +run-name: GitHub Issue #${{ github.event.issue.number }} + +on: + issues: + types: [opened] + +jobs: + create-ado-story: + runs-on: ubuntu-latest + + steps: + - name: Build payload and call Azure DevOps + env: + ADO_ORG: ${{ secrets.ADO_ORG }} + ADO_PROJECT: ${{ secrets.ADO_PROJECT }} + ADO_PAT: ${{ secrets.ADO_PAT }} + DRI_EMAIL: ${{ secrets.DRI_EMAIL }} + ADO_AREA_PATH: ${{ secrets.ADO_AREA_PATH }} + ADO_ITERATION_PATH: ${{ secrets.ADO_ITERATION_PATH }} + ADO_TAG: ${{ secrets.ADO_TAG }} + ISSUE_TITLE: ${{ github.event.issue.title }} + ISSUE_BODY: ${{ github.event.issue.body }} + ISSUE_URL: ${{ github.event.issue.html_url }} + run: | + DESCRIPTION="
${ISSUE_BODY//$'\n'/
}

$ISSUE_URL
" + + jq -n \ + --arg title "$ISSUE_TITLE" \ + --arg desc "$DESCRIPTION" \ + --arg area "$ADO_AREA_PATH" \ + --arg iter "$ADO_ITERATION_PATH" \ + --arg assn "$DRI_EMAIL" \ + --arg tags "$ADO_TAG" \ + '[ + { "op":"add", "path":"/fields/System.Title", "value":$title }, + { "op":"add", "path":"/fields/System.Description", "value":$desc }, + { "op":"add", "path":"/fields/System.AreaPath", "value":$area }, + { "op":"add", "path":"/fields/System.IterationPath", "value":$iter }, + { "op":"add", "path":"/fields/System.AssignedTo", "value":$assn }, + { "op":"add", "path":"/fields/System.Tags", "value":$tags } + ]' > /tmp/payload.json + + AUTH=$(printf ":$ADO_PAT" | base64 | tr -d '\n') + WORK_ITEM_URL="https://dev.azure.com/${ADO_ORG}/${ADO_PROJECT}/_apis/wit/workitems/\$User%20Story?api-version=7.1-preview.3" + + curl --fail-with-body -sS \ + -H "Content-Type: application/json-patch+json" \ + -H "Authorization: Basic $AUTH" \ + -X POST \ + --data-binary @/tmp/payload.json \ + "$WORK_ITEM_URL" diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml new file mode 100644 index 000000000..466da19c6 --- /dev/null +++ b/.github/workflows/pre-commit.yml @@ -0,0 +1,23 @@ +name: Pre-Commit + + +on: + # push: + # branches: + # - main + # pull_request: + # branches: + # - main + workflow_dispatch: + +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: "3.9" + - run: pip install -r dev-requirements.txt + - name: Run Pre-Commit + run: pre-commit run --all-files diff --git a/.github/workflows/pull-request-checks.yml b/.github/workflows/pull-request-checks.yml new file mode 100644 index 000000000..79ba913c7 --- /dev/null +++ b/.github/workflows/pull-request-checks.yml @@ -0,0 +1,24 @@ +name: Pull Request Checks + +on: + # push: + # branches: + # - main + # pull_request: + # branches: + # - main + workflow_dispatch: + +jobs: + pull_request_size: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: "3.9" + - name: Check Pull Request Size + run: | + git fetch origin ${{ github.event.pull_request.base.ref }} --quiet # Need to manually fetch base branch in CI + python ./.github/scripts/commit-filesize-diff-summary.py --limit 1MB origin/${{ github.event.pull_request.base.ref }}..HEAD diff --git a/.github/workflows/redirect-pull-requests.yml b/.github/workflows/redirect-pull-requests.yml new file mode 100644 index 000000000..390cf9163 --- /dev/null +++ b/.github/workflows/redirect-pull-requests.yml @@ -0,0 +1,132 @@ +name: Redirect Pull Requests + +on: + pull_request_target: + types: [opened] + +permissions: + pull-requests: write + +jobs: + redirect: + runs-on: ubuntu-latest + steps: + - name: Check org membership and redirect + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + const author = pr.user.login; + + // Allow PRs from trusted automation bots (e.g., repo sync) + const allowedBots = ['foundry-samples-repo-sync[bot]']; + if (allowedBots.includes(author)) { + console.log(`Skipping redirect for allowed bot: ${author}`); + return; + } + + // Check if author is a Microsoft contributor using multiple signals. + // The GITHUB_TOKEN can only see *public* members of the 'microsoft' org + // (since this repo is in the 'microsoft-foundry' org), so we cascade + // through several checks to catch contributors with private membership. + let isInternal = false; + let matchedSignal = null; + + // Signal 1: Check microsoft-foundry org membership (full visibility via GITHUB_TOKEN) + try { + const res = await github.rest.orgs.checkMembershipForUser({ + org: 'microsoft-foundry', + username: author, + }); + if (res.status === 204) { + isInternal = true; + matchedSignal = 'microsoft-foundry org member'; + } + } catch { + // 404 or 302 means not a member + } + + // Signal 2: Check if author is a collaborator on this repo + if (!isInternal) { + try { + const res = await github.rest.repos.checkCollaborator({ + owner: context.repo.owner, + repo: context.repo.repo, + username: author, + }); + if (res.status === 204) { + isInternal = true; + matchedSignal = 'repo collaborator'; + } + } catch { + // 404 means not a collaborator + } + } + + // Signal 3: Check microsoft org membership (catches public members only) + if (!isInternal) { + try { + const res = await github.rest.orgs.checkMembershipForUser({ + org: 'microsoft', + username: author, + }); + if (res.status === 204) { + isInternal = true; + matchedSignal = 'microsoft org member (public)'; + } + } catch { + // 404 or 302 means not a member (or private membership not visible) + } + } + + console.log(`Author: ${author}, isInternal: ${isInternal}, signal: ${matchedSignal || 'none'}`); + + let body; + if (isInternal) { + body = [ + `πŸ‘‹ Thanks for your contribution, @${author}!`, + '', + 'This repository is read-only. As a Microsoft contributor, please submit your PR to the private staging repository instead:', + '', + 'πŸ‘‰ **[foundry-samples-pr](https://github.com/microsoft-foundry/foundry-samples-pr)**', + '', + 'See [CONTRIBUTING.md](https://github.com/microsoft-foundry/foundry-samples/blob/main/CONTRIBUTING.md) for full instructions.', + ].join('\n'); + } else { + body = [ + `πŸ‘‹ Thanks for your interest in contributing, @${author}!`, + '', + 'This repository does not accept pull requests directly. If you\'d like to report a bug, suggest an improvement, or propose a new sample, please **[open an issue](https://github.com/microsoft-foundry/foundry-samples/issues/new)** instead.', + '', + 'See [CONTRIBUTING.md](https://github.com/microsoft-foundry/foundry-samples/blob/main/CONTRIBUTING.md) for more details.', + ].join('\n'); + } + + // Skip if the bot already commented (idempotent on re-runs) + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + }); + const alreadyCommented = comments.data.some(c => + c.user.login === 'github-actions[bot]' && + c.body.includes('This repository') + ); + if (alreadyCommented) { + console.log('Bot already commented on this PR, skipping.'); + return; + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body, + }); + + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + state: 'closed', + }); diff --git a/.github/workflows/run-setup.yml b/.github/workflows/run-setup.yml new file mode 100644 index 000000000..f045900a1 --- /dev/null +++ b/.github/workflows/run-setup.yml @@ -0,0 +1,135 @@ +name: Run Setup + +on: + push: + branches: [main] + paths: + - infrastructure/infrastructure-setup-bicep/** + pull_request: + branches: [main] + paths: + - infrastructure/infrastructure-setup-bicep/** + workflow_dispatch: + +permissions: + contents: write + +jobs: + run-setup: + runs-on: ubuntu-latest + + steps: + - name: Checkout source branch + uses: actions/checkout@v3 + with: + # PR: checks out the PR branch, Push: checks out main, Dispatch: checks out default branch + ref: ${{ github.head_ref || github.ref_name }} + fetch-depth: 0 + + - name: Install Bicep + run: | + INSTALL_PATH="$RUNNER_TEMP/bicep" + BICEP_PATH="$RUNNER_TEMP/bicep/bicep" + mkdir -p "$INSTALL_PATH" + curl -sLo bicep https://github.com/Azure/bicep/releases/latest/download/bicep-linux-x64 + chmod +x ./bicep + sudo mv ./bicep "$INSTALL_PATH" + echo "BICEP_PATH=$BICEP_PATH" >> $GITHUB_ENV + $BICEP_PATH --version + + - name: Determine changed main.bicep files + id: changes + run: | + set -e + cd "$GITHUB_WORKSPACE" + + EVENT="${{ github.event_name }}" + echo "Event: $EVENT" + + if [ "$EVENT" = "pull_request" ]; then + BASE="${{ github.event.pull_request.base.sha }}" + HEAD="${{ github.event.pull_request.head.sha }}" + elif [ "$EVENT" = "push" ]; then + BASE="${{ github.event.before }}" + HEAD="${{ github.sha }}" + else + # workflow_dispatch: use last commit as best-effort + BASE="$(git rev-parse HEAD~1 || echo '')" + HEAD="$(git rev-parse HEAD)" + fi + + echo "Diff range: ${BASE}..${HEAD}" + + # Only rebuild when main.bicep changes + if [ -n "$BASE" ]; then + MODIFIED=$(git diff --name-only "$BASE" "$HEAD" \ + | grep -E "^infrastructure/infrastructure-setup-bicep/.*/main\.bicep$" || true) + else + MODIFIED=$(git show --name-only --pretty="" -1 \ + | grep -E "^infrastructure/infrastructure-setup-bicep/.*/main\.bicep$" || true) + fi + + if [ -z "$MODIFIED" ]; then + echo "No relevant Bicep changes detected." + echo "files=" >> $GITHUB_OUTPUT + exit 0 + fi + + echo "Changed main.bicep files:" + echo "$MODIFIED" + + # Output as newline-delimited list + { + echo "files<> $GITHUB_OUTPUT + + - name: Build changed Bicep files -> azuredeploy.json + if: steps.changes.outputs.files != '' + run: | + set -e + cd "$GITHUB_WORKSPACE" + + while IFS= read -r BICEP_FILE; do + OUTFILE="$(dirname "$BICEP_FILE")/azuredeploy.json" + echo "Building: $BICEP_FILE -> $OUTFILE" + $BICEP_PATH build "$BICEP_FILE" --outfile "$OUTFILE" + done <<< "${{ steps.changes.outputs.files }}" + + - name: Commit + push changes back to branch (PR) or main (push) + if: always() + run: | + set -e + cd "$GITHUB_WORKSPACE" + + git config --global user.email "foundry-samples@noreply.github.com" + git config --global user.name "foundry-samples automation" + + git add -A + + if git diff-index --quiet HEAD --; then + echo "No changes to commit." + exit 0 + fi + + git commit -m "Automatic fixes" + + EVENT="${{ github.event_name }}" + + # If PR is from a fork, pushing will be rejected. Detect and skip. + if [ "$EVENT" = "pull_request" ]; then + if [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]; then + echo "PR is from a fork; cannot push changes back to fork branch. Skipping push." + exit 0 + fi + BRANCH="${{ github.head_ref }}" + echo "Pushing fixes to PR branch: $BRANCH" + git push origin "HEAD:refs/heads/$BRANCH" + exit 0 + fi + + # push / workflow_dispatch + BRANCH="${{ github.ref_name }}" + echo "Pushing fixes to branch: $BRANCH" + git push origin "HEAD:refs/heads/$BRANCH" \ No newline at end of file diff --git a/.gitignore b/.gitignore index ece0ddaab..79cfdf7cd 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,9 @@ bld/ # Visual Studio 2015/2017 cache/options directory .vs/ + +# Language server cache files +*.lscache # Uncomment if you have tasks that create the project's static files in wwwroot #wwwroot/ @@ -464,3 +467,11 @@ samples/microsoft/developer-journey-stage-1-idea-to-prototype.md # oh-my-claudecode local state .omc/ + +# Generated cloud E2E artifacts +logs_latest/ +logs_maf/ +logs_new/ + +# Playwright MCP browser session snapshots +.playwright-mcp/ diff --git a/.infra/pytest_plugins/changed_samples/build/lib/pytest_changed_samples/plugin.py b/.infra/pytest_plugins/changed_samples/build/lib/pytest_changed_samples/plugin.py index 02900aa81..977fc3261 100644 --- a/.infra/pytest_plugins/changed_samples/build/lib/pytest_changed_samples/plugin.py +++ b/.infra/pytest_plugins/changed_samples/build/lib/pytest_changed_samples/plugin.py @@ -42,9 +42,7 @@ def pytest_configure(config: pytest.Config) -> None: # Validate that mutually exclusive options haven't been provided mutually_exclusive_options = (WORKING_TREE_CHANGES_OPTION, PR_CHANGES_OPTION) if sum(bool(config.getoption(opt_var(o))) for o in mutually_exclusive_options) > 1: - raise pytest.UsageError( - f"{' and '.join(mutually_exclusive_options)} are mutually exclusive" - ) + raise pytest.UsageError(f"{' and '.join(mutually_exclusive_options)} are mutually exclusive") @pytest.hookimpl(hookwrapper=True) @@ -70,9 +68,7 @@ def pytest_collection(session: pytest.Session) -> None: del config.stash[DIFF_PATH_TRIE_KEY] -def pytest_ignore_collect( - collection_path: Path, config: pytest.Config -) -> Optional[bool]: +def pytest_ignore_collect(collection_path: Path, config: pytest.Config) -> Optional[bool]: """Ignore paths that were not touched by the current git diff.""" if DIFF_PATH_TRIE_KEY not in config.stash: # Occurs when calling `pytest --fixtures` @@ -99,9 +95,7 @@ def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None: session.exitstatus = pytest.ExitCode.OK -def get_diff_paths_function( - config: pytest.Config, -) -> Optional[Callable[[], Iterable[Path]]]: +def get_diff_paths_function(config: pytest.Config) -> Optional[Callable[[], Iterable[Path]]]: """Get the function that returns paths present in a diff specfied by cmdline arguments :param pytest.Config config: The pytest config diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..e320f1aaa --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,35 @@ +# Contributing to Microsoft Foundry Samples + +This repository contains official Microsoft Foundry documentation samples. The contents are published automatically from a private staging repository and are not edited directly here. + +## Reporting Issues + +If you find a bug, have a question, or want to suggest an improvement to an existing sample, please [open an issue](https://github.com/microsoft-foundry/foundry-samples/issues/new) on this repository. We welcome feedback from everyone! + +## Contributing Changes (Microsoft Contributors) + +Sample contributions are currently limited to Microsoft Foundry teams. + +All changes β€” new samples, updates, and bug fixes β€” are submitted through the private staging repository [`foundry-samples-pr`](https://github.com/microsoft-foundry/foundry-samples-pr). Changes merged there are automatically synced to this public repository on a nightly basis. + +> [!NOTE] +> The link above will return a **404** until you've completed step 1 below. + +### How to get started + +1. **Join the `microsoft-foundry` GitHub organization.** Navigate to the organization page on the Open Source Management Portal and click **Join**: + + + +2. **Access the staging repository.** Once you've joined the org, you'll be able to view [`foundry-samples-pr`](https://github.com/microsoft-foundry/foundry-samples-pr). + +3. **Follow the contributing guide there.** The `foundry-samples-pr` repository has its own [`CONTRIBUTING.md`](https://github.com/microsoft-foundry/foundry-samples-pr/blob/main/CONTRIBUTING.md) with full instructions for setting up write access, creating a branch, and submitting a pull request. + +## Contributor License Agreement + +This project requires a Contributor License Agreement (CLA). When you submit a pull request, a CLA bot will check whether you need to sign one and guide you through the process. You only need to do this once across all Microsoft repos. For details, visit . + +## Code of Conduct + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information, see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com). + diff --git a/README.md b/README.md new file mode 100644 index 000000000..3ca3252ff --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# Microsoft Foundry Documentation Samples + +This repository acts as the top-level directory for official Microsoft Foundry documentation sample code and examples. It includes notebooks and sample code that contain end-to-end examples as well as smaller code snippets for common developer tasks. + +This repository is entirely open source, guidance on how to contribute and links to additional repositories are provided below. + +Use the samples in this repository to try out Microsoft Foundry scenarios on your local machine! + +## Contributing + +Found a bug or have a suggestion? [Open an issue](https://github.com/microsoft-foundry/foundry-samples/issues/new) β€” we welcome feedback from everyone! + +Sample contributions are submitted through a private staging repository. If you're a Microsoft employee or contractor, see the [contributing guidelines](CONTRIBUTING.md) for how to get started. + diff --git a/infrastructure/infrastructure-setup-bicep/11-private-network-basic-vnet/README.md b/infrastructure/infrastructure-setup-bicep/11-private-network-basic-vnet/README.md new file mode 100644 index 000000000..9edae6c1a --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/11-private-network-basic-vnet/README.md @@ -0,0 +1,424 @@ +--- +description: This template demonstrates how to set up Azure AI Foundry with a basic agent configuration using VNet injection for network isolation. +page_type: sample +products: +- azure +- azure-resource-manager +urlFragment: network-secured-basic-agent-vnet +languages: +- bicep +- json +--- + +# Microsoft Foundry: Basic Agent Setup with E2E Network Isolation (without Tools behind VNET) + +> **NEW** +> For support on deploying the right network isolation template, check out the [GitHub Copilot for Azure skill for private networking](https://github.com/microsoft/GitHub-Copilot-for-Azure/blob/main/plugin/skills/microsoft-foundry/resource/private-network/private-network.md) set-up! + +--- + +## Overview + +This infrastructure-as-code (IaC) solution deploys a **basic agent** environment with **VNet injection** for network isolation. Unlike the [standard agent setup (template 15)](../15-private-network-standard-agent-setup), this template does **not** create or connect BYO (Bring Your Own) resources such as Azure AI Search, Azure Storage Account, or Azure Cosmos DB. Instead, it relies on platform-managed resources for agent storage needs. + +This template combines: +- **Basic agent configuration** β€” No BYO resources, no connections to external search/storage/cosmos services +- **VNet injection** β€” Custom virtual network with subnet delegation for agent workloads (`Microsoft.App/environments`) +- **Private endpoints** β€” Network-secured access to the AI Services account only +- **Capability host** β€” Basic agent capability host without BYO connections + +### What Gets Deployed + +| Resource | Purpose | +|----------|---------| +| **Virtual Network** | Network isolation with two subnets | +| **Agent Subnet** | Delegated to `Microsoft.App/environments` for VNet-injected agent workloads | +| **Private Endpoint Subnet** | Hosts private endpoint for the AI Services account | +| **AI Services Account** | AI Foundry account with network injection, public access disabled | +| **Private Endpoint** | Secure private connectivity to AI Services | +| **Private DNS Zones** (x3) | DNS resolution for `privatelink.services.ai.azure.com`, `privatelink.openai.azure.com`, `privatelink.cognitiveservices.azure.com` | +| **AI Foundry Project** | Project with system-assigned managed identity | +| **Capability Host** | Basic agent capability host (platform-managed storage) | +| **Model Deployment** | gpt-4.1 (configurable) | + + +[![Deploy To Azure](https://raw.githubusercontent.com/Azure/azure-quickstart-templates/master/1-CONTRIBUTION-GUIDE/images/deploytoazure.svg?sanitize=true)](https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fraw.githubusercontent.com%2Fazure-ai-foundry%2Ffoundry-samples%2Frefs%2Fheads%2Fmain%2Finfrastructure%2Finfrastructure-setup-bicep%2F11-private-network-basic-vnet%2Fmain.json) + +--- + +## When to Use This Template + +Use this template when you need: +- **VNet injection for network isolation** β€” Agent workloads run inside your own virtual network with subnet delegation +- **Basic agent setup without BYO resources** β€” Platform-managed storage for agent data (no customer-managed Storage, Cosmos DB, or AI Search) +- **Private endpoint access to AI Services** β€” Secure, internal-only connectivity to the Foundry account +- **System Assigned Managed Identity** β€” Simplified identity management with platform-managed credentials + +### Template Decision Guide + +Use the table below to choose the right infrastructure template for your scenario: + +| Template | Agent Type | Networking | Identity | Key Use Case | +|----------|-----------|------------|----------|-------------| +| [**11** (this template)](../11-private-network-basic-vnet/) | **Basic** (platform-managed) | BYO VNet injection | System Assigned MI | Basic agents with VNet isolation β€” no BYO resources needed | +| [**15**](../15-private-network-standard-agent-setup/) | Standard (BYO resources) | BYO VNet + Private Endpoints | System Assigned MI | E2E network isolation with full agent capabilities | +| [**19**](../19-private-network-agent-tools/) | Standard (BYO resources) | BYO VNet + Private Endpoints | System Assigned MI | Same as 15 **plus** tools behind VNet (MCP, OpenAPI, Functions, A2A) | +| [**17**](../17-private-network-standard-user-assigned-identity-agent-setup/) | Standard (BYO resources) | BYO VNet + Private Endpoints | **User Assigned MI** | Same as 15 but with user-managed identity | +| [**16**](../16-private-network-standard-agent-apim-setup-preview/) | Standard (BYO resources) | BYO VNet + Private Endpoints | System Assigned MI | Same as 15 **plus** private APIM integration (preview) | +| [**18**](../18-managed-virtual-network-preview/) | Standard (BYO resources) | **Managed VNet** (Microsoft-managed) | System Assigned MI | Network isolation without managing your own VNet (preview) | +| [**15a**](../15a-private-network-evaluation-only-setup/) | Evaluation only | BYO VNet + Private Endpoints | System Assigned MI | Minimal setup for evaluation β€” no Cosmos DB, AI Search, or capability host | +| [**41**](../41-standard-agent-setup/) | Standard (BYO resources) | **Public** (no VNet) | System Assigned MI | Standard agents without network isolation | +| [**40**](../40-basic-agent-setup/) | **Basic** (platform-managed) | **Public** (no VNet) | System Assigned MI | Simplest setup β€” no BYO resources, no private networking | + +--- + +## Key Information + +**Region and Resource Placement Requirements** +- **All Foundry workspace resources should be in the same region as the VNet**, including the Foundry Account and Project. The only exception is within the Foundry Account, you may choose to deploy your model to a different region, and any cross-region communication will be handled securely within our network infrastructure. + - **Note:** Your Virtual Network can be in a different resource group than your Foundry workspace resources + +--- + +## Prerequisites + +1. **Active Azure subscription with appropriate permissions** + - **Foundry Account Owner**: Needed to create the Azure AI Foundry account and project. + - **Owner or Role Based Access Administrator**: Needed to assign RBAC on the Azure resources used by this template. + - **Foundry User**: Needed to create and use agents, projects, or evaluation workloads after deployment. + +2. **Register Resource Providers** + + Make sure you have an active Azure subscription that allows registering resource providers. Subnet delegation requires the Microsoft.App provider to be registered in your subscription. If it's not already registered, run the commands below: + + ```bash + az provider register --namespace 'Microsoft.CognitiveServices' + az provider register --namespace 'Microsoft.Network' + az provider register --namespace 'Microsoft.App' + ``` + +3. Network administrator permissions (if operating in a restricted or enterprise environment) + +4. Sufficient quota for all resources required by this template in the target Azure region, including model deployment quota. + +5. Azure CLI installed and configured on your local workstation or deployment pipeline server + +--- + +## Pre-Deployment Steps + +### Networking Requirements + +1. Review network requirements and plan Virtual Network address space (e.g., `192.168.0.0/16` or an alternative non-overlapping address space) + +2. Two subnets are needed: + - **Agent Subnet** (e.g., `192.168.0.0/24`): Hosts Agent client for Agent workloads, delegated to `Microsoft.App/environments`. The recommended size should be `/24` for this delegated subnet. + - **Private Endpoint Subnet** (e.g., `192.168.1.0/24`): Hosts private endpoints for the AI Services account + - Ensure that the address spaces for the used VNET does not overlap with any existing networks in your Azure environment or reserved IP ranges like the following: `169.254.0.0/16, 172.30.0.0/16, 172.31.0.0/16, 192.0.2.0/24, 0.0.0.0/8, 127.0.0.0/8, 100.100.0.0/17, 100.100.192.0/19, 100.100.224.0/19, 100.64.0.0/11`. This includes all address space(s) you have in your VNET if you have more than one, and peered VNETs. + + > **Notes:** + - If you do not provide an existing virtual network, the template will create a new virtual network with the default address spaces and subnets described above. If you use an existing virtual network, make sure it already contains two subnets (Agent and Private Endpoint) before deploying the template. + - You must ensure the Foundry account was successfully created so that underlying caphost has also succeeded. Then proceed to deploying the project caphost bicep. + - You must ensure the subnet is exclusively delegated to __Microsoft.App/environments__ and cannot be used by any other Azure resources. + +### Limitations / Known Issues + +1. The delegated agent subnet must be exclusively used by a single Foundry account. It cannot be shared across accounts. +2. The Foundry resource and the virtual network must be in the same Azure region. +3. Private Class A IP address ranges (10.x.x.x) are only supported in the following regions: **Australia East, Brazil South, Canada East, East US, East US 2, France Central, Germany West Central, Italy North, Japan East, South Africa North, South Central US, South India, Spain Central, Sweden Central, UAE North, UK South, West US, West US 3.**. Use Class B (172.16.x.x) or C (192.168.x.x) ranges for other regions. +4. This template uses platform-managed resources for agent storage. If you need customer-managed Storage, Cosmos DB, or AI Search, use [template 15](../15-private-network-standard-agent-setup/) instead. +5. There is no upgrade path from BYO VNet (this template) to Managed Virtual Network (template 18). A Foundry resource redeployment is required. +6. All projects within the same Foundry account share model deployments. Per-project model isolation is not supported. + +### Account Deletion Prerequisites and Cleanup Guidance + +Before deleting an **Account** resource, it is essential to first delete the associated **Account Capability Host**. Failure to do so may result in residual dependenciesβ€”such as subnets and other provisioned resources (e.g., ACA applications)β€”remaining linked to the capability host. This can lead to errors such as **"Subnet already in use"** when attempting to reuse the same subnet in a different account deployment. + +**Cleanup Options** + +**1. Full Account Removal**: To completely remove an account, you must delete and purge the account. Simply deleting the account is not sufficient, you must purge so that deletion of the associated capability host is triggered. The service will automatically handle the removal of the capability host and any linked resources in the background. To purge the account, use the following [link](https://learn.microsoft.com/en-us/azure/ai-services/recover-purge-resources?tabs=azure-portal#purge-a-deleted-resource). Please allow approximately max of 20 minutes for all resources to be fully unlinked from the account. + +**2. Retain Account, Remove Capability Host**: If you intend to retain the account but remove the capability host, execute the script `deleteCaphost.sh` located in the [template 15 folder](../15-private-network-standard-agent-setup/). After deletion, allow approximately max of 20 minutes for all resources to be fully unlinked from the account. To recreate the capability host for the account, use the script `createCaphost.sh` located in the same folder. + +> **Important**: Before deleting the account capability host, ensure that the **project capability host** is deleted. + +### Template Customization + +#### Parameters + +| Parameter | Description | Default | Required | +|-----------|-------------|---------|----------| +| `location` | Azure region for deployment | `eastus` | Yes | +| `aiServices` | Base name for the AI Services resource | `aiservices` | No | +| `firstProjectName` | Name for the Foundry project | `project` | No | +| `modelName` | Model to deploy | `gpt-4.1` | No | +| `modelFormat` | Model provider | `OpenAI` | No | +| `modelVersion` | Model version | `2025-04-14` | No | +| `modelSkuName` | Model deployment SKU | `GlobalStandard` | No | +| `modelCapacity` | Tokens per minute (TPM) capacity | `30` | No | +| `vnetName` | Virtual Network name | `agent-vnet-test` | No | +| `agentSubnetName` | Subnet name for agent workloads | `agent-subnet` | No | +| `agentSubnetPrefix` | Address prefix for agent subnet | `192.168.0.0/24` | No | +| `peSubnetName` | Subnet name for private endpoints | `pe-subnet` | No | +| `peSubnetPrefix` | Address prefix for PE subnet | `192.168.1.0/24` | No | +| `existingVnetResourceId` | Full ARM Resource ID of an existing VNet | `''` (creates new) | No | +| `vnetAddressPrefix` | Address space for new VNet | `192.168.0.0/16` | No | +| `dnsZonesSubscriptionId` | Subscription ID for existing DNS zones | `''` (current sub) | No | +| `existingDnsZones` | Map of DNS zone names to resource groups | All empty (creates new) | No | +| `projectCapHost` | Name of the project capability host | `caphostproj` | No | + +#### BYO Virtual Network Details + +**Use an Existing Virtual Network and Subnets** + +To use an existing VNet and subnets, set the `existingVnetResourceId` parameter to the full Azure Resource ID of the target VNet, and provide the names of the two required subnets. If the existing VNet is associated with private DNS zones, set the `existingDnsZones` parameter to the resource group name in which the zones are located. For example: +- `param existingVnetResourceId = "/subscriptions//resourceGroups//providers/Microsoft.Network/virtualNetworks/"` +- `param agentSubnetName string = 'agent-subnet'` (optional, default is `agent-subnet`) +- `param agentSubnetPrefix string = '192.168.0.0/24'` (optional, default is `192.168.0.0/24`) +- `param peSubnetName string = 'pe-subnet'` (optional, default is `pe-subnet`) +- `param peSubnetPrefix string = '192.168.1.0/24'` (optional, default is `192.168.1.0/24`) +- `param dnsZonesSubscriptionId string = ''` (optional, leave empty to use current subscription, or set to a subscription ID if DNS zones are in a different subscription) +- `param existingDnsZones = {` + `'privatelink.services.ai.azure.com': 'privzoneRG'` (add resource group name where your private DNS zone is located) + `'privatelink.openai.azure.com': ''` (leave empty to create new private DNS zone) + `'privatelink.cognitiveservices.azure.com': ''` `}` + +> **Tip**: If subnet information is provided, make sure the subnets exist within the specified VNet to avoid deployment errors. If subnet information is not provided, the template will create subnets with the default address space. + +> **Cross-Subscription DNS Zones**: All DNS zones specified in `existingDnsZones` will be referenced from the subscription specified in `dnsZonesSubscriptionId`. Leave this parameter empty (default) to use the current deployment subscription, or set it to a subscription ID if your DNS zones are located in a different subscription. + +> **Important**: When `dnsZonesSubscriptionId` is set to a different subscription, ALL DNS zones in `existingDnsZones` must have resource groups specified (non-empty values). The template does not support creating new DNS zones in a different subscription. Empty resource groups are only allowed when creating zones in the current deployment subscription. + +--- + +## Deployment Steps + +Choose your deployment method: Use the "Deploy to Azure" button from the top of this README for a guided experience in Azure Portal. + +### Option 1: Automatic deployment +Click the Deploy to Azure button above to open the Azure portal and deploy the template directly. +- Fill in the parameters as needed, including the existing VNet and subnets if applicable. + +### Option 2: Deploy via Azure CLI + +- **Create a New (or Use Existing) Resource Group** + + ```bash + az group create --name --location + ``` + +- Deploy the main.bicep file + - Edit the main.bicepparam file to use an existing Virtual Network & subnets if needed. + + ```bash + az deployment group create \ + --resource-group \ + --template-file main.bicep \ + --parameters main.bicepparam + ``` + + Or deploy with inline parameters: + + ```bash + az deployment group create \ + --resource-group \ + --template-file main.bicep \ + --parameters aiServices=myFoundry location=eastus + ``` + +### Option 3: Use an Existing Virtual Network + +If you already have a VNet you want to use, provide its resource ID: + +```bash +az deployment group create \ + --resource-group \ + --template-file main.bicep \ + --parameters main.bicepparam \ + --parameters existingVnetResourceId='/subscriptions//resourceGroups//providers/Microsoft.Network/virtualNetworks/' +``` + +The template will create the required subnets (agent subnet with delegation and PE subnet) in your existing VNet. + +--- + +## Post-Deployment + +**NOTE:** To access your Foundry resource securely, please use either a VM, VPN, or ExpressRoute since public network access is disabled. + +### Cleanup + +To delete all resources created by this template: + +```bash +az group delete --name --yes --no-wait +``` + +> **Important**: If you need to reuse the same subnet, follow the [Account Deletion Prerequisites and Cleanup Guidance](#account-deletion-prerequisites-and-cleanup-guidance) to properly purge the account and wait for the capability host to fully unlink (~20 minutes). + +--- + +## Network Secured Basic Agent Architecture Deep Dive + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Secure Access (VPN Gateway / ExpressRoute / Azure Bastion) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Microsoft Foundry β”‚ + β”‚ (publicNetworkAccess: β”‚ + β”‚ DISABLED) β”‚ + β”‚ β”‚ + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ + β”‚ β”‚ Foundry Project β”‚ β”‚ + β”‚ β”‚ (Agent Workspace) β”‚ β”‚ + β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ Subnet Delegation + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ BYO Virtual Network β”‚ + β”‚ (192.168.0.0/16) β”‚ + β”‚ β”‚ + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ + β”‚ β”‚ Agent Subnet β”‚ β”‚ + β”‚ β”‚ (192.168.0.0/24) β”‚ β”‚ ◄── Delegated to + β”‚ β”‚ Microsoft.App/envs β”‚ β”‚ Microsoft.App/environments + β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β”‚ β”‚ + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ + β”‚ β”‚ PE Subnet β”‚ β”‚ + β”‚ β”‚ (192.168.1.0/24) β”‚ β”‚ + β”‚ β”‚ β”‚ β”‚ + β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ + β”‚ β”‚ β”‚Foundry β”‚ β”‚ β”‚ ◄── Private endpoint + β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ (no public access) + β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Core Components + +**Microsoft Foundry** resource +- Central orchestration point +- Manages service connections +- Set networking and policy configurations + +**Foundry** project +- Defines the workspace configuration +- Service integration +- Agents are created within a specific project, and each project acts as an isolated workspace. This means: + - All agents in the same project share access to platform-managed storage resources. + - Data is isolated between projects. Agents in one project cannot access resources from another. Projects are currently the unit of sharing and isolation in Foundry. See the what is AI foundry article for more information on Foundry projects. + +**Platform-Managed Resources**: In the basic agent setup, all agent state (thread storage, file storage, vector store) is managed by the platform. No customer-managed BYO resources are required. + +### Azure Resources Created + +**Microsoft Foundry (Cognitive Services)** +- Type: `Microsoft.CognitiveServices/accounts` +- API version: `2025-04-01-preview` +- Kind: AIServices +- SKU: S0 +- Identity: System-assigned +- Features: + - Custom subdomain name + - Disabled public network access + - Network ACLs with Azure Services bypass + - Network injection with agent subnet delegation + +**AI Model Deployment** +- Type: `Microsoft.CognitiveServices/accounts/deployments` +- API version: `2025-04-01-preview` +- SKU: Based on `modelSkuName` parameter, capacity set by `modelCapacity` +- Model properties: + - Name: From `modelName` parameter + - Format: From `modelFormat` parameter + - Version: From `modelVersion` parameter + +### Network Security Design + +This implementation utilizes a BYO VNet (Bring Your Own Virtual Network) approach with subnet delegation. Within your virtual network, two subnets are created: one delegated for agent workloads and one for private endpoints. + +**Network Security** +- Public network access disabled +- Private endpoint for AI Services +- Network ACLs with deny by default + +**Network Infrastructure** +- A Virtual Network (192.168.0.0/16) is created (if existing isn't passed in) +- Agent Subnet (192.168.0.0/24): Hosts Agent client, delegated to `Microsoft.App/environments` +- Private Endpoint Subnet (192.168.1.0/24): Hosts private endpoint for AI Services + +**Private Endpoints** +A private endpoint ensures secure, internal-only connectivity to the AI Services account. + +**Private DNS Zones** +| Private Link Resource Type | Sub Resource | Private DNS Zone Name | Public DNS Zone Forwarders | +|----------------------------|--------------|------------------------|-----------------------------| +| **Microsoft Foundry** | account | `privatelink.cognitiveservices.azure.com`
`privatelink.openai.azure.com`
`privatelink.services.ai.azure.com` | `cognitiveservices.azure.com`
`openai.azure.com`
`services.ai.azure.com` | + +### Authentication & Authorization + +- **Managed Identity** + - Zero-trust security model + - No credential storage + - Platform-managed rotation + + This template uses System Managed Identity. + +> **Note**: Unlike the standard agent setup (template 15), this basic template does not create RBAC role assignments for BYO resources (Storage, Cosmos DB, AI Search), since those resources are not used. + +--- + +## Module Structure + +```text +modules-network-secured/ +β”œβ”€β”€ ai-account-identity.bicep # AI Services account with network injection +β”œβ”€β”€ add-project-capability-host.bicep # Basic capability host (no BYO connections) +β”œβ”€β”€ network-agent-vnet.bicep # VNet router (new or existing) +β”œβ”€β”€ vnet.bicep # New VNet creation +β”œβ”€β”€ existing-vnet.bicep # Existing VNet integration +β”œβ”€β”€ subnet.bicep # Subnet creation helper +└── private-endpoint-and-dns.bicep # PE and DNS for AI Services only +``` + +### Compared to Other Templates + +| Feature | 10 (Private Network Basic) | **11 (This Template)** | 15 (Standard Agent Setup) | +|---------|---------------------------|------------------------|---------------------------| +| Public Network Access | Disabled | Disabled | Disabled | +| VNet Injection | No | **Yes** | Yes | +| Agent Subnet Delegation | No | **Yes** (`Microsoft.App/environments`) | Yes | +| Private Endpoints | AI Services only | **AI Services only** | AI Services + Search + Storage + Cosmos DB | +| BYO Resources | No | **No** | Yes (Search, Storage, Cosmos DB) | +| Capability Host | No | **Yes (basic)** | Yes (with BYO connections) | +| Existing VNet Support | No | **Yes** | Yes | +| RBAC Assignments | No | **No** | Yes (Storage, Cosmos, Search) | + +--- + +## Maintenance + +### Regular Tasks + +1. Monitor network security +2. Check service health +3. Review DNS resolution +4. Update configurations as needed + +### Troubleshooting + +1. Verify private endpoint connectivity +2. Check DNS resolution for AI Services +3. Validate subnet delegation is correct +4. Review network security groups + +--- + +## References + +- [Microsoft Foundry Networking Documentation](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/configure-private-link?tabs=azure-portal&pivots=fdp-project) +- [Private Endpoint Documentation](https://learn.microsoft.com/en-us/azure/private-link/) +- [Network Security Best Practices](https://learn.microsoft.com/en-us/azure/security/fundamentals/network-best-practices) diff --git a/infrastructure/infrastructure-setup-bicep/11-private-network-basic-vnet/azuredeploy.json b/infrastructure/infrastructure-setup-bicep/11-private-network-basic-vnet/azuredeploy.json new file mode 100644 index 000000000..b744d76c4 --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/11-private-network-basic-vnet/azuredeploy.json @@ -0,0 +1,1329 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.43.1.21952", + "templateHash": "7766740854543235375" + } + }, + "parameters": { + "location": { + "type": "string", + "defaultValue": "eastus", + "allowedValues": [ + "westus", + "eastus", + "eastus2", + "japaneast", + "francecentral", + "spaincentral", + "uaenorth", + "southcentralus", + "italynorth", + "germanywestcentral", + "brazilsouth", + "southafricanorth", + "australiaeast", + "swedencentral", + "canadaeast", + "westeurope", + "westus3", + "uksouth", + "southindia", + "koreacentral", + "polandcentral", + "switzerlandnorth", + "norwayeast" + ], + "metadata": { + "description": "Location for all resources." + } + }, + "aiServices": { + "type": "string", + "defaultValue": "aiservices", + "metadata": { + "description": "Name for your AI Services resource." + } + }, + "modelName": { + "type": "string", + "defaultValue": "gpt-4.1", + "metadata": { + "description": "The name of the model you want to deploy" + } + }, + "modelFormat": { + "type": "string", + "defaultValue": "OpenAI", + "metadata": { + "description": "The provider of your model" + } + }, + "modelVersion": { + "type": "string", + "defaultValue": "2025-04-14", + "metadata": { + "description": "The version of your model" + } + }, + "modelSkuName": { + "type": "string", + "defaultValue": "GlobalStandard", + "metadata": { + "description": "The sku of your model deployment" + } + }, + "modelCapacity": { + "type": "int", + "defaultValue": 30, + "metadata": { + "description": "The tokens per minute (TPM) of your model deployment" + } + }, + "deploymentTimestamp": { + "type": "string", + "defaultValue": "[utcNow('yyyyMMddHHmmss')]" + }, + "firstProjectName": { + "type": "string", + "defaultValue": "project", + "metadata": { + "description": "Name for your project resource." + } + }, + "projectDescription": { + "type": "string", + "defaultValue": "A project for the AI Foundry account with network secured basic Agent", + "metadata": { + "description": "This project will be a sub-resource of your account" + } + }, + "displayName": { + "type": "string", + "defaultValue": "network secured basic agent project", + "metadata": { + "description": "The display name of the project" + } + }, + "vnetName": { + "type": "string", + "defaultValue": "agent-vnet-test", + "metadata": { + "description": "Virtual Network name for the Agent to create new or existing virtual network" + } + }, + "agentSubnetName": { + "type": "string", + "defaultValue": "agent-subnet", + "metadata": { + "description": "The name of Agents Subnet to create new or existing subnet for agents" + } + }, + "peSubnetName": { + "type": "string", + "defaultValue": "pe-subnet", + "metadata": { + "description": "The name of Private Endpoint subnet to create new or existing subnet for private endpoints" + } + }, + "existingVnetResourceId": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Existing Virtual Network name Resource ID" + } + }, + "vnetAddressPrefix": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Address space for the VNet (only used for new VNet)" + } + }, + "agentSubnetPrefix": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Address prefix for the agent subnet. The default value is 192.168.0.0/24 but you can choose any size /26 or any class like 10.0.0.0 or 172.168.0.0" + } + }, + "peSubnetPrefix": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Address prefix for the private endpoint subnet" + } + }, + "dnsZonesSubscriptionId": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Subscription ID where existing private DNS zones are located. Leave empty to use current subscription." + } + }, + "existingDnsZones": { + "type": "object", + "defaultValue": { + "privatelink.services.ai.azure.com": "", + "privatelink.openai.azure.com": "", + "privatelink.cognitiveservices.azure.com": "" + }, + "metadata": { + "description": "Object mapping DNS zone names to their resource group, or empty string to indicate creation" + } + }, + "projectCapHost": { + "type": "string", + "defaultValue": "caphostproj", + "metadata": { + "description": "The name of the project capability host to be created" + } + } + }, + "variables": { + "uniqueSuffix": "[substring(uniqueString(format('{0}-{1}', resourceGroup().id, parameters('deploymentTimestamp'))), 0, 4)]", + "accountName": "[toLower(format('{0}{1}', parameters('aiServices'), variables('uniqueSuffix')))]", + "projectName": "[toLower(format('{0}{1}', parameters('firstProjectName'), variables('uniqueSuffix')))]", + "existingVnetPassedIn": "[not(equals(parameters('existingVnetResourceId'), ''))]", + "vnetParts": "[split(parameters('existingVnetResourceId'), '/')]", + "vnetSubscriptionId": "[if(variables('existingVnetPassedIn'), variables('vnetParts')[2], subscription().subscriptionId)]", + "vnetResourceGroupName": "[if(variables('existingVnetPassedIn'), variables('vnetParts')[4], resourceGroup().name)]", + "existingVnetName": "[if(variables('existingVnetPassedIn'), last(variables('vnetParts')), parameters('vnetName'))]", + "trimVnetName": "[trim(variables('existingVnetName'))]", + "resolvedDnsZonesSubscriptionId": "[if(empty(parameters('dnsZonesSubscriptionId')), subscription().subscriptionId, parameters('dnsZonesSubscriptionId'))]" + }, + "resources": [ + { + "type": "Microsoft.CognitiveServices/accounts/projects", + "apiVersion": "2025-04-01-preview", + "name": "[format('{0}/{1}', variables('accountName'), variables('projectName'))]", + "location": "[parameters('location')]", + "identity": { + "type": "SystemAssigned" + }, + "properties": { + "description": "[parameters('projectDescription')]", + "displayName": "[parameters('displayName')]" + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('accountName'), variables('uniqueSuffix')))]", + "[resourceId('Microsoft.Resources/deployments', format('{0}-private-endpoint', variables('uniqueSuffix')))]" + ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "[format('vnet-{0}-{1}-deployment', variables('trimVnetName'), variables('uniqueSuffix'))]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "vnetName": { + "value": "[variables('trimVnetName')]" + }, + "useExistingVnet": { + "value": "[variables('existingVnetPassedIn')]" + }, + "existingVnetResourceGroupName": { + "value": "[variables('vnetResourceGroupName')]" + }, + "agentSubnetName": { + "value": "[parameters('agentSubnetName')]" + }, + "peSubnetName": { + "value": "[parameters('peSubnetName')]" + }, + "vnetAddressPrefix": { + "value": "[parameters('vnetAddressPrefix')]" + }, + "agentSubnetPrefix": { + "value": "[parameters('agentSubnetPrefix')]" + }, + "peSubnetPrefix": { + "value": "[parameters('peSubnetPrefix')]" + }, + "existingVnetSubscriptionId": { + "value": "[variables('vnetSubscriptionId')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.43.1.21952", + "templateHash": "17355345610868234879" + } + }, + "parameters": { + "location": { + "type": "string", + "metadata": { + "description": "Azure region for the deployment" + } + }, + "vnetName": { + "type": "string", + "metadata": { + "description": "The name of the virtual network" + } + }, + "useExistingVnet": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Indicates if an existing VNet should be used" + } + }, + "existingVnetSubscriptionId": { + "type": "string", + "defaultValue": "[subscription().subscriptionId]", + "metadata": { + "description": "Subscription ID of the existing VNet (if different from current subscription)" + } + }, + "existingVnetResourceGroupName": { + "type": "string", + "defaultValue": "[resourceGroup().name]", + "metadata": { + "description": "Resource Group name of the existing VNet (if different from current resource group)" + } + }, + "agentSubnetName": { + "type": "string", + "defaultValue": "agent-subnet", + "metadata": { + "description": "The name of Agents Subnet" + } + }, + "peSubnetName": { + "type": "string", + "defaultValue": "pe-subnet", + "metadata": { + "description": "The name of Private Endpoint subnet" + } + }, + "vnetAddressPrefix": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Address space for the VNet (only used for new VNet)" + } + }, + "agentSubnetPrefix": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Address prefix for the agent subnet" + } + }, + "peSubnetPrefix": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Address prefix for the private endpoint subnet" + } + } + }, + "resources": [ + { + "condition": "[not(parameters('useExistingVnet'))]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "vnet-deployment", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "vnetName": { + "value": "[parameters('vnetName')]" + }, + "agentSubnetName": { + "value": "[parameters('agentSubnetName')]" + }, + "peSubnetName": { + "value": "[parameters('peSubnetName')]" + }, + "vnetAddressPrefix": { + "value": "[parameters('vnetAddressPrefix')]" + }, + "agentSubnetPrefix": { + "value": "[parameters('agentSubnetPrefix')]" + }, + "peSubnetPrefix": { + "value": "[parameters('peSubnetPrefix')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.43.1.21952", + "templateHash": "10901913041029806810" + } + }, + "parameters": { + "location": { + "type": "string", + "metadata": { + "description": "Azure region for the deployment" + } + }, + "vnetName": { + "type": "string", + "defaultValue": "agents-vnet-test", + "metadata": { + "description": "The name of the virtual network" + } + }, + "agentSubnetName": { + "type": "string", + "defaultValue": "agent-subnet", + "metadata": { + "description": "The name of Agents Subnet" + } + }, + "peSubnetName": { + "type": "string", + "defaultValue": "pe-subnet", + "metadata": { + "description": "The name of Hub subnet" + } + }, + "vnetAddressPrefix": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Address space for the VNet" + } + }, + "agentSubnetPrefix": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Address prefix for the agent subnet" + } + }, + "peSubnetPrefix": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Address prefix for the private endpoint subnet" + } + } + }, + "variables": { + "defaultVnetAddressPrefix": "192.168.0.0/16", + "vnetAddress": "[if(empty(parameters('vnetAddressPrefix')), variables('defaultVnetAddressPrefix'), parameters('vnetAddressPrefix'))]", + "agentSubnet": "[if(empty(parameters('agentSubnetPrefix')), cidrSubnet(variables('vnetAddress'), 24, 0), parameters('agentSubnetPrefix'))]", + "peSubnet": "[if(empty(parameters('peSubnetPrefix')), cidrSubnet(variables('vnetAddress'), 24, 1), parameters('peSubnetPrefix'))]" + }, + "resources": [ + { + "type": "Microsoft.Network/virtualNetworks", + "apiVersion": "2024-05-01", + "name": "[parameters('vnetName')]", + "location": "[parameters('location')]", + "properties": { + "addressSpace": { + "addressPrefixes": [ + "[variables('vnetAddress')]" + ] + }, + "subnets": [ + { + "name": "[parameters('agentSubnetName')]", + "properties": { + "addressPrefix": "[variables('agentSubnet')]", + "delegations": [ + { + "name": "Microsoft.app/environments", + "properties": { + "serviceName": "Microsoft.App/environments" + } + } + ] + } + }, + { + "name": "[parameters('peSubnetName')]", + "properties": { + "addressPrefix": "[variables('peSubnet')]" + } + } + ] + } + } + ], + "outputs": { + "peSubnetName": { + "type": "string", + "value": "[parameters('peSubnetName')]" + }, + "agentSubnetName": { + "type": "string", + "value": "[parameters('agentSubnetName')]" + }, + "agentSubnetId": { + "type": "string", + "value": "[format('{0}/subnets/{1}', resourceId('Microsoft.Network/virtualNetworks', parameters('vnetName')), parameters('agentSubnetName'))]" + }, + "peSubnetId": { + "type": "string", + "value": "[format('{0}/subnets/{1}', resourceId('Microsoft.Network/virtualNetworks', parameters('vnetName')), parameters('peSubnetName'))]" + }, + "virtualNetworkName": { + "type": "string", + "value": "[parameters('vnetName')]" + }, + "virtualNetworkId": { + "type": "string", + "value": "[resourceId('Microsoft.Network/virtualNetworks', parameters('vnetName'))]" + }, + "virtualNetworkResourceGroup": { + "type": "string", + "value": "[resourceGroup().name]" + }, + "virtualNetworkSubscriptionId": { + "type": "string", + "value": "[subscription().subscriptionId]" + } + } + } + } + }, + { + "condition": "[parameters('useExistingVnet')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "existing-vnet-deployment", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "vnetName": { + "value": "[parameters('vnetName')]" + }, + "vnetResourceGroupName": { + "value": "[parameters('existingVnetResourceGroupName')]" + }, + "vnetSubscriptionId": { + "value": "[parameters('existingVnetSubscriptionId')]" + }, + "agentSubnetName": { + "value": "[parameters('agentSubnetName')]" + }, + "peSubnetName": { + "value": "[parameters('peSubnetName')]" + }, + "agentSubnetPrefix": { + "value": "[parameters('agentSubnetPrefix')]" + }, + "peSubnetPrefix": { + "value": "[parameters('peSubnetPrefix')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.43.1.21952", + "templateHash": "10509554109014084911" + } + }, + "parameters": { + "vnetName": { + "type": "string", + "metadata": { + "description": "The name of the existing virtual network" + } + }, + "vnetSubscriptionId": { + "type": "string", + "defaultValue": "[subscription().subscriptionId]", + "metadata": { + "description": "Subscription ID of virtual network (if different from current subscription)" + } + }, + "vnetResourceGroupName": { + "type": "string", + "defaultValue": "[resourceGroup().name]", + "metadata": { + "description": "Resource Group name of the existing VNet (if different from current resource group)" + } + }, + "agentSubnetName": { + "type": "string", + "defaultValue": "agent-subnet", + "metadata": { + "description": "The name of Agents Subnet" + } + }, + "peSubnetName": { + "type": "string", + "defaultValue": "pe-subnet", + "metadata": { + "description": "The name of Private Endpoint subnet" + } + }, + "agentSubnetPrefix": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Address prefix for the agent subnet (only needed if creating new subnet)" + } + }, + "peSubnetPrefix": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Address prefix for the private endpoint subnet (only needed if creating new subnet)" + } + } + }, + "resources": [ + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "[format('agent-subnet-{0}', uniqueString(deployment().name, parameters('agentSubnetName')))]", + "resourceGroup": "[parameters('vnetResourceGroupName')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "vnetName": { + "value": "[parameters('vnetName')]" + }, + "subnetName": { + "value": "[parameters('agentSubnetName')]" + }, + "addressPrefix": "[if(empty(parameters('agentSubnetPrefix')), createObject('value', cidrSubnet(reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName')), '2024-05-01').addressSpace.addressPrefixes[0], 24, 0)), createObject('value', parameters('agentSubnetPrefix')))]", + "delegations": { + "value": [ + { + "name": "Microsoft.App/environments", + "properties": { + "serviceName": "Microsoft.App/environments" + } + } + ] + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.43.1.21952", + "templateHash": "13091507001124186389" + } + }, + "parameters": { + "vnetName": { + "type": "string", + "metadata": { + "description": "Name of the virtual network" + } + }, + "subnetName": { + "type": "string", + "metadata": { + "description": "Name of the subnet" + } + }, + "addressPrefix": { + "type": "string", + "metadata": { + "description": "Address prefix for the subnet" + } + }, + "delegations": { + "type": "array", + "defaultValue": [], + "metadata": { + "description": "Array of subnet delegations" + } + } + }, + "resources": [ + { + "type": "Microsoft.Network/virtualNetworks/subnets", + "apiVersion": "2024-05-01", + "name": "[format('{0}/{1}', parameters('vnetName'), parameters('subnetName'))]", + "properties": { + "addressPrefix": "[parameters('addressPrefix')]", + "delegations": "[parameters('delegations')]" + } + } + ], + "outputs": { + "subnetId": { + "type": "string", + "value": "[resourceId('Microsoft.Network/virtualNetworks/subnets', split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[0], split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[1])]" + }, + "subnetName": { + "type": "string", + "value": "[parameters('subnetName')]" + } + } + } + } + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "[format('pe-subnet-{0}', uniqueString(deployment().name, parameters('peSubnetName')))]", + "resourceGroup": "[parameters('vnetResourceGroupName')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "vnetName": { + "value": "[parameters('vnetName')]" + }, + "subnetName": { + "value": "[parameters('peSubnetName')]" + }, + "addressPrefix": "[if(empty(parameters('peSubnetPrefix')), createObject('value', cidrSubnet(reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName')), '2024-05-01').addressSpace.addressPrefixes[0], 24, 1)), createObject('value', parameters('peSubnetPrefix')))]", + "delegations": { + "value": [] + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.43.1.21952", + "templateHash": "13091507001124186389" + } + }, + "parameters": { + "vnetName": { + "type": "string", + "metadata": { + "description": "Name of the virtual network" + } + }, + "subnetName": { + "type": "string", + "metadata": { + "description": "Name of the subnet" + } + }, + "addressPrefix": { + "type": "string", + "metadata": { + "description": "Address prefix for the subnet" + } + }, + "delegations": { + "type": "array", + "defaultValue": [], + "metadata": { + "description": "Array of subnet delegations" + } + } + }, + "resources": [ + { + "type": "Microsoft.Network/virtualNetworks/subnets", + "apiVersion": "2024-05-01", + "name": "[format('{0}/{1}', parameters('vnetName'), parameters('subnetName'))]", + "properties": { + "addressPrefix": "[parameters('addressPrefix')]", + "delegations": "[parameters('delegations')]" + } + } + ], + "outputs": { + "subnetId": { + "type": "string", + "value": "[resourceId('Microsoft.Network/virtualNetworks/subnets', split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[0], split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[1])]" + }, + "subnetName": { + "type": "string", + "value": "[parameters('subnetName')]" + } + } + } + } + } + ], + "outputs": { + "peSubnetName": { + "type": "string", + "value": "[parameters('peSubnetName')]" + }, + "agentSubnetName": { + "type": "string", + "value": "[parameters('agentSubnetName')]" + }, + "agentSubnetId": { + "type": "string", + "value": "[format('{0}/subnets/{1}', extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName')), parameters('agentSubnetName'))]" + }, + "peSubnetId": { + "type": "string", + "value": "[format('{0}/subnets/{1}', extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName')), parameters('peSubnetName'))]" + }, + "virtualNetworkName": { + "type": "string", + "value": "[parameters('vnetName')]" + }, + "virtualNetworkId": { + "type": "string", + "value": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName'))]" + }, + "virtualNetworkResourceGroup": { + "type": "string", + "value": "[parameters('vnetResourceGroupName')]" + }, + "virtualNetworkSubscriptionId": { + "type": "string", + "value": "[parameters('vnetSubscriptionId')]" + } + } + } + } + } + ], + "outputs": { + "virtualNetworkName": { + "type": "string", + "value": "[if(parameters('useExistingVnet'), reference(resourceId('Microsoft.Resources/deployments', 'existing-vnet-deployment'), '2025-04-01').outputs.virtualNetworkName.value, reference(resourceId('Microsoft.Resources/deployments', 'vnet-deployment'), '2025-04-01').outputs.virtualNetworkName.value)]" + }, + "virtualNetworkId": { + "type": "string", + "value": "[if(parameters('useExistingVnet'), reference(resourceId('Microsoft.Resources/deployments', 'existing-vnet-deployment'), '2025-04-01').outputs.virtualNetworkId.value, reference(resourceId('Microsoft.Resources/deployments', 'vnet-deployment'), '2025-04-01').outputs.virtualNetworkId.value)]" + }, + "virtualNetworkSubscriptionId": { + "type": "string", + "value": "[if(parameters('useExistingVnet'), reference(resourceId('Microsoft.Resources/deployments', 'existing-vnet-deployment'), '2025-04-01').outputs.virtualNetworkSubscriptionId.value, reference(resourceId('Microsoft.Resources/deployments', 'vnet-deployment'), '2025-04-01').outputs.virtualNetworkSubscriptionId.value)]" + }, + "virtualNetworkResourceGroup": { + "type": "string", + "value": "[if(parameters('useExistingVnet'), reference(resourceId('Microsoft.Resources/deployments', 'existing-vnet-deployment'), '2025-04-01').outputs.virtualNetworkResourceGroup.value, reference(resourceId('Microsoft.Resources/deployments', 'vnet-deployment'), '2025-04-01').outputs.virtualNetworkResourceGroup.value)]" + }, + "agentSubnetName": { + "type": "string", + "value": "[parameters('agentSubnetName')]" + }, + "peSubnetName": { + "type": "string", + "value": "[parameters('peSubnetName')]" + }, + "agentSubnetId": { + "type": "string", + "value": "[if(parameters('useExistingVnet'), reference(resourceId('Microsoft.Resources/deployments', 'existing-vnet-deployment'), '2025-04-01').outputs.agentSubnetId.value, reference(resourceId('Microsoft.Resources/deployments', 'vnet-deployment'), '2025-04-01').outputs.agentSubnetId.value)]" + }, + "peSubnetId": { + "type": "string", + "value": "[if(parameters('useExistingVnet'), reference(resourceId('Microsoft.Resources/deployments', 'existing-vnet-deployment'), '2025-04-01').outputs.peSubnetId.value, reference(resourceId('Microsoft.Resources/deployments', 'vnet-deployment'), '2025-04-01').outputs.peSubnetId.value)]" + } + } + } + } + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "[format('{0}-{1}-deployment', variables('accountName'), variables('uniqueSuffix'))]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "accountName": { + "value": "[variables('accountName')]" + }, + "location": { + "value": "[parameters('location')]" + }, + "modelName": { + "value": "[parameters('modelName')]" + }, + "modelFormat": { + "value": "[parameters('modelFormat')]" + }, + "modelVersion": { + "value": "[parameters('modelVersion')]" + }, + "modelSkuName": { + "value": "[parameters('modelSkuName')]" + }, + "modelCapacity": { + "value": "[parameters('modelCapacity')]" + }, + "agentSubnetId": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', format('vnet-{0}-{1}-deployment', variables('trimVnetName'), variables('uniqueSuffix'))), '2025-04-01').outputs.agentSubnetId.value]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.43.1.21952", + "templateHash": "14040588815014793423" + } + }, + "parameters": { + "accountName": { + "type": "string" + }, + "location": { + "type": "string" + }, + "modelName": { + "type": "string" + }, + "modelFormat": { + "type": "string" + }, + "modelVersion": { + "type": "string" + }, + "modelSkuName": { + "type": "string" + }, + "modelCapacity": { + "type": "int" + }, + "agentSubnetId": { + "type": "string" + }, + "networkInjection": { + "type": "string", + "defaultValue": "true" + } + }, + "resources": [ + { + "type": "Microsoft.CognitiveServices/accounts", + "apiVersion": "2025-04-01-preview", + "name": "[parameters('accountName')]", + "location": "[parameters('location')]", + "sku": { + "name": "S0" + }, + "kind": "AIServices", + "identity": { + "type": "SystemAssigned" + }, + "properties": { + "allowProjectManagement": true, + "customSubDomainName": "[parameters('accountName')]", + "networkAcls": { + "defaultAction": "Deny", + "virtualNetworkRules": [], + "ipRules": [], + "bypass": "AzureServices" + }, + "publicNetworkAccess": "Disabled", + "networkInjections": "[if(equals(parameters('networkInjection'), 'true'), createArray(createObject('scenario', 'agent', 'subnetArmId', parameters('agentSubnetId'), 'useMicrosoftManagedNetwork', false())), null())]", + "disableLocalAuth": false + } + }, + { + "type": "Microsoft.CognitiveServices/accounts/deployments", + "apiVersion": "2025-04-01-preview", + "name": "[format('{0}/{1}', parameters('accountName'), parameters('modelName'))]", + "sku": { + "capacity": "[parameters('modelCapacity')]", + "name": "[parameters('modelSkuName')]" + }, + "properties": { + "model": { + "name": "[parameters('modelName')]", + "format": "[parameters('modelFormat')]", + "version": "[parameters('modelVersion')]" + } + }, + "dependsOn": [ + "[resourceId('Microsoft.CognitiveServices/accounts', parameters('accountName'))]" + ] + } + ], + "outputs": { + "accountName": { + "type": "string", + "value": "[parameters('accountName')]" + }, + "accountID": { + "type": "string", + "value": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('accountName'))]" + }, + "accountTarget": { + "type": "string", + "value": "[reference(resourceId('Microsoft.CognitiveServices/accounts', parameters('accountName')), '2025-04-01-preview').endpoint]" + }, + "accountPrincipalId": { + "type": "string", + "value": "[reference(resourceId('Microsoft.CognitiveServices/accounts', parameters('accountName')), '2025-04-01-preview', 'full').identity.principalId]" + } + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', format('vnet-{0}-{1}-deployment', variables('trimVnetName'), variables('uniqueSuffix')))]" + ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "[format('{0}-private-endpoint', variables('uniqueSuffix'))]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "aiAccountName": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('accountName'), variables('uniqueSuffix'))), '2025-04-01').outputs.accountName.value]" + }, + "vnetName": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', format('vnet-{0}-{1}-deployment', variables('trimVnetName'), variables('uniqueSuffix'))), '2025-04-01').outputs.virtualNetworkName.value]" + }, + "peSubnetName": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', format('vnet-{0}-{1}-deployment', variables('trimVnetName'), variables('uniqueSuffix'))), '2025-04-01').outputs.peSubnetName.value]" + }, + "suffix": { + "value": "[variables('uniqueSuffix')]" + }, + "vnetResourceGroupName": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', format('vnet-{0}-{1}-deployment', variables('trimVnetName'), variables('uniqueSuffix'))), '2025-04-01').outputs.virtualNetworkResourceGroup.value]" + }, + "vnetSubscriptionId": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', format('vnet-{0}-{1}-deployment', variables('trimVnetName'), variables('uniqueSuffix'))), '2025-04-01').outputs.virtualNetworkSubscriptionId.value]" + }, + "existingDnsZones": { + "value": "[parameters('existingDnsZones')]" + }, + "dnsZonesSubscriptionId": { + "value": "[variables('resolvedDnsZonesSubscriptionId')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.43.1.21952", + "templateHash": "9941350741815038708" + } + }, + "parameters": { + "aiAccountName": { + "type": "string", + "metadata": { + "description": "Name of the AI Foundry account" + } + }, + "vnetName": { + "type": "string", + "metadata": { + "description": "Name of the Vnet" + } + }, + "peSubnetName": { + "type": "string", + "metadata": { + "description": "Name of the private endpoint subnet" + } + }, + "suffix": { + "type": "string", + "metadata": { + "description": "Suffix for unique resource names" + } + }, + "vnetResourceGroupName": { + "type": "string", + "defaultValue": "[resourceGroup().name]", + "metadata": { + "description": "Resource Group name for existing Virtual Network (if different from current resource group)" + } + }, + "vnetSubscriptionId": { + "type": "string", + "defaultValue": "[subscription().subscriptionId]", + "metadata": { + "description": "Subscription ID for Virtual Network" + } + }, + "existingDnsZones": { + "type": "object", + "defaultValue": { + "privatelink.services.ai.azure.com": "", + "privatelink.openai.azure.com": "", + "privatelink.cognitiveservices.azure.com": "" + }, + "metadata": { + "description": "Map of DNS zone FQDNs to resource group names. If provided, reference existing DNS zones in this resource group instead of creating them." + } + }, + "dnsZonesSubscriptionId": { + "type": "string", + "metadata": { + "description": "Subscription ID where existing private DNS zones are located. Should be resolved to current subscription if empty." + } + } + }, + "variables": { + "aiServicesDnsZoneName": "privatelink.services.ai.azure.com", + "openAiDnsZoneName": "privatelink.openai.azure.com", + "cognitiveServicesDnsZoneName": "privatelink.cognitiveservices.azure.com", + "aiServicesDnsZoneRG": "[parameters('existingDnsZones')[variables('aiServicesDnsZoneName')]]", + "openAiDnsZoneRG": "[parameters('existingDnsZones')[variables('openAiDnsZoneName')]]", + "cognitiveServicesDnsZoneRG": "[parameters('existingDnsZones')[variables('cognitiveServicesDnsZoneName')]]", + "aiServicesDnsZoneId": "[if(empty(variables('aiServicesDnsZoneRG')), resourceId('Microsoft.Network/privateDnsZones', variables('aiServicesDnsZoneName')), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('dnsZonesSubscriptionId'), variables('aiServicesDnsZoneRG')), 'Microsoft.Network/privateDnsZones', variables('aiServicesDnsZoneName')))]", + "openAiDnsZoneId": "[if(empty(variables('openAiDnsZoneRG')), resourceId('Microsoft.Network/privateDnsZones', variables('openAiDnsZoneName')), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('dnsZonesSubscriptionId'), variables('openAiDnsZoneRG')), 'Microsoft.Network/privateDnsZones', variables('openAiDnsZoneName')))]", + "cognitiveServicesDnsZoneId": "[if(empty(variables('cognitiveServicesDnsZoneRG')), resourceId('Microsoft.Network/privateDnsZones', variables('cognitiveServicesDnsZoneName')), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('dnsZonesSubscriptionId'), variables('cognitiveServicesDnsZoneRG')), 'Microsoft.Network/privateDnsZones', variables('cognitiveServicesDnsZoneName')))]" + }, + "resources": [ + { + "type": "Microsoft.Network/privateEndpoints", + "apiVersion": "2024-05-01", + "name": "[format('{0}-private-endpoint', parameters('aiAccountName'))]", + "location": "[resourceGroup().location]", + "properties": { + "subnet": { + "id": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('vnetSubscriptionId'), parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks/subnets', parameters('vnetName'), parameters('peSubnetName'))]" + }, + "privateLinkServiceConnections": [ + { + "name": "[format('{0}-private-link-service-connection', parameters('aiAccountName'))]", + "properties": { + "privateLinkServiceId": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('aiAccountName'))]", + "groupIds": [ + "account" + ] + } + } + ] + } + }, + { + "condition": "[empty(variables('aiServicesDnsZoneRG'))]", + "type": "Microsoft.Network/privateDnsZones", + "apiVersion": "2020-06-01", + "name": "[variables('aiServicesDnsZoneName')]", + "location": "global" + }, + { + "condition": "[empty(variables('openAiDnsZoneRG'))]", + "type": "Microsoft.Network/privateDnsZones", + "apiVersion": "2020-06-01", + "name": "[variables('openAiDnsZoneName')]", + "location": "global" + }, + { + "condition": "[empty(variables('cognitiveServicesDnsZoneRG'))]", + "type": "Microsoft.Network/privateDnsZones", + "apiVersion": "2020-06-01", + "name": "[variables('cognitiveServicesDnsZoneName')]", + "location": "global" + }, + { + "condition": "[empty(variables('aiServicesDnsZoneRG'))]", + "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", + "apiVersion": "2024-06-01", + "name": "[format('{0}/{1}', variables('aiServicesDnsZoneName'), format('aiServices-{0}-link', parameters('suffix')))]", + "location": "global", + "properties": { + "virtualNetwork": { + "id": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('vnetSubscriptionId'), parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName'))]" + }, + "registrationEnabled": false + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', variables('aiServicesDnsZoneName'))]" + ] + }, + { + "condition": "[empty(variables('openAiDnsZoneRG'))]", + "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", + "apiVersion": "2024-06-01", + "name": "[format('{0}/{1}', variables('openAiDnsZoneName'), format('aiServicesOpenAI-{0}-link', parameters('suffix')))]", + "location": "global", + "properties": { + "virtualNetwork": { + "id": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('vnetSubscriptionId'), parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName'))]" + }, + "registrationEnabled": false + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', variables('openAiDnsZoneName'))]" + ] + }, + { + "condition": "[empty(variables('cognitiveServicesDnsZoneRG'))]", + "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", + "apiVersion": "2024-06-01", + "name": "[format('{0}/{1}', variables('cognitiveServicesDnsZoneName'), format('aiServicesCognitiveServices-{0}-link', parameters('suffix')))]", + "location": "global", + "properties": { + "virtualNetwork": { + "id": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('vnetSubscriptionId'), parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName'))]" + }, + "registrationEnabled": false + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', variables('cognitiveServicesDnsZoneName'))]" + ] + }, + { + "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", + "apiVersion": "2024-05-01", + "name": "[format('{0}/{1}', format('{0}-private-endpoint', parameters('aiAccountName')), format('{0}-dns-group', parameters('aiAccountName')))]", + "properties": { + "privateDnsZoneConfigs": [ + { + "name": "[format('{0}-dns-aiserv-config', parameters('aiAccountName'))]", + "properties": { + "privateDnsZoneId": "[variables('aiServicesDnsZoneId')]" + } + }, + { + "name": "[format('{0}-dns-openai-config', parameters('aiAccountName'))]", + "properties": { + "privateDnsZoneId": "[variables('openAiDnsZoneId')]" + } + }, + { + "name": "[format('{0}-dns-cogserv-config', parameters('aiAccountName'))]", + "properties": { + "privateDnsZoneId": "[variables('cognitiveServicesDnsZoneId')]" + } + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateEndpoints', format('{0}-private-endpoint', parameters('aiAccountName')))]", + "[resourceId('Microsoft.Network/privateDnsZones/virtualNetworkLinks', variables('aiServicesDnsZoneName'), format('aiServices-{0}-link', parameters('suffix')))]", + "[resourceId('Microsoft.Network/privateDnsZones', variables('aiServicesDnsZoneName'))]", + "[resourceId('Microsoft.Network/privateDnsZones/virtualNetworkLinks', variables('cognitiveServicesDnsZoneName'), format('aiServicesCognitiveServices-{0}-link', parameters('suffix')))]", + "[resourceId('Microsoft.Network/privateDnsZones', variables('cognitiveServicesDnsZoneName'))]", + "[resourceId('Microsoft.Network/privateDnsZones/virtualNetworkLinks', variables('openAiDnsZoneName'), format('aiServicesOpenAI-{0}-link', parameters('suffix')))]", + "[resourceId('Microsoft.Network/privateDnsZones', variables('openAiDnsZoneName'))]" + ] + } + ] + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('accountName'), variables('uniqueSuffix')))]", + "[resourceId('Microsoft.Resources/deployments', format('vnet-{0}-{1}-deployment', variables('trimVnetName'), variables('uniqueSuffix')))]" + ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "[format('capabilityHost-configuration-{0}-deployment', variables('uniqueSuffix'))]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "accountName": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('accountName'), variables('uniqueSuffix'))), '2025-04-01').outputs.accountName.value]" + }, + "projectName": { + "value": "[variables('projectName')]" + }, + "projectCapHost": { + "value": "[parameters('projectCapHost')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.43.1.21952", + "templateHash": "4929793693928679874" + } + }, + "parameters": { + "projectName": { + "type": "string" + }, + "accountName": { + "type": "string" + }, + "projectCapHost": { + "type": "string" + } + }, + "resources": [ + { + "type": "Microsoft.CognitiveServices/accounts/projects/capabilityHosts", + "apiVersion": "2025-04-01-preview", + "name": "[format('{0}/{1}/{2}', parameters('accountName'), parameters('projectName'), parameters('projectCapHost'))]", + "properties": { + "capabilityHostKind": "Agents" + } + } + ], + "outputs": { + "projectCapHost": { + "type": "string", + "value": "[parameters('projectCapHost')]" + } + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('accountName'), variables('uniqueSuffix')))]", + "[resourceId('Microsoft.Resources/deployments', format('{0}-private-endpoint', variables('uniqueSuffix')))]", + "[resourceId('Microsoft.CognitiveServices/accounts/projects', variables('accountName'), variables('projectName'))]" + ] + } + ], + "outputs": { + "accountId": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('accountName'), variables('uniqueSuffix'))), '2025-04-01').outputs.accountID.value]" + }, + "accountName": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('accountName'), variables('uniqueSuffix'))), '2025-04-01').outputs.accountName.value]" + }, + "projectName": { + "type": "string", + "value": "[variables('projectName')]" + } + } +} \ No newline at end of file diff --git a/infrastructure/infrastructure-setup-bicep/11-private-network-basic-vnet/main.bicep b/infrastructure/infrastructure-setup-bicep/11-private-network-basic-vnet/main.bicep new file mode 100644 index 000000000..3ee3085c2 --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/11-private-network-basic-vnet/main.bicep @@ -0,0 +1,235 @@ +/* +Basic Agent Setup with VNet Injection - Network Secured +----------------------------------- +This template creates: + - A virtual network with agent and private endpoint subnets + - An AI Foundry account with network injection (VNet integration for agents) + - Private endpoint and DNS zones for the AI Services account + - An AI Foundry project with system-assigned managed identity + - A capability host for the project (basic agent, no BYO resources) + - A model deployment (gpt-4.1 by default) + +This is a "basic" agent setup β€” it does NOT create or connect BYO resources +(Azure AI Search, Storage Account, Cosmos DB). The platform-managed resources +are used instead. +*/ +@description('Location for all resources.') +@allowed([ + 'westus' + 'eastus' + 'eastus2' + 'japaneast' + 'francecentral' + 'spaincentral' + 'uaenorth' + 'southcentralus' + 'italynorth' + 'germanywestcentral' + 'brazilsouth' + 'southafricanorth' + 'australiaeast' + 'swedencentral' + 'canadaeast' + 'westeurope' + 'westus3' + 'uksouth' + 'southindia' + + //only class B and C + 'koreacentral' + 'polandcentral' + 'switzerlandnorth' + 'norwayeast' +]) +param location string = 'eastus' + +@description('Name for your AI Services resource.') +param aiServices string = 'aiservices' + +// Model deployment parameters +@description('The name of the model you want to deploy') +param modelName string = 'gpt-4.1' +@description('The provider of your model') +param modelFormat string = 'OpenAI' +@description('The version of your model') +param modelVersion string = '2025-04-14' +@description('The sku of your model deployment') +param modelSkuName string = 'GlobalStandard' +@description('The tokens per minute (TPM) of your model deployment') +param modelCapacity int = 30 + +// Create a short, unique suffix, that will be unique to each resource group +param deploymentTimestamp string = utcNow('yyyyMMddHHmmss') +var uniqueSuffix = substring(uniqueString('${resourceGroup().id}-${deploymentTimestamp}'), 0, 4) +var accountName = toLower('${aiServices}${uniqueSuffix}') + +@description('Name for your project resource.') +param firstProjectName string = 'project' + +@description('This project will be a sub-resource of your account') +param projectDescription string = 'A project for the AI Foundry account with network secured basic Agent' + +@description('The display name of the project') +param displayName string = 'network secured basic agent project' + +// Virtual Network parameters +@description('Virtual Network name for the Agent to create new or existing virtual network') +param vnetName string = 'agent-vnet-test' + +@description('The name of Agents Subnet to create new or existing subnet for agents') +param agentSubnetName string = 'agent-subnet' + +@description('The name of Private Endpoint subnet to create new or existing subnet for private endpoints') +param peSubnetName string = 'pe-subnet' + +@description('Existing Virtual Network name Resource ID') +param existingVnetResourceId string = '' + +@description('Address space for the VNet (only used for new VNet)') +param vnetAddressPrefix string = '' + +@description('Address prefix for the agent subnet. The default value is 192.168.0.0/24 but you can choose any size /26 or any class like 10.0.0.0 or 172.168.0.0') +param agentSubnetPrefix string = '' + +@description('Address prefix for the private endpoint subnet') +param peSubnetPrefix string = '' + +// DNS zone parameters +@description('Subscription ID where existing private DNS zones are located. Leave empty to use current subscription.') +param dnsZonesSubscriptionId string = '' + +@description('Object mapping DNS zone names to their resource group, or empty string to indicate creation') +param existingDnsZones object = { + 'privatelink.services.ai.azure.com': '' + 'privatelink.openai.azure.com': '' + 'privatelink.cognitiveservices.azure.com': '' +} + +@description('The name of the project capability host to be created') +param projectCapHost string = 'caphostproj' + + +var projectName = toLower('${firstProjectName}${uniqueSuffix}') + +// Check if existing VNet has been passed in +var existingVnetPassedIn = existingVnetResourceId != '' + +var vnetParts = split(existingVnetResourceId, '/') +var vnetSubscriptionId = existingVnetPassedIn ? vnetParts[2] : subscription().subscriptionId +var vnetResourceGroupName = existingVnetPassedIn ? vnetParts[4] : resourceGroup().name +var existingVnetName = existingVnetPassedIn ? last(vnetParts) : vnetName +var trimVnetName = trim(existingVnetName) + +// Resolve DNS zones subscription ID - use current subscription if not specified +var resolvedDnsZonesSubscriptionId = empty(dnsZonesSubscriptionId) ? subscription().subscriptionId : dnsZonesSubscriptionId + + +/* + Step 1: Create Virtual Network and Subnets + - Agent subnet delegated to Microsoft.App/environments for VNet injection + - Private endpoint subnet for secure access to AI Services +*/ +module vnet 'modules-network-secured/network-agent-vnet.bicep' = { + name: 'vnet-${trimVnetName}-${uniqueSuffix}-deployment' + params: { + location: location + vnetName: trimVnetName + useExistingVnet: existingVnetPassedIn + existingVnetResourceGroupName: vnetResourceGroupName + agentSubnetName: agentSubnetName + peSubnetName: peSubnetName + vnetAddressPrefix: vnetAddressPrefix + agentSubnetPrefix: agentSubnetPrefix + peSubnetPrefix: peSubnetPrefix + existingVnetSubscriptionId: vnetSubscriptionId + } +} + +/* + Step 2: Create the AI Services account with network injection and model deployment + - Network injection points the agent subnet for VNet integration + - Public network access is disabled + - Model deployment (gpt-4.1 by default) +*/ +module aiAccount 'modules-network-secured/ai-account-identity.bicep' = { + name: '${accountName}-${uniqueSuffix}-deployment' + params: { + accountName: accountName + location: location + modelName: modelName + modelFormat: modelFormat + modelVersion: modelVersion + modelSkuName: modelSkuName + modelCapacity: modelCapacity + agentSubnetId: vnet.outputs.agentSubnetId + } +} + +/* + Step 3: Private Endpoint and DNS Configuration for AI Services + - Creates private endpoint in the PE subnet + - Sets up private DNS zones for AI Services, OpenAI, and Cognitive Services + - Links DNS zones to the VNet for name resolution +*/ +module privateEndpointAndDNS 'modules-network-secured/private-endpoint-and-dns.bicep' = { + name: '${uniqueSuffix}-private-endpoint' + params: { + aiAccountName: aiAccount.outputs.accountName + vnetName: vnet.outputs.virtualNetworkName + peSubnetName: vnet.outputs.peSubnetName + suffix: uniqueSuffix + vnetResourceGroupName: vnet.outputs.virtualNetworkResourceGroup + vnetSubscriptionId: vnet.outputs.virtualNetworkSubscriptionId + existingDnsZones: existingDnsZones + dnsZonesSubscriptionId: resolvedDnsZonesSubscriptionId + } +} + +/* + Step 4: Create a Project + - Sub-resource of the AI Services account + - System-assigned managed identity + - No BYO resource connections (basic agent setup) +*/ +resource account 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { + name: accountName +} + +resource project 'Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview' = { + parent: account + name: projectName + location: location + identity: { + type: 'SystemAssigned' + } + properties: { + description: projectDescription + displayName: displayName + } + dependsOn: [ + aiAccount + privateEndpointAndDNS + ] +} + +/* + Step 5: Create the Capability Host for the project + - Basic agent capability host (no BYO connections) + - Platform-managed resources are used for thread storage, file storage, and vector store +*/ +module addProjectCapabilityHost 'modules-network-secured/add-project-capability-host.bicep' = { + name: 'capabilityHost-configuration-${uniqueSuffix}-deployment' + params: { + accountName: aiAccount.outputs.accountName + projectName: projectName + projectCapHost: projectCapHost + } + dependsOn: [ + project + privateEndpointAndDNS + ] +} + +output accountId string = aiAccount.outputs.accountID +output accountName string = aiAccount.outputs.accountName +output projectName string = project.name diff --git a/infrastructure/infrastructure-setup-bicep/11-private-network-basic-vnet/main.bicepparam b/infrastructure/infrastructure-setup-bicep/11-private-network-basic-vnet/main.bicepparam new file mode 100644 index 000000000..2ee35c245 --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/11-private-network-basic-vnet/main.bicepparam @@ -0,0 +1,28 @@ +using './main.bicep' + +param location = 'eastus' +param aiServices = 'foundry' +param modelName = 'gpt-4.1' +param modelFormat = 'OpenAI' +param modelVersion = '2025-04-14' +param modelSkuName = 'GlobalStandard' +param modelCapacity = 30 +param firstProjectName = 'project' +param projectDescription = 'A project for the AI Foundry account with network secured basic Agent' +param displayName = 'project' +param peSubnetName = 'pe-subnet' + +// Virtual Network parameters +// If you provide an existing VNet resource ID, the deployment will use it instead of creating a new one +param existingVnetResourceId = '' +param vnetName = 'agent-vnet-test' +param agentSubnetName = 'agent-subnet' + +// DNS zone parameters +// Leave empty to create new DNS zones, or provide resource group names to use existing ones +param dnsZonesSubscriptionId = '' +param existingDnsZones = { + 'privatelink.services.ai.azure.com': '' + 'privatelink.openai.azure.com': '' + 'privatelink.cognitiveservices.azure.com': '' +} diff --git a/infrastructure/infrastructure-setup-bicep/11-private-network-basic-vnet/main.json b/infrastructure/infrastructure-setup-bicep/11-private-network-basic-vnet/main.json new file mode 100644 index 000000000..9652d3b63 --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/11-private-network-basic-vnet/main.json @@ -0,0 +1,1329 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.40.2.10011", + "templateHash": "3777570653041274440" + } + }, + "parameters": { + "location": { + "type": "string", + "defaultValue": "eastus", + "allowedValues": [ + "westus", + "eastus", + "eastus2", + "japaneast", + "francecentral", + "spaincentral", + "uaenorth", + "southcentralus", + "italynorth", + "germanywestcentral", + "brazilsouth", + "southafricanorth", + "australiaeast", + "swedencentral", + "canadaeast", + "westeurope", + "westus3", + "uksouth", + "southindia", + "koreacentral", + "polandcentral", + "switzerlandnorth", + "norwayeast" + ], + "metadata": { + "description": "Location for all resources." + } + }, + "aiServices": { + "type": "string", + "defaultValue": "aiservices", + "metadata": { + "description": "Name for your AI Services resource." + } + }, + "modelName": { + "type": "string", + "defaultValue": "gpt-4.1", + "metadata": { + "description": "The name of the model you want to deploy" + } + }, + "modelFormat": { + "type": "string", + "defaultValue": "OpenAI", + "metadata": { + "description": "The provider of your model" + } + }, + "modelVersion": { + "type": "string", + "defaultValue": "2025-04-14", + "metadata": { + "description": "The version of your model" + } + }, + "modelSkuName": { + "type": "string", + "defaultValue": "GlobalStandard", + "metadata": { + "description": "The sku of your model deployment" + } + }, + "modelCapacity": { + "type": "int", + "defaultValue": 30, + "metadata": { + "description": "The tokens per minute (TPM) of your model deployment" + } + }, + "deploymentTimestamp": { + "type": "string", + "defaultValue": "[utcNow('yyyyMMddHHmmss')]" + }, + "firstProjectName": { + "type": "string", + "defaultValue": "project", + "metadata": { + "description": "Name for your project resource." + } + }, + "projectDescription": { + "type": "string", + "defaultValue": "A project for the AI Foundry account with network secured basic Agent", + "metadata": { + "description": "This project will be a sub-resource of your account" + } + }, + "displayName": { + "type": "string", + "defaultValue": "network secured basic agent project", + "metadata": { + "description": "The display name of the project" + } + }, + "vnetName": { + "type": "string", + "defaultValue": "agent-vnet-test", + "metadata": { + "description": "Virtual Network name for the Agent to create new or existing virtual network" + } + }, + "agentSubnetName": { + "type": "string", + "defaultValue": "agent-subnet", + "metadata": { + "description": "The name of Agents Subnet to create new or existing subnet for agents" + } + }, + "peSubnetName": { + "type": "string", + "defaultValue": "pe-subnet", + "metadata": { + "description": "The name of Private Endpoint subnet to create new or existing subnet for private endpoints" + } + }, + "existingVnetResourceId": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Existing Virtual Network name Resource ID" + } + }, + "vnetAddressPrefix": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Address space for the VNet (only used for new VNet)" + } + }, + "agentSubnetPrefix": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Address prefix for the agent subnet. The default value is 192.168.0.0/24 but you can choose any size /26 or any class like 10.0.0.0 or 172.168.0.0" + } + }, + "peSubnetPrefix": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Address prefix for the private endpoint subnet" + } + }, + "dnsZonesSubscriptionId": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Subscription ID where existing private DNS zones are located. Leave empty to use current subscription." + } + }, + "existingDnsZones": { + "type": "object", + "defaultValue": { + "privatelink.services.ai.azure.com": "", + "privatelink.openai.azure.com": "", + "privatelink.cognitiveservices.azure.com": "" + }, + "metadata": { + "description": "Object mapping DNS zone names to their resource group, or empty string to indicate creation" + } + }, + "projectCapHost": { + "type": "string", + "defaultValue": "caphostproj", + "metadata": { + "description": "The name of the project capability host to be created" + } + } + }, + "variables": { + "uniqueSuffix": "[substring(uniqueString(format('{0}-{1}', resourceGroup().id, parameters('deploymentTimestamp'))), 0, 4)]", + "accountName": "[toLower(format('{0}{1}', parameters('aiServices'), variables('uniqueSuffix')))]", + "projectName": "[toLower(format('{0}{1}', parameters('firstProjectName'), variables('uniqueSuffix')))]", + "existingVnetPassedIn": "[not(equals(parameters('existingVnetResourceId'), ''))]", + "vnetParts": "[split(parameters('existingVnetResourceId'), '/')]", + "vnetSubscriptionId": "[if(variables('existingVnetPassedIn'), variables('vnetParts')[2], subscription().subscriptionId)]", + "vnetResourceGroupName": "[if(variables('existingVnetPassedIn'), variables('vnetParts')[4], resourceGroup().name)]", + "existingVnetName": "[if(variables('existingVnetPassedIn'), last(variables('vnetParts')), parameters('vnetName'))]", + "trimVnetName": "[trim(variables('existingVnetName'))]", + "resolvedDnsZonesSubscriptionId": "[if(empty(parameters('dnsZonesSubscriptionId')), subscription().subscriptionId, parameters('dnsZonesSubscriptionId'))]" + }, + "resources": [ + { + "type": "Microsoft.CognitiveServices/accounts/projects", + "apiVersion": "2025-04-01-preview", + "name": "[format('{0}/{1}', variables('accountName'), variables('projectName'))]", + "location": "[parameters('location')]", + "identity": { + "type": "SystemAssigned" + }, + "properties": { + "description": "[parameters('projectDescription')]", + "displayName": "[parameters('displayName')]" + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('accountName'), variables('uniqueSuffix')))]", + "[resourceId('Microsoft.Resources/deployments', format('{0}-private-endpoint', variables('uniqueSuffix')))]" + ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "[format('vnet-{0}-{1}-deployment', variables('trimVnetName'), variables('uniqueSuffix'))]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "vnetName": { + "value": "[variables('trimVnetName')]" + }, + "useExistingVnet": { + "value": "[variables('existingVnetPassedIn')]" + }, + "existingVnetResourceGroupName": { + "value": "[variables('vnetResourceGroupName')]" + }, + "agentSubnetName": { + "value": "[parameters('agentSubnetName')]" + }, + "peSubnetName": { + "value": "[parameters('peSubnetName')]" + }, + "vnetAddressPrefix": { + "value": "[parameters('vnetAddressPrefix')]" + }, + "agentSubnetPrefix": { + "value": "[parameters('agentSubnetPrefix')]" + }, + "peSubnetPrefix": { + "value": "[parameters('peSubnetPrefix')]" + }, + "existingVnetSubscriptionId": { + "value": "[variables('vnetSubscriptionId')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.40.2.10011", + "templateHash": "9522150535342725348" + } + }, + "parameters": { + "location": { + "type": "string", + "metadata": { + "description": "Azure region for the deployment" + } + }, + "vnetName": { + "type": "string", + "metadata": { + "description": "The name of the virtual network" + } + }, + "useExistingVnet": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Indicates if an existing VNet should be used" + } + }, + "existingVnetSubscriptionId": { + "type": "string", + "defaultValue": "[subscription().subscriptionId]", + "metadata": { + "description": "Subscription ID of the existing VNet (if different from current subscription)" + } + }, + "existingVnetResourceGroupName": { + "type": "string", + "defaultValue": "[resourceGroup().name]", + "metadata": { + "description": "Resource Group name of the existing VNet (if different from current resource group)" + } + }, + "agentSubnetName": { + "type": "string", + "defaultValue": "agent-subnet", + "metadata": { + "description": "The name of Agents Subnet" + } + }, + "peSubnetName": { + "type": "string", + "defaultValue": "pe-subnet", + "metadata": { + "description": "The name of Private Endpoint subnet" + } + }, + "vnetAddressPrefix": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Address space for the VNet (only used for new VNet)" + } + }, + "agentSubnetPrefix": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Address prefix for the agent subnet" + } + }, + "peSubnetPrefix": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Address prefix for the private endpoint subnet" + } + } + }, + "resources": [ + { + "condition": "[not(parameters('useExistingVnet'))]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "vnet-deployment", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "vnetName": { + "value": "[parameters('vnetName')]" + }, + "agentSubnetName": { + "value": "[parameters('agentSubnetName')]" + }, + "peSubnetName": { + "value": "[parameters('peSubnetName')]" + }, + "vnetAddressPrefix": { + "value": "[parameters('vnetAddressPrefix')]" + }, + "agentSubnetPrefix": { + "value": "[parameters('agentSubnetPrefix')]" + }, + "peSubnetPrefix": { + "value": "[parameters('peSubnetPrefix')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.40.2.10011", + "templateHash": "1725519900292599397" + } + }, + "parameters": { + "location": { + "type": "string", + "metadata": { + "description": "Azure region for the deployment" + } + }, + "vnetName": { + "type": "string", + "defaultValue": "agents-vnet-test", + "metadata": { + "description": "The name of the virtual network" + } + }, + "agentSubnetName": { + "type": "string", + "defaultValue": "agent-subnet", + "metadata": { + "description": "The name of Agents Subnet" + } + }, + "peSubnetName": { + "type": "string", + "defaultValue": "pe-subnet", + "metadata": { + "description": "The name of Hub subnet" + } + }, + "vnetAddressPrefix": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Address space for the VNet" + } + }, + "agentSubnetPrefix": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Address prefix for the agent subnet" + } + }, + "peSubnetPrefix": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Address prefix for the private endpoint subnet" + } + } + }, + "variables": { + "defaultVnetAddressPrefix": "192.168.0.0/16", + "vnetAddress": "[if(empty(parameters('vnetAddressPrefix')), variables('defaultVnetAddressPrefix'), parameters('vnetAddressPrefix'))]", + "agentSubnet": "[if(empty(parameters('agentSubnetPrefix')), cidrSubnet(variables('vnetAddress'), 24, 0), parameters('agentSubnetPrefix'))]", + "peSubnet": "[if(empty(parameters('peSubnetPrefix')), cidrSubnet(variables('vnetAddress'), 24, 1), parameters('peSubnetPrefix'))]" + }, + "resources": [ + { + "type": "Microsoft.Network/virtualNetworks", + "apiVersion": "2024-05-01", + "name": "[parameters('vnetName')]", + "location": "[parameters('location')]", + "properties": { + "addressSpace": { + "addressPrefixes": [ + "[variables('vnetAddress')]" + ] + }, + "subnets": [ + { + "name": "[parameters('agentSubnetName')]", + "properties": { + "addressPrefix": "[variables('agentSubnet')]", + "delegations": [ + { + "name": "Microsoft.app/environments", + "properties": { + "serviceName": "Microsoft.App/environments" + } + } + ] + } + }, + { + "name": "[parameters('peSubnetName')]", + "properties": { + "addressPrefix": "[variables('peSubnet')]" + } + } + ] + } + } + ], + "outputs": { + "peSubnetName": { + "type": "string", + "value": "[parameters('peSubnetName')]" + }, + "agentSubnetName": { + "type": "string", + "value": "[parameters('agentSubnetName')]" + }, + "agentSubnetId": { + "type": "string", + "value": "[format('{0}/subnets/{1}', resourceId('Microsoft.Network/virtualNetworks', parameters('vnetName')), parameters('agentSubnetName'))]" + }, + "peSubnetId": { + "type": "string", + "value": "[format('{0}/subnets/{1}', resourceId('Microsoft.Network/virtualNetworks', parameters('vnetName')), parameters('peSubnetName'))]" + }, + "virtualNetworkName": { + "type": "string", + "value": "[parameters('vnetName')]" + }, + "virtualNetworkId": { + "type": "string", + "value": "[resourceId('Microsoft.Network/virtualNetworks', parameters('vnetName'))]" + }, + "virtualNetworkResourceGroup": { + "type": "string", + "value": "[resourceGroup().name]" + }, + "virtualNetworkSubscriptionId": { + "type": "string", + "value": "[subscription().subscriptionId]" + } + } + } + } + }, + { + "condition": "[parameters('useExistingVnet')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "existing-vnet-deployment", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "vnetName": { + "value": "[parameters('vnetName')]" + }, + "vnetResourceGroupName": { + "value": "[parameters('existingVnetResourceGroupName')]" + }, + "vnetSubscriptionId": { + "value": "[parameters('existingVnetSubscriptionId')]" + }, + "agentSubnetName": { + "value": "[parameters('agentSubnetName')]" + }, + "peSubnetName": { + "value": "[parameters('peSubnetName')]" + }, + "agentSubnetPrefix": { + "value": "[parameters('agentSubnetPrefix')]" + }, + "peSubnetPrefix": { + "value": "[parameters('peSubnetPrefix')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.40.2.10011", + "templateHash": "12834877200138369468" + } + }, + "parameters": { + "vnetName": { + "type": "string", + "metadata": { + "description": "The name of the existing virtual network" + } + }, + "vnetSubscriptionId": { + "type": "string", + "defaultValue": "[subscription().subscriptionId]", + "metadata": { + "description": "Subscription ID of virtual network (if different from current subscription)" + } + }, + "vnetResourceGroupName": { + "type": "string", + "defaultValue": "[resourceGroup().name]", + "metadata": { + "description": "Resource Group name of the existing VNet (if different from current resource group)" + } + }, + "agentSubnetName": { + "type": "string", + "defaultValue": "agent-subnet", + "metadata": { + "description": "The name of Agents Subnet" + } + }, + "peSubnetName": { + "type": "string", + "defaultValue": "pe-subnet", + "metadata": { + "description": "The name of Private Endpoint subnet" + } + }, + "agentSubnetPrefix": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Address prefix for the agent subnet (only needed if creating new subnet)" + } + }, + "peSubnetPrefix": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Address prefix for the private endpoint subnet (only needed if creating new subnet)" + } + } + }, + "resources": [ + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "[format('agent-subnet-{0}', uniqueString(deployment().name, parameters('agentSubnetName')))]", + "resourceGroup": "[parameters('vnetResourceGroupName')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "vnetName": { + "value": "[parameters('vnetName')]" + }, + "subnetName": { + "value": "[parameters('agentSubnetName')]" + }, + "addressPrefix": "[if(empty(parameters('agentSubnetPrefix')), createObject('value', cidrSubnet(reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName')), '2024-05-01').addressSpace.addressPrefixes[0], 24, 0)), createObject('value', parameters('agentSubnetPrefix')))]", + "delegations": { + "value": [ + { + "name": "Microsoft.App/environments", + "properties": { + "serviceName": "Microsoft.App/environments" + } + } + ] + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.40.2.10011", + "templateHash": "16684585228229443328" + } + }, + "parameters": { + "vnetName": { + "type": "string", + "metadata": { + "description": "Name of the virtual network" + } + }, + "subnetName": { + "type": "string", + "metadata": { + "description": "Name of the subnet" + } + }, + "addressPrefix": { + "type": "string", + "metadata": { + "description": "Address prefix for the subnet" + } + }, + "delegations": { + "type": "array", + "defaultValue": [], + "metadata": { + "description": "Array of subnet delegations" + } + } + }, + "resources": [ + { + "type": "Microsoft.Network/virtualNetworks/subnets", + "apiVersion": "2024-05-01", + "name": "[format('{0}/{1}', parameters('vnetName'), parameters('subnetName'))]", + "properties": { + "addressPrefix": "[parameters('addressPrefix')]", + "delegations": "[parameters('delegations')]" + } + } + ], + "outputs": { + "subnetId": { + "type": "string", + "value": "[resourceId('Microsoft.Network/virtualNetworks/subnets', split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[0], split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[1])]" + }, + "subnetName": { + "type": "string", + "value": "[parameters('subnetName')]" + } + } + } + } + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "[format('pe-subnet-{0}', uniqueString(deployment().name, parameters('peSubnetName')))]", + "resourceGroup": "[parameters('vnetResourceGroupName')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "vnetName": { + "value": "[parameters('vnetName')]" + }, + "subnetName": { + "value": "[parameters('peSubnetName')]" + }, + "addressPrefix": "[if(empty(parameters('peSubnetPrefix')), createObject('value', cidrSubnet(reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName')), '2024-05-01').addressSpace.addressPrefixes[0], 24, 1)), createObject('value', parameters('peSubnetPrefix')))]", + "delegations": { + "value": [] + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.40.2.10011", + "templateHash": "16684585228229443328" + } + }, + "parameters": { + "vnetName": { + "type": "string", + "metadata": { + "description": "Name of the virtual network" + } + }, + "subnetName": { + "type": "string", + "metadata": { + "description": "Name of the subnet" + } + }, + "addressPrefix": { + "type": "string", + "metadata": { + "description": "Address prefix for the subnet" + } + }, + "delegations": { + "type": "array", + "defaultValue": [], + "metadata": { + "description": "Array of subnet delegations" + } + } + }, + "resources": [ + { + "type": "Microsoft.Network/virtualNetworks/subnets", + "apiVersion": "2024-05-01", + "name": "[format('{0}/{1}', parameters('vnetName'), parameters('subnetName'))]", + "properties": { + "addressPrefix": "[parameters('addressPrefix')]", + "delegations": "[parameters('delegations')]" + } + } + ], + "outputs": { + "subnetId": { + "type": "string", + "value": "[resourceId('Microsoft.Network/virtualNetworks/subnets', split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[0], split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[1])]" + }, + "subnetName": { + "type": "string", + "value": "[parameters('subnetName')]" + } + } + } + } + } + ], + "outputs": { + "peSubnetName": { + "type": "string", + "value": "[parameters('peSubnetName')]" + }, + "agentSubnetName": { + "type": "string", + "value": "[parameters('agentSubnetName')]" + }, + "agentSubnetId": { + "type": "string", + "value": "[format('{0}/subnets/{1}', extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName')), parameters('agentSubnetName'))]" + }, + "peSubnetId": { + "type": "string", + "value": "[format('{0}/subnets/{1}', extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName')), parameters('peSubnetName'))]" + }, + "virtualNetworkName": { + "type": "string", + "value": "[parameters('vnetName')]" + }, + "virtualNetworkId": { + "type": "string", + "value": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName'))]" + }, + "virtualNetworkResourceGroup": { + "type": "string", + "value": "[parameters('vnetResourceGroupName')]" + }, + "virtualNetworkSubscriptionId": { + "type": "string", + "value": "[parameters('vnetSubscriptionId')]" + } + } + } + } + } + ], + "outputs": { + "virtualNetworkName": { + "type": "string", + "value": "[if(parameters('useExistingVnet'), reference(resourceId('Microsoft.Resources/deployments', 'existing-vnet-deployment'), '2025-04-01').outputs.virtualNetworkName.value, reference(resourceId('Microsoft.Resources/deployments', 'vnet-deployment'), '2025-04-01').outputs.virtualNetworkName.value)]" + }, + "virtualNetworkId": { + "type": "string", + "value": "[if(parameters('useExistingVnet'), reference(resourceId('Microsoft.Resources/deployments', 'existing-vnet-deployment'), '2025-04-01').outputs.virtualNetworkId.value, reference(resourceId('Microsoft.Resources/deployments', 'vnet-deployment'), '2025-04-01').outputs.virtualNetworkId.value)]" + }, + "virtualNetworkSubscriptionId": { + "type": "string", + "value": "[if(parameters('useExistingVnet'), reference(resourceId('Microsoft.Resources/deployments', 'existing-vnet-deployment'), '2025-04-01').outputs.virtualNetworkSubscriptionId.value, reference(resourceId('Microsoft.Resources/deployments', 'vnet-deployment'), '2025-04-01').outputs.virtualNetworkSubscriptionId.value)]" + }, + "virtualNetworkResourceGroup": { + "type": "string", + "value": "[if(parameters('useExistingVnet'), reference(resourceId('Microsoft.Resources/deployments', 'existing-vnet-deployment'), '2025-04-01').outputs.virtualNetworkResourceGroup.value, reference(resourceId('Microsoft.Resources/deployments', 'vnet-deployment'), '2025-04-01').outputs.virtualNetworkResourceGroup.value)]" + }, + "agentSubnetName": { + "type": "string", + "value": "[parameters('agentSubnetName')]" + }, + "peSubnetName": { + "type": "string", + "value": "[parameters('peSubnetName')]" + }, + "agentSubnetId": { + "type": "string", + "value": "[if(parameters('useExistingVnet'), reference(resourceId('Microsoft.Resources/deployments', 'existing-vnet-deployment'), '2025-04-01').outputs.agentSubnetId.value, reference(resourceId('Microsoft.Resources/deployments', 'vnet-deployment'), '2025-04-01').outputs.agentSubnetId.value)]" + }, + "peSubnetId": { + "type": "string", + "value": "[if(parameters('useExistingVnet'), reference(resourceId('Microsoft.Resources/deployments', 'existing-vnet-deployment'), '2025-04-01').outputs.peSubnetId.value, reference(resourceId('Microsoft.Resources/deployments', 'vnet-deployment'), '2025-04-01').outputs.peSubnetId.value)]" + } + } + } + } + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "[format('{0}-{1}-deployment', variables('accountName'), variables('uniqueSuffix'))]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "accountName": { + "value": "[variables('accountName')]" + }, + "location": { + "value": "[parameters('location')]" + }, + "modelName": { + "value": "[parameters('modelName')]" + }, + "modelFormat": { + "value": "[parameters('modelFormat')]" + }, + "modelVersion": { + "value": "[parameters('modelVersion')]" + }, + "modelSkuName": { + "value": "[parameters('modelSkuName')]" + }, + "modelCapacity": { + "value": "[parameters('modelCapacity')]" + }, + "agentSubnetId": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', format('vnet-{0}-{1}-deployment', variables('trimVnetName'), variables('uniqueSuffix'))), '2025-04-01').outputs.agentSubnetId.value]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.40.2.10011", + "templateHash": "481468620289904255" + } + }, + "parameters": { + "accountName": { + "type": "string" + }, + "location": { + "type": "string" + }, + "modelName": { + "type": "string" + }, + "modelFormat": { + "type": "string" + }, + "modelVersion": { + "type": "string" + }, + "modelSkuName": { + "type": "string" + }, + "modelCapacity": { + "type": "int" + }, + "agentSubnetId": { + "type": "string" + }, + "networkInjection": { + "type": "string", + "defaultValue": "true" + } + }, + "resources": [ + { + "type": "Microsoft.CognitiveServices/accounts", + "apiVersion": "2025-04-01-preview", + "name": "[parameters('accountName')]", + "location": "[parameters('location')]", + "sku": { + "name": "S0" + }, + "kind": "AIServices", + "identity": { + "type": "SystemAssigned" + }, + "properties": { + "allowProjectManagement": true, + "customSubDomainName": "[parameters('accountName')]", + "networkAcls": { + "defaultAction": "Deny", + "virtualNetworkRules": [], + "ipRules": [], + "bypass": "AzureServices" + }, + "publicNetworkAccess": "Disabled", + "networkInjections": "[if(equals(parameters('networkInjection'), 'true'), createArray(createObject('scenario', 'agent', 'subnetArmId', parameters('agentSubnetId'), 'useMicrosoftManagedNetwork', false())), null())]", + "disableLocalAuth": false + } + }, + { + "type": "Microsoft.CognitiveServices/accounts/deployments", + "apiVersion": "2025-04-01-preview", + "name": "[format('{0}/{1}', parameters('accountName'), parameters('modelName'))]", + "sku": { + "capacity": "[parameters('modelCapacity')]", + "name": "[parameters('modelSkuName')]" + }, + "properties": { + "model": { + "name": "[parameters('modelName')]", + "format": "[parameters('modelFormat')]", + "version": "[parameters('modelVersion')]" + } + }, + "dependsOn": [ + "[resourceId('Microsoft.CognitiveServices/accounts', parameters('accountName'))]" + ] + } + ], + "outputs": { + "accountName": { + "type": "string", + "value": "[parameters('accountName')]" + }, + "accountID": { + "type": "string", + "value": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('accountName'))]" + }, + "accountTarget": { + "type": "string", + "value": "[reference(resourceId('Microsoft.CognitiveServices/accounts', parameters('accountName')), '2025-04-01-preview').endpoint]" + }, + "accountPrincipalId": { + "type": "string", + "value": "[reference(resourceId('Microsoft.CognitiveServices/accounts', parameters('accountName')), '2025-04-01-preview', 'full').identity.principalId]" + } + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', format('vnet-{0}-{1}-deployment', variables('trimVnetName'), variables('uniqueSuffix')))]" + ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "[format('{0}-private-endpoint', variables('uniqueSuffix'))]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "aiAccountName": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('accountName'), variables('uniqueSuffix'))), '2025-04-01').outputs.accountName.value]" + }, + "vnetName": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', format('vnet-{0}-{1}-deployment', variables('trimVnetName'), variables('uniqueSuffix'))), '2025-04-01').outputs.virtualNetworkName.value]" + }, + "peSubnetName": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', format('vnet-{0}-{1}-deployment', variables('trimVnetName'), variables('uniqueSuffix'))), '2025-04-01').outputs.peSubnetName.value]" + }, + "suffix": { + "value": "[variables('uniqueSuffix')]" + }, + "vnetResourceGroupName": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', format('vnet-{0}-{1}-deployment', variables('trimVnetName'), variables('uniqueSuffix'))), '2025-04-01').outputs.virtualNetworkResourceGroup.value]" + }, + "vnetSubscriptionId": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', format('vnet-{0}-{1}-deployment', variables('trimVnetName'), variables('uniqueSuffix'))), '2025-04-01').outputs.virtualNetworkSubscriptionId.value]" + }, + "existingDnsZones": { + "value": "[parameters('existingDnsZones')]" + }, + "dnsZonesSubscriptionId": { + "value": "[variables('resolvedDnsZonesSubscriptionId')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.40.2.10011", + "templateHash": "11715965448884311497" + } + }, + "parameters": { + "aiAccountName": { + "type": "string", + "metadata": { + "description": "Name of the AI Foundry account" + } + }, + "vnetName": { + "type": "string", + "metadata": { + "description": "Name of the Vnet" + } + }, + "peSubnetName": { + "type": "string", + "metadata": { + "description": "Name of the private endpoint subnet" + } + }, + "suffix": { + "type": "string", + "metadata": { + "description": "Suffix for unique resource names" + } + }, + "vnetResourceGroupName": { + "type": "string", + "defaultValue": "[resourceGroup().name]", + "metadata": { + "description": "Resource Group name for existing Virtual Network (if different from current resource group)" + } + }, + "vnetSubscriptionId": { + "type": "string", + "defaultValue": "[subscription().subscriptionId]", + "metadata": { + "description": "Subscription ID for Virtual Network" + } + }, + "existingDnsZones": { + "type": "object", + "defaultValue": { + "privatelink.services.ai.azure.com": "", + "privatelink.openai.azure.com": "", + "privatelink.cognitiveservices.azure.com": "" + }, + "metadata": { + "description": "Map of DNS zone FQDNs to resource group names. If provided, reference existing DNS zones in this resource group instead of creating them." + } + }, + "dnsZonesSubscriptionId": { + "type": "string", + "metadata": { + "description": "Subscription ID where existing private DNS zones are located. Should be resolved to current subscription if empty." + } + } + }, + "variables": { + "aiServicesDnsZoneName": "privatelink.services.ai.azure.com", + "openAiDnsZoneName": "privatelink.openai.azure.com", + "cognitiveServicesDnsZoneName": "privatelink.cognitiveservices.azure.com", + "aiServicesDnsZoneRG": "[parameters('existingDnsZones')[variables('aiServicesDnsZoneName')]]", + "openAiDnsZoneRG": "[parameters('existingDnsZones')[variables('openAiDnsZoneName')]]", + "cognitiveServicesDnsZoneRG": "[parameters('existingDnsZones')[variables('cognitiveServicesDnsZoneName')]]", + "aiServicesDnsZoneId": "[if(empty(variables('aiServicesDnsZoneRG')), resourceId('Microsoft.Network/privateDnsZones', variables('aiServicesDnsZoneName')), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('dnsZonesSubscriptionId'), variables('aiServicesDnsZoneRG')), 'Microsoft.Network/privateDnsZones', variables('aiServicesDnsZoneName')))]", + "openAiDnsZoneId": "[if(empty(variables('openAiDnsZoneRG')), resourceId('Microsoft.Network/privateDnsZones', variables('openAiDnsZoneName')), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('dnsZonesSubscriptionId'), variables('openAiDnsZoneRG')), 'Microsoft.Network/privateDnsZones', variables('openAiDnsZoneName')))]", + "cognitiveServicesDnsZoneId": "[if(empty(variables('cognitiveServicesDnsZoneRG')), resourceId('Microsoft.Network/privateDnsZones', variables('cognitiveServicesDnsZoneName')), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('dnsZonesSubscriptionId'), variables('cognitiveServicesDnsZoneRG')), 'Microsoft.Network/privateDnsZones', variables('cognitiveServicesDnsZoneName')))]" + }, + "resources": [ + { + "type": "Microsoft.Network/privateEndpoints", + "apiVersion": "2024-05-01", + "name": "[format('{0}-private-endpoint', parameters('aiAccountName'))]", + "location": "[resourceGroup().location]", + "properties": { + "subnet": { + "id": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('vnetSubscriptionId'), parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks/subnets', parameters('vnetName'), parameters('peSubnetName'))]" + }, + "privateLinkServiceConnections": [ + { + "name": "[format('{0}-private-link-service-connection', parameters('aiAccountName'))]", + "properties": { + "privateLinkServiceId": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('aiAccountName'))]", + "groupIds": [ + "account" + ] + } + } + ] + } + }, + { + "condition": "[empty(variables('aiServicesDnsZoneRG'))]", + "type": "Microsoft.Network/privateDnsZones", + "apiVersion": "2020-06-01", + "name": "[variables('aiServicesDnsZoneName')]", + "location": "global" + }, + { + "condition": "[empty(variables('openAiDnsZoneRG'))]", + "type": "Microsoft.Network/privateDnsZones", + "apiVersion": "2020-06-01", + "name": "[variables('openAiDnsZoneName')]", + "location": "global" + }, + { + "condition": "[empty(variables('cognitiveServicesDnsZoneRG'))]", + "type": "Microsoft.Network/privateDnsZones", + "apiVersion": "2020-06-01", + "name": "[variables('cognitiveServicesDnsZoneName')]", + "location": "global" + }, + { + "condition": "[empty(variables('aiServicesDnsZoneRG'))]", + "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", + "apiVersion": "2024-06-01", + "name": "[format('{0}/{1}', variables('aiServicesDnsZoneName'), format('aiServices-{0}-link', parameters('suffix')))]", + "location": "global", + "properties": { + "virtualNetwork": { + "id": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('vnetSubscriptionId'), parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName'))]" + }, + "registrationEnabled": false + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', variables('aiServicesDnsZoneName'))]" + ] + }, + { + "condition": "[empty(variables('openAiDnsZoneRG'))]", + "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", + "apiVersion": "2024-06-01", + "name": "[format('{0}/{1}', variables('openAiDnsZoneName'), format('aiServicesOpenAI-{0}-link', parameters('suffix')))]", + "location": "global", + "properties": { + "virtualNetwork": { + "id": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('vnetSubscriptionId'), parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName'))]" + }, + "registrationEnabled": false + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', variables('openAiDnsZoneName'))]" + ] + }, + { + "condition": "[empty(variables('cognitiveServicesDnsZoneRG'))]", + "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", + "apiVersion": "2024-06-01", + "name": "[format('{0}/{1}', variables('cognitiveServicesDnsZoneName'), format('aiServicesCognitiveServices-{0}-link', parameters('suffix')))]", + "location": "global", + "properties": { + "virtualNetwork": { + "id": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('vnetSubscriptionId'), parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName'))]" + }, + "registrationEnabled": false + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', variables('cognitiveServicesDnsZoneName'))]" + ] + }, + { + "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", + "apiVersion": "2024-05-01", + "name": "[format('{0}/{1}', format('{0}-private-endpoint', parameters('aiAccountName')), format('{0}-dns-group', parameters('aiAccountName')))]", + "properties": { + "privateDnsZoneConfigs": [ + { + "name": "[format('{0}-dns-aiserv-config', parameters('aiAccountName'))]", + "properties": { + "privateDnsZoneId": "[variables('aiServicesDnsZoneId')]" + } + }, + { + "name": "[format('{0}-dns-openai-config', parameters('aiAccountName'))]", + "properties": { + "privateDnsZoneId": "[variables('openAiDnsZoneId')]" + } + }, + { + "name": "[format('{0}-dns-cogserv-config', parameters('aiAccountName'))]", + "properties": { + "privateDnsZoneId": "[variables('cognitiveServicesDnsZoneId')]" + } + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateEndpoints', format('{0}-private-endpoint', parameters('aiAccountName')))]", + "[resourceId('Microsoft.Network/privateDnsZones/virtualNetworkLinks', variables('aiServicesDnsZoneName'), format('aiServices-{0}-link', parameters('suffix')))]", + "[resourceId('Microsoft.Network/privateDnsZones', variables('aiServicesDnsZoneName'))]", + "[resourceId('Microsoft.Network/privateDnsZones/virtualNetworkLinks', variables('cognitiveServicesDnsZoneName'), format('aiServicesCognitiveServices-{0}-link', parameters('suffix')))]", + "[resourceId('Microsoft.Network/privateDnsZones', variables('cognitiveServicesDnsZoneName'))]", + "[resourceId('Microsoft.Network/privateDnsZones/virtualNetworkLinks', variables('openAiDnsZoneName'), format('aiServicesOpenAI-{0}-link', parameters('suffix')))]", + "[resourceId('Microsoft.Network/privateDnsZones', variables('openAiDnsZoneName'))]" + ] + } + ] + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('accountName'), variables('uniqueSuffix')))]", + "[resourceId('Microsoft.Resources/deployments', format('vnet-{0}-{1}-deployment', variables('trimVnetName'), variables('uniqueSuffix')))]" + ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "[format('capabilityHost-configuration-{0}-deployment', variables('uniqueSuffix'))]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "accountName": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('accountName'), variables('uniqueSuffix'))), '2025-04-01').outputs.accountName.value]" + }, + "projectName": { + "value": "[variables('projectName')]" + }, + "projectCapHost": { + "value": "[parameters('projectCapHost')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.40.2.10011", + "templateHash": "17607216443982035670" + } + }, + "parameters": { + "projectName": { + "type": "string" + }, + "accountName": { + "type": "string" + }, + "projectCapHost": { + "type": "string" + } + }, + "resources": [ + { + "type": "Microsoft.CognitiveServices/accounts/projects/capabilityHosts", + "apiVersion": "2025-04-01-preview", + "name": "[format('{0}/{1}/{2}', parameters('accountName'), parameters('projectName'), parameters('projectCapHost'))]", + "properties": { + "capabilityHostKind": "Agents" + } + } + ], + "outputs": { + "projectCapHost": { + "type": "string", + "value": "[parameters('projectCapHost')]" + } + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('accountName'), variables('uniqueSuffix')))]", + "[resourceId('Microsoft.Resources/deployments', format('{0}-private-endpoint', variables('uniqueSuffix')))]", + "[resourceId('Microsoft.CognitiveServices/accounts/projects', variables('accountName'), variables('projectName'))]" + ] + } + ], + "outputs": { + "accountId": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('accountName'), variables('uniqueSuffix'))), '2025-04-01').outputs.accountID.value]" + }, + "accountName": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('accountName'), variables('uniqueSuffix'))), '2025-04-01').outputs.accountName.value]" + }, + "projectName": { + "type": "string", + "value": "[variables('projectName')]" + } + } +} \ No newline at end of file diff --git a/infrastructure/infrastructure-setup-bicep/11-private-network-basic-vnet/metadata.json b/infrastructure/infrastructure-setup-bicep/11-private-network-basic-vnet/metadata.json new file mode 100644 index 000000000..bc42e410d --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/11-private-network-basic-vnet/metadata.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://aka.ms/azure-quickstart-templates-metadata-schema#", + "type": "QuickStart", + "itemDisplayName": "Network Secured Basic Agent Setup with VNet Injection", + "description": "This template demonstrates how to set up Foundry with a basic agent configuration using VNet injection for network isolation.", + "summary": "This template demonstrates how to set up Foundry with a basic agent using your own virtual network for network-secured agent workloads.", + "githubUsername": "meerakurup", + "dateUpdated": "2026-05-04", + "environments": [ + "AzureCloud" + ] +} diff --git a/infrastructure/infrastructure-setup-bicep/11-private-network-basic-vnet/modules-network-secured/add-project-capability-host.bicep b/infrastructure/infrastructure-setup-bicep/11-private-network-basic-vnet/modules-network-secured/add-project-capability-host.bicep new file mode 100644 index 000000000..155eeffb8 --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/11-private-network-basic-vnet/modules-network-secured/add-project-capability-host.bicep @@ -0,0 +1,22 @@ +param projectName string +param accountName string +param projectCapHost string + +resource account 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { + name: accountName +} + +resource project 'Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview' existing = { + name: projectName + parent: account +} + +resource projectCapabilityHost 'Microsoft.CognitiveServices/accounts/projects/capabilityHosts@2025-04-01-preview' = { + name: projectCapHost + parent: project + properties: { + capabilityHostKind: 'Agents' + } +} + +output projectCapHost string = projectCapabilityHost.name diff --git a/infrastructure/infrastructure-setup-bicep/11-private-network-basic-vnet/modules-network-secured/ai-account-identity.bicep b/infrastructure/infrastructure-setup-bicep/11-private-network-basic-vnet/modules-network-secured/ai-account-identity.bicep new file mode 100644 index 000000000..47f72a3be --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/11-private-network-basic-vnet/modules-network-secured/ai-account-identity.bicep @@ -0,0 +1,63 @@ +param accountName string +param location string +param modelName string +param modelFormat string +param modelVersion string +param modelSkuName string +param modelCapacity int +param agentSubnetId string +param networkInjection string = 'true' + +#disable-next-line BCP036 +resource account 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' = { + name: accountName + location: location + sku: { + name: 'S0' + } + kind: 'AIServices' + identity: { + type: 'SystemAssigned' + } + properties: { + allowProjectManagement: true + customSubDomainName: accountName + networkAcls: { + defaultAction: 'Deny' + virtualNetworkRules: [] + ipRules: [] + bypass:'AzureServices' + } + publicNetworkAccess: 'Disabled' + networkInjections:((networkInjection == 'true') ? [ + { + scenario: 'agent' + subnetArmId: agentSubnetId + useMicrosoftManagedNetwork: false + } + ] : null ) + disableLocalAuth: false + } +} + +#disable-next-line BCP081 +resource modelDeployment 'Microsoft.CognitiveServices/accounts/deployments@2025-04-01-preview'= { + parent: account + name: modelName + sku : { + capacity: modelCapacity + name: modelSkuName + } + properties: { + model:{ + name: modelName + format: modelFormat + version: modelVersion + } + } +} + +output accountName string = account.name +output accountID string = account.id +output accountTarget string = account.properties.endpoint +output accountPrincipalId string = account.identity.principalId diff --git a/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/modules-network-secured/existing-vnet.bicep b/infrastructure/infrastructure-setup-bicep/11-private-network-basic-vnet/modules-network-secured/existing-vnet.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/modules-network-secured/existing-vnet.bicep rename to infrastructure/infrastructure-setup-bicep/11-private-network-basic-vnet/modules-network-secured/existing-vnet.bicep diff --git a/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/modules-network-secured/network-agent-vnet.bicep b/infrastructure/infrastructure-setup-bicep/11-private-network-basic-vnet/modules-network-secured/network-agent-vnet.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/modules-network-secured/network-agent-vnet.bicep rename to infrastructure/infrastructure-setup-bicep/11-private-network-basic-vnet/modules-network-secured/network-agent-vnet.bicep diff --git a/infrastructure/infrastructure-setup-bicep/11-private-network-basic-vnet/modules-network-secured/private-endpoint-and-dns.bicep b/infrastructure/infrastructure-setup-bicep/11-private-network-basic-vnet/modules-network-secured/private-endpoint-and-dns.bicep new file mode 100644 index 000000000..babc0f76d --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/11-private-network-basic-vnet/modules-network-secured/private-endpoint-and-dns.bicep @@ -0,0 +1,189 @@ +/* +Private Endpoint and DNS Configuration Module +------------------------------------------ +This module configures private network access for the AI Services account using: + +1. Private Endpoints: + - Creates network interface in the specified subnet + - Establishes private connection to AI Services account + - Enables secure access without public internet exposure + +2. Private DNS Zones: + - Enables custom DNS resolution for private endpoints + +3. DNS Zone Links: + - Links private DNS zones to the VNet + - Enables name resolution for resources in the VNet + - Prevents DNS resolution conflicts + +Security Benefits: +- Eliminates public internet exposure +- Enables secure access from within VNet +- Prevents data exfiltration through network +*/ + +// Resource names and identifiers +@description('Name of the AI Foundry account') +param aiAccountName string +@description('Name of the Vnet') +param vnetName string +@description('Name of the private endpoint subnet') +param peSubnetName string +@description('Suffix for unique resource names') +param suffix string + +@description('Resource Group name for existing Virtual Network (if different from current resource group)') +param vnetResourceGroupName string = resourceGroup().name + +@description('Subscription ID for Virtual Network') +param vnetSubscriptionId string = subscription().subscriptionId + +@description('Map of DNS zone FQDNs to resource group names. If provided, reference existing DNS zones in this resource group instead of creating them.') +param existingDnsZones object = { + 'privatelink.services.ai.azure.com': '' + 'privatelink.openai.azure.com': '' + 'privatelink.cognitiveservices.azure.com': '' +} + +@description('Subscription ID where existing private DNS zones are located. Should be resolved to current subscription if empty.') +param dnsZonesSubscriptionId string + +// ---- Resource references ---- +resource aiAccount 'Microsoft.CognitiveServices/accounts@2023-05-01' existing = { + name: aiAccountName + scope: resourceGroup() +} + +// Reference existing network resources +resource vnet 'Microsoft.Network/virtualNetworks@2024-05-01' existing = { + name: vnetName + scope: resourceGroup(vnetSubscriptionId, vnetResourceGroupName) +} +resource peSubnet 'Microsoft.Network/virtualNetworks/subnets@2024-05-01' existing = { + parent: vnet + name: peSubnetName +} + +/* -------------------------------------------- AI Foundry Account Private Endpoint -------------------------------------------- */ + +// Private endpoint for AI Services account +// - Creates network interface in customer hub subnet +// - Establishes private connection to AI Services account +resource aiAccountPrivateEndpoint 'Microsoft.Network/privateEndpoints@2024-05-01' = { + name: '${aiAccountName}-private-endpoint' + location: resourceGroup().location + properties: { + subnet: { id: peSubnet.id } // Deploy in customer hub subnet + privateLinkServiceConnections: [ + { + name: '${aiAccountName}-private-link-service-connection' + properties: { + privateLinkServiceId: aiAccount.id + groupIds: [ 'account' ] // Target AI Services account + } + } + ] + } +} + +/* -------------------------------------------- Private DNS Zones -------------------------------------------- */ + +// Format: 1) Private DNS Zone +// 2) Link Private DNS Zone to VNet +// 3) Create DNS Zone Group for Private Endpoint + +var aiServicesDnsZoneName = 'privatelink.services.ai.azure.com' +var openAiDnsZoneName = 'privatelink.openai.azure.com' +var cognitiveServicesDnsZoneName = 'privatelink.cognitiveservices.azure.com' + +// ---- DNS Zone Resource Group lookups ---- +var aiServicesDnsZoneRG = existingDnsZones[aiServicesDnsZoneName] +var openAiDnsZoneRG = existingDnsZones[openAiDnsZoneName] +var cognitiveServicesDnsZoneRG = existingDnsZones[cognitiveServicesDnsZoneName] + +// ---- DNS Zone Resources and References ---- +resource aiServicesPrivateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' = if (empty(aiServicesDnsZoneRG)) { + name: aiServicesDnsZoneName + location: 'global' +} + +// Reference existing private DNS zone if provided +resource existingAiServicesPrivateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' existing = if (!empty(aiServicesDnsZoneRG)) { + name: aiServicesDnsZoneName + scope: resourceGroup(dnsZonesSubscriptionId, aiServicesDnsZoneRG) +} +//creating condition if user pass existing dns zones or not +var aiServicesDnsZoneId = empty(aiServicesDnsZoneRG) ? aiServicesPrivateDnsZone.id : existingAiServicesPrivateDnsZone.id + +resource openAiPrivateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' = if (empty(openAiDnsZoneRG)) { + name: openAiDnsZoneName + location: 'global' +} + +// Reference existing private DNS zone if provided +resource existingOpenAiPrivateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' existing = if (!empty(openAiDnsZoneRG)) { + name: openAiDnsZoneName + scope: resourceGroup(dnsZonesSubscriptionId, openAiDnsZoneRG) +} +//creating condition if user pass existing dns zones or not +var openAiDnsZoneId = empty(openAiDnsZoneRG) ? openAiPrivateDnsZone.id : existingOpenAiPrivateDnsZone.id + +resource cognitiveServicesPrivateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' = if (empty(cognitiveServicesDnsZoneRG)) { + name: cognitiveServicesDnsZoneName + location: 'global' +} + +// Reference existing private DNS zone if provided +resource existingCognitiveServicesPrivateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' existing = if (!empty(cognitiveServicesDnsZoneRG)) { + name: cognitiveServicesDnsZoneName + scope: resourceGroup(dnsZonesSubscriptionId, cognitiveServicesDnsZoneRG) +} +//creating condition if user pass existing dns zones or not +var cognitiveServicesDnsZoneId = empty(cognitiveServicesDnsZoneRG) ? cognitiveServicesPrivateDnsZone.id : existingCognitiveServicesPrivateDnsZone.id + +// ---- DNS VNet Links ---- +resource aiServicesLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = if (empty(aiServicesDnsZoneRG)) { + parent: aiServicesPrivateDnsZone + location: 'global' + name: 'aiServices-${suffix}-link' + properties: { + virtualNetwork: { id: vnet.id } + registrationEnabled: false + } +} +resource openAiLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = if (empty(openAiDnsZoneRG)) { + parent: openAiPrivateDnsZone + location: 'global' + name: 'aiServicesOpenAI-${suffix}-link' + properties: { + virtualNetwork: { id: vnet.id } + registrationEnabled: false + } +} +resource cognitiveServicesLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = if (empty(cognitiveServicesDnsZoneRG)) { + parent: cognitiveServicesPrivateDnsZone + location: 'global' + name: 'aiServicesCognitiveServices-${suffix}-link' + properties: { + virtualNetwork: { id: vnet.id } + registrationEnabled: false + } +} + +// ---- DNS Zone Groups ---- +resource aiServicesDnsGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2024-05-01' = { + parent: aiAccountPrivateEndpoint + name: '${aiAccountName}-dns-group' + properties: { + privateDnsZoneConfigs: [ + { name: '${aiAccountName}-dns-aiserv-config', properties: { privateDnsZoneId: aiServicesDnsZoneId } } + { name: '${aiAccountName}-dns-openai-config', properties: { privateDnsZoneId: openAiDnsZoneId } } + { name: '${aiAccountName}-dns-cogserv-config', properties: { privateDnsZoneId: cognitiveServicesDnsZoneId } } + ] + } + dependsOn: [ + empty(aiServicesDnsZoneRG) ? aiServicesLink : null + empty(openAiDnsZoneRG) ? openAiLink : null + empty(cognitiveServicesDnsZoneRG) ? cognitiveServicesLink : null + ] +} diff --git a/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/modules-network-secured/subnet.bicep b/infrastructure/infrastructure-setup-bicep/11-private-network-basic-vnet/modules-network-secured/subnet.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/modules-network-secured/subnet.bicep rename to infrastructure/infrastructure-setup-bicep/11-private-network-basic-vnet/modules-network-secured/subnet.bicep diff --git a/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/modules-network-secured/vnet.bicep b/infrastructure/infrastructure-setup-bicep/11-private-network-basic-vnet/modules-network-secured/vnet.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/modules-network-secured/vnet.bicep rename to infrastructure/infrastructure-setup-bicep/11-private-network-basic-vnet/modules-network-secured/vnet.bicep diff --git a/infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/README.md b/infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/README.md index 9bffa6610..2fde8bc50 100644 --- a/infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/README.md +++ b/infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/README.md @@ -1,5 +1,5 @@ --- -description: This set of templates demonstrates how to set up Azure AI Agent Service with virtual network isolation with private network links to connect the agent to your secure data. +description: This set of templates demonstrates how to set up Foundry Agent Service with virtual network isolation with private network links to connect the agent to your secure data. page_type: sample products: - azure @@ -10,30 +10,51 @@ languages: - json --- -# Azure AI Agent Service: Standard Agent Setup with E2E Network Isolation +# Microsoft Foundry: Standard Agent Setup with E2E Network Isolation (without Tools behind VNET) + +> **NEW** +> For support on deploying the right network isolation template, check out the [GitHub Copilot for Azure skill for private networking](https://github.com/microsoft/GitHub-Copilot-for-Azure/blob/main/plugin/skills/microsoft-foundry/resource/private-network/private-network.md) set-up! > **IMPORTANT** -> -> Private Class A subnet support is GA and available in the following regions. **Supported regions: Australia East, Brazil South, Canada East, East US, East US 2, France Central, Germany West Central, Italy North, Japan East, South Africa North, South Central US, South India, Spain Central, Sweden Central, UAE North, UK South, West Europe, West US, West US 3.** -> -> Private Class B and C subnet support is already GA and available in all regions supported by Azure AI Foundry Agent Service. Deployment templates and setup steps are identical for Class A, B, and C subnets. For more on the supported regions of the Azure AI Foundry Agent service, see [Models supported by Azure AI Foundry Agent Service](https://learn.microsoft.com/en-us/azure/ai-foundry/agents/concepts/model-region-support?tabs=global-standard) +> Please note this template does not support using Agent tools behind a VNET. Please refer to [template 19](../19-private-network-agent-tools/) and the [TESTING-GUIDE.md](../19-private-network-agent-tools/tests/TESTING-GUIDE.md) to ensure tool traffic also goes through your vnet. --- ## Overview -This infrastructure-as-code (IaC) solution deploys a network-secured Azure AI agent environment with private networking and role-based access control (RBAC). +This infrastructure-as-code (IaC) solution deploys a network-secured agent environment with private networking and role-based access control (RBAC). -Standard setup supports private network isolation through utilizing **Bring Your Own Virtual Network (BYO VNet)** approach, also known as **custom VNet support with subnet delegation.** +Standard setup supports private network isolation through utilizing **Bring Your Own Virtual Network (BYO VNet)** approach, also known as **custom VNet support with subnet delegation.** Please note this template does not support using Agent tools behind a VNET. Please use [template 19](../19-private-network-agent-tools/) for this. This implementation gives you full control over the inbound and outbound communication paths for your agent. You can restrict access to only the resources explicitly required by your agent, such as storage accounts, databases, or APIs, while blocking all other traffic by default. This approach ensures that your agent operates within a tightly scoped network boundary, reducing the risk of data leakage or unauthorized access. By default, this setup simplifies security configuration while enforcing strong isolation guarantees, ensuring that each agent deployment remains secure, compliant, and aligned with enterprise networking policies. --- -## Key Information +## When to Use This Template + +Use this template when you need: +- **Full end-to-end network isolation** β€” All resources behind private endpoints with no public internet access +- **BYO VNet control** β€” You manage your own virtual network, subnets, and network security groups +- **Standard agent setup with BYO resources** β€” Customer-managed Storage, Cosmos DB, and AI Search for data residency and compliance +- **System Assigned Managed Identity** β€” Simplified identity management with platform-managed credentials + +### Template Decision Guide + +Use the table below to choose the right infrastructure template for your scenario: -**Region and Resource Placement Requirements** -- **All Foundry workspace resources should be in the same region as the VNet**, including CosmosDB, Storage Account, AI Search, Foundry Account, Project, Managed Identity. The only exception is within the Foundry Account, you may choose to deploy your model to a different region, and any cross-region communication will be handled securely within our network infrastructure. - - **Note:** Your Virtual Network can be in a different resource group than your Foundry workspace resources +| Template | Agent Type | Networking | Identity | Key Use Case | +|----------|-----------|------------|----------|-------------| +| [**15** (this template)](../15-private-network-standard-agent-setup/) | Standard (BYO resources) | BYO VNet + Private Endpoints | System Assigned MI | E2E network isolation with full agent capabilities | +| [**19**](../19-private-network-agent-tools/) | Standard (BYO resources) | BYO VNet + Private Endpoints | System Assigned MI | Same as 15 **plus** tools behind VNet (MCP, OpenAPI, Functions, A2A) | +| [**17**](../17-private-network-standard-user-assigned-identity-agent-setup/) | Standard (BYO resources) | BYO VNet + Private Endpoints | **User Assigned MI** | Same as 15 but with user-managed identity | +| [**16**](../16-private-network-standard-agent-apim-setup-preview/) | Standard (BYO resources) | BYO VNet + Private Endpoints | System Assigned MI | Same as 15 **plus** private APIM integration (preview) | +| [**18**](../18-managed-virtual-network-preview/) | Standard (BYO resources) | **Managed VNet** (Microsoft-managed) | System Assigned MI | Network isolation without managing your own VNet (preview) | +| [**15a**](../15a-private-network-evaluation-only-setup/) | Evaluation only | BYO VNet + Private Endpoints | System Assigned MI | Minimal setup for evaluation β€” no Cosmos DB, AI Search, or capability host | +| [**11**](../11-private-network-basic-vnet/) | **Basic** (platform-managed) | BYO VNet injection | System Assigned MI | Basic agents with VNet isolation β€” no BYO resources needed | +| [**41**](../41-standard-agent-setup/) | Standard (BYO resources) | **Public** (no VNet) | System Assigned MI | Standard agents without network isolation | +| [**40**](../40-basic-agent-setup/) | **Basic** (platform-managed) | **Public** (no VNet) | System Assigned MI | Simplest setup β€” no BYO resources, no private networking | +--- + +## Deploy to Azure [![Deploy To Azure](https://raw.githubusercontent.com/Azure/azure-quickstart-templates/master/1-CONTRIBUTION-GUIDE/images/deploytoazure.svg?sanitize=true)](https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fraw.githubusercontent.com%2Fazure-ai-foundry%2Ffoundry-samples%2Frefs%2Fheads%2Fmain%2Finfrastructure%2Finfrastructure-setup-bicep%2F15-private-network-standard-agent-setup%2Fazuredeploy.json) @@ -42,9 +63,9 @@ This implementation gives you full control over the inbound and outbound communi ## Prerequisites 1. **Active Azure subscription with appropriate permissions** - - **Azure AI Account Owner**: Needed to create a cognitive services account and project - - **Owner or Role Based Access Administrator**: Needed to assign RBAC to the required resources (Cosmos DB, Azure AI Search, Storage) - - **Azure AI User**: Needed to create and edit agents + - **Foundry Account Owner**: Needed to create the Microsoft Foundry account and project. + - **Owner or Role Based Access Administrator**: Needed to assign RBAC on the Azure resources used by this template. + - **Foundry User**: Needed to create and use agents, projects, or evaluation workloads after deployment. 1. **Register Resource Providers** @@ -62,8 +83,8 @@ This implementation gives you full control over the inbound and outbound communi 1. Network administrator permissions (if operating in a restricted or enterprise environment) -1. Sufficient quota for all resources in your target Azure region - * If no parameters are passed in, this template creates an Azure AI Foundry resource, Foundry project, Azure Cosmos DB for NoSQL, Azure AI Search, and Azure Storage account +1. Sufficient quota for all resources required by this template in the target Azure region, including model deployment quota. + * If no parameters are passed in, this template creates an Microsoft Foundry resource, Foundry project, Azure Cosmos DB for NoSQL, Azure AI Search, and Azure Storage account 1. Azure CLI installed and configured on your local workstation or deployment pipeline server --- @@ -81,10 +102,21 @@ This implementation gives you full control over the inbound and outbound communi > **Notes:** - If you do not provide an existing virtual network, the template will create a new virtual network with the default address spaces and subnets described above. If you use an existing virtual network, make sure it already contains two subnets (Agent and Private Endpoint) before deploying the template. - - You must ensure the Foundry account was successfully created so that underlying caphost has also succeeded. Then proceed to deploying the project caphost bicep. - - You must ensure the subnet is not already in use by another account. It must be an exclusive subnet for the Foundry account. + - The account-level capability host is now provisioned declaratively by `modules-network-secured/add-account-capability-host.bicep` as part of `main.bicep`. The standalone `createCapHost.sh` script is no longer required for first-time deployments; it remains in the folder only to support the cleanup-then-recreate flow described in the [Account Deletion Prerequisites and Cleanup Guidance](#account-deletion-prerequisites-and-cleanup-guidance). - You must ensure the subnet is exclusively delegated to __Microsoft.App/environments__ and cannot be used by any other Azure resources. - + + + +### Limitations / Known Issues + +1. The delegated agent subnet must be exclusively used by a single Foundry account. It cannot be shared across accounts. +2. The Foundry resource and the virtual network must be in the same Azure region. BYO resources (Storage, Cosmos DB, AI Search) may be in different regions. +3. For the virtual network IP range, you may use any Private Class A, B or C IP range. Private Class A IP address ranges (10.x.x.x) are only supported in the following regions: **Australia East, Brazil South, Canada East, East US, East US 2, France Central, Germany West Central, Italy North, Japan East, South Africa North, South Central US, South India, Spain Central, Sweden Central, UAE North, UK South, West US, West US 3.** Use Class B (172.16.x.x) or C (192.168.x.x) ranges for other regions. You may not use any other IP range that overlaps to the list above or uses public IP ranges. +4. This template does **not** support tools (MCP servers, OpenAPI tools, Azure Functions, A2A) behind the VNet. Use [template 19](../19-private-network-agent-tools/) for that scenario. +5. There is no upgrade path from BYO VNet (this template) to Managed Virtual Network (template 18). A Foundry resource redeployment is required. +6. All projects within the same Foundry account share model deployments. Per-project model isolation is not supported. +7. Cosmos DB is deployed as single-region. Multi-region replication must be configured manually post-deployment. +8. When reusing an existing Foundry account (`existingAiFoundryAccountResourceId`), the template will not create a new model deployment if `skipModelDeployment` is set to `true`. The required model deployment(s) must already exist on the BYO account. ### Account Deletion Prerequisites and Cleanup Guidance @@ -96,6 +128,8 @@ Before deleting an **Account** resource, it is essential to first delete the ass **2. Retain Account, Remove Capability Host**: If you intend to retain the account but remove the capability host, execute the script `deleteCaphost.sh` located in this folder. After deletion, allow approximately max of 20 minutes for all resources to be fully unlinked from the account. To recreate the capability host for the account, use the script `createCaphost.sh` located in the same folder. +> **Note**: The account-level capability host is created declaratively by `main.bicep` (via `modules-network-secured/add-account-capability-host.bicep`) on first deployment. The `createCapHost.sh` script is intended for this cleanup-then-recreate scenario only; it is not required for an initial deployment. + > **Important**: Before deleting the account capability host, ensure that the **project capability host** is deleted. @@ -109,6 +143,34 @@ Note: If not provided, the following resources will be created automatically for #### Parameters +| Parameter | Description | Default | Required | +|-----------|-------------|---------|----------| +| `location` | Azure region for deployment | `eastus` | Yes | +| `aiServices` | Base name for the AI Services resource | `aiservices` | No | +| `firstProjectName` | Name for the Foundry project | `project` | No | +| `modelName` | Model to deploy | `gpt-4.1` | No | +| `modelFormat` | Model provider | `OpenAI` | No | +| `modelVersion` | Model version | `2025-04-14` | No | +| `modelSkuName` | Model deployment SKU | `GlobalStandard` | No | +| `modelCapacity` | Tokens per minute (TPM) capacity | `30` | No | +| `skipModelDeployment` | When `true`, skip creating a model deployment. Recommended when reusing an existing Foundry account that already has the required model deployments. | `false` | No | +| `vnetName` | Virtual Network name. When `existingVnetResourceId` is set, the name is derived from that resource ID and this parameter is ignored. When creating a new VNet, leave empty to use the generated default. | `''` | No | +| `agentSubnetName` | Subnet name for agent workloads | `agent-subnet` | No | +| `agentSubnetPrefix` | Address prefix for agent subnet | `192.168.0.0/24` | No | +| `peSubnetName` | Subnet name for private endpoints | `pe-subnet` | No | +| `peSubnetPrefix` | Address prefix for PE subnet | `192.168.1.0/24` | No | +| `existingVnetResourceId` | Full ARM Resource ID of an existing VNet | `''` (creates new) | No | +| `reuseExistingSubnets` | When `true` and `existingVnetResourceId` is set, the template will reference your existing subnets without modifying them. Use this when your subnets are already configured by your platform team (NSG, route tables, private endpoint network policies) and tenant policies forbid changes. | `false` | No | +| `vnetAddressPrefix` | Address space for new VNet | `192.168.0.0/16` | No | +| `aiSearchResourceId` | ARM Resource ID of existing AI Search | `''` (creates new) | No | +| `azureStorageAccountResourceId` | ARM Resource ID of existing Storage account | `''` (creates new) | No | +| `azureCosmosDBAccountResourceId` | ARM Resource ID of existing Cosmos DB | `''` (creates new) | No | +| `existingAiFoundryAccountResourceId` | Full ARM Resource ID of an existing Microsoft Foundry (Cognitive Services / AIServices) account to reuse. When set, the template will not create a new account. | `''` (creates new) | No | +| `dnsZonesSubscriptionId` | Subscription ID for existing DNS zones. Accepts either a bare GUID (``) or a full ARM subscription path (`/subscriptions/`); the template normalizes the value internally. | `''` (current sub) | No | +| `existingDnsZones` | Map of DNS zone names to resource groups | All empty (creates new) | No | + +#### BYO Resource Details + 1. **Use Existing Virtual Network and Subnets** To use an existing VNet and subnets, set the existingVnetResourceId parameter to the full Azure Resource ID of the target VNet and its address range, and provide the names of the two required subnets. If the existing VNet is associated with private DNS zones, set the existingDnsZones parameter to the resource group name in which the zones are located. For example: @@ -126,7 +188,9 @@ To use an existing VNet and subnets, set the existingVnetResourceId parameter to πŸ’‘ If subnets information is provided then make sure it exist within the specified VNet to avoid deployment errors. If subnet information is not provided, the template will create subnets with the default address space. -πŸ’‘ **Cross-Subscription DNS Zones**: All DNS zones specified in `existingDnsZones` will be referenced from the subscription specified in `dnsZonesSubscriptionId`. Leave this parameter empty (default) to use the current deployment subscription, or set it to a subscription ID if your DNS zones are located in a different subscription. +πŸ’‘ **Reuse pre-configured subnets**: If your subnets are already configured by your platform team (NSG, route tables, `privateEndpointNetworkPolicies` set per tenant policy), set `reuseExistingSubnets = true`. This tells the template to reference the subnets without re-applying their configuration, which prevents an inadvertent reset of subnet properties on redeploy. + +πŸ’‘ **Cross-Subscription DNS Zones**: All DNS zones specified in `existingDnsZones` will be referenced from the subscription specified in `dnsZonesSubscriptionId`. Leave this parameter empty (default) to use the current deployment subscription, or set it to a subscription ID if your DNS zones are located in a different subscription. The parameter accepts either a bare subscription GUID or a full ARM subscription path (`/subscriptions/`); the template normalizes the value internally. ⚠️ **Important**: When `dnsZonesSubscriptionId` is set to a different subscription, ALL DNS zones in `existingDnsZones` must have resource groups specified (non-empty values). The template does not support creating new DNS zones in a different subscription. Empty resource groups are only allowed when creating zones in the current deployment subscription. @@ -136,25 +200,6 @@ To use an existing VNet and subnets, set the existingVnetResourceId parameter to To use an existing Cosmos DB for NoSQL resource, set cosmosDBResourceId parameter to the full Azure Resource ID of the target Cosmos DB. - param azureCosmosDBAccountResourceId string = /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.DocumentDB/databaseAccounts/{cosmosDbAccountName} -> **⚠️ Important: Cosmos DB Connection Requirements** -> -> When creating the Cosmos DB connection (e.g., via REST API or ARM), ensure the following: -> - The `authType` **must** be set to `AAD`. This is the only supported authentication type for the Cosmos DB connection used by the Agent Service. -> - The `metadata` section **must** include the `ResourceId` property, set to the full Azure Resource ID of your Cosmos DB account. The Agent Service relies on this property to correctly identify and connect to your Cosmos DB resource. Omitting `ResourceId` from the metadata will cause the connection to fail. -> -> Example connection properties: -> ```json -> { -> "category": "CosmosDB", -> "authType": "AAD", -> "metadata": { -> "ApiType": "Azure", -> "ResourceId": "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.DocumentDB/databaseAccounts/{cosmosDbAccountName}", -> "location": "{region}" -> } -> } -> ``` - 3. **Use an existing Azure AI Search resource** @@ -167,6 +212,18 @@ To use an existing Azure AI Search resource, set aiSearchServiceResourceId param To use an existing Azure Storage account, set aiStorageAccountResourceId parameter to the full Azure resource Id of the target Azure Storage account resource. - param aiStorageAccountResourceId string = /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Storage/storageAccounts/{storageAccountName} + +5. **Use an existing Microsoft Foundry account** + +To reuse an existing Microsoft Foundry (Cognitive Services / AIServices kind) account instead of creating a new one, set `existingAiFoundryAccountResourceId` to the full Azure Resource ID of the target account. The template will reference the existing account, scope the account-level capability host to its resource group and subscription, and skip the deterministic-suffix account creation path (which would otherwise create a new account on every redeploy). + +- param existingAiFoundryAccountResourceId string = '/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.CognitiveServices/accounts/{accountName}' +- param skipModelDeployment bool = true // recommended when the BYO account already has the required model deployment(s) + +πŸ’‘ **When to use this**: bring-your-own-account is intended for scenarios where the Foundry account is provisioned ahead of time by a platform team or landing zone, and the workload deployment must reuse it (for compliance, naming standards, or to avoid orphaned accounts on retry). + +⚠️ **Important**: When `existingAiFoundryAccountResourceId` is set, the required model deployment(s) must already exist on the BYO account if `skipModelDeployment = true`. The agent service depends on at least one model deployment matching `modelName` / `modelVersion` to function. + --- ## Deploy the bicep template @@ -191,15 +248,70 @@ Click the deploy to Azure button above to open the Azure portal and deploy the t az deployment group create --resource-group --template-file main.bicep --parameters main.bicepparam ``` -> **Note:** To access your Foundry resource securely, use either a VM, VPN, or ExpressRoute. +> **Note:** To access a private Foundry resource securely, use one of the following: +> - A VM or jump box on the virtual network, optionally accessed through Azure Bastion +> - Azure VPN Gateway +> - Azure ExpressRoute + +### Cleanup + +To delete all resources created by this template: + +```bash +az group delete --name --yes --no-wait +``` + +> **Important**: If you need to reuse the same subnet, follow the [Account Deletion Prerequisites and Cleanup Guidance](#account-deletion-prerequisites-and-cleanup-guidance) to properly purge the account and wait for the capability host to fully unlink (~20 minutes). --- ## Network Secured Agent Project Architecture Deep Dive +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Secure Access (VPN Gateway / ExpressRoute / Azure Bastion) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Microsoft Foundry β”‚ + β”‚ (publicNetworkAccess: β”‚ + β”‚ DISABLED) β”‚ + β”‚ β”‚ + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ + β”‚ β”‚ Foundry Project β”‚ β”‚ + β”‚ β”‚ (Agent Workspace) β”‚ β”‚ + β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ Subnet Delegation + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ BYO Virtual Network β”‚ + β”‚ (192.168.0.0/16) β”‚ + β”‚ β”‚ + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ + β”‚ β”‚ Agent Subnet β”‚ β”‚ + β”‚ β”‚ (192.168.0.0/24) β”‚ β”‚ ◄── Delegated to + β”‚ β”‚ Microsoft.App/envs β”‚ β”‚ Microsoft.App/environments + β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β”‚ β”‚ + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ + β”‚ β”‚ PE Subnet β”‚ β”‚ + β”‚ β”‚ (192.168.1.0/24) β”‚ β”‚ + β”‚ β”‚ β”‚ β”‚ + β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ + β”‚ β”‚ β”‚Storage β”‚ β”‚Cosmos β”‚ β”‚ β”‚ ◄── Private endpoints + β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ (no public access) + β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ + β”‚ β”‚ β”‚Search β”‚ β”‚Foundry β”‚ β”‚ β”‚ + β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ + β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +> **Tip:** For detailed layer-by-layer deployment diagrams, see the `diagrams/` folder. + ### Core Components -**Azure AI Foundry** resource +**Microsoft Foundry** resource - Central orchestration point - Manages service connections - Set networking and policy configurations @@ -216,11 +328,11 @@ Click the deploy to Azure button above to open the Azure portal and deploy the t - BYO Search: All vector stores created by the agent leverage the customer’s Azure AI Search resource. - BYO Thread Storage: All customer messages and conversation history will be stored in the customer’s own Azure Cosmos DB account. -By bundling these BYO features (file storage, search, and thread storage), the standard setup guarantees that your deployment is secure by default. All data processed by Azure AI Foundry Agent Service is automatically stored at rest in your own Azure resources, helping you meet internal policies, compliance requirements, and enterprise security standards. +By bundling these BYO features (file storage, search, and thread storage), the standard setup guarantees that your deployment is secure by default. All data processed by Microsoft Foundry Agent Service is automatically stored at rest in your own Azure resources, helping you meet internal policies, compliance requirements, and enterprise security standards. ### Azure Resources Created -Azure AI Foundry (Cognitive Services) +Microsoft Foundry (Cognitive Services) - Type: Microsoft.CognitiveServices/accounts - API version: 2025-04-01-preview - Kind: AIServices @@ -291,7 +403,7 @@ Network Security **Private Endpoints** Private endpoints ensure secure, internal-only connectivity. Private endpoints are created for the following: -- Azure AI Foundry +- Microsoft Foundry - Azure AI Search - Azure Storage - Azure Cosmos DB @@ -299,7 +411,7 @@ Private endpoints ensure secure, internal-only connectivity. Private endpoints a **Private DNS Zones** | Private Link Resource Type | Sub Resource | Private DNS Zone Name | Public DNS Zone Forwarders | |----------------------------|--------------|------------------------|-----------------------------| -| **Azure AI Foundry** | account | `privatelink.cognitiveservices.azure.com`
`privatelink.openai.azure.com`
`privatelink.services.ai.azure.com` | `cognitiveservices.azure.com`
`openai.azure.com`
`services.ai.azure.com` | +| **Microsoft Foundry** | account | `privatelink.cognitiveservices.azure.com`
`privatelink.openai.azure.com`
`privatelink.services.ai.azure.com` | `cognitiveservices.azure.com`
`openai.azure.com`
`services.ai.azure.com` | | **Azure AI Search** | searchService| `privatelink.search.windows.net` | `search.windows.net` | | **Azure Cosmos DB** | Sql | `privatelink.documents.azure.com` | `documents.azure.com` | | **Azure Storage** | blob | `privatelink.blob.core.windows.net` | `blob.core.windows.net` | @@ -340,8 +452,9 @@ Private endpoints ensure secure, internal-only connectivity. Private endpoints a ```text modules-network-secured/ +β”œβ”€β”€ add-account-capability-host.bicep # Declarative account-level capability host (replaces createCapHost.sh for first-time deployments) β”œβ”€β”€ add-project-capability-host.bicep # Configuring the project's capability host -β”œβ”€β”€ ai-account-identity.bicep # Azure AI Foundry deployment and configuration +β”œβ”€β”€ ai-account-identity.bicep # Microsoft Foundry deployment and configuration (supports BYO existing account) β”œβ”€β”€ ai-project-identity.bicep # Foundry project deployment and connection configuration β”œβ”€β”€ ai-search-role-assignments.bicep # AI Search RBAC configuration β”œβ”€β”€ azure-storage-account-role-assignments.bicep # Storage Account RBAC configuration @@ -358,8 +471,6 @@ modules-network-secured/ └── vnet.bicep # Deploying a new virtual network ``` -> **Note:** If you bring your own VNET for this template, ensure the subnet for Agents has the correct subnet delegation to `Microsoft.App/environments`. If you have not specified the delegated subnet, the template will complete this for you. - ## Maintenance ### Regular Tasks @@ -377,8 +488,7 @@ modules-network-secured/ 4. Review network security groups --- ---- -# (Optional) Adding Multiple Projects to AI Foundry Deployment +# (Optional) Adding Multiple Projects to Foundry Deployment This guide explains how to add additional projects to your existing AI Foundry deployment with network security and capability hosts. @@ -541,8 +651,8 @@ Each new project deployment creates: ## References -- [Azure AI Foundry Networking Documentation](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/configure-private-link?tabs=azure-portal&pivots=fdp-project) -- [Azure AI Foundry RBAC Documentation](https://learn.microsoft.com/en-us/azure/ai-foundry/concepts/rbac-azure-ai-foundry?pivots=fdp-project) +- [Microsoft Foundry Networking Documentation](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/configure-private-link?tabs=azure-portal&pivots=fdp-project) +- [Microsoft Foundry RBAC Documentation](https://learn.microsoft.com/en-us/azure/ai-foundry/concepts/rbac-azure-ai-foundry?pivots=fdp-project) - [Private Endpoint Documentation](https://learn.microsoft.com/en-us/azure/private-link/) - [RBAC Documentation](https://learn.microsoft.com/en-us/azure/role-based-access-control/) - [Network Security Best Practices](https://learn.microsoft.com/en-us/azure/security/fundamentals/network-best-practices) diff --git a/infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/azuredeploy.json b/infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/azuredeploy.json index 8c09508fa..fac50d7df 100644 --- a/infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/azuredeploy.json +++ b/infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/azuredeploy.json @@ -4,8 +4,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.40.2.10011", - "templateHash": "10213309447334439675" + "version": "0.43.8.12551", + "templateHash": "4437106568758821389" } }, "parameters": { @@ -110,9 +110,9 @@ }, "vnetName": { "type": "string", - "defaultValue": "agent-vnet-test", + "defaultValue": "", "metadata": { - "description": "Virtual Network name for the Agent to create new or existing virtual network" + "description": "Virtual Network name. Required ONLY when creating a NEW VNet (existingVnetResourceId is empty). When existingVnetResourceId is set, this value is IGNORED, the name is derived from the resource ID. If you supply both they should match; otherwise the resource ID wins." } }, "agentSubnetName": { @@ -157,6 +157,27 @@ "description": "Address prefix for the private endpoint subnet" } }, + "reuseExistingSubnets": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "When true and existingVnetResourceId is set, the template will NOT modify your existing subnets." + } + }, + "existingAiFoundryAccountResourceId": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Optional. Full ARM resource ID of an existing AI Foundry (CognitiveServices/accounts kind=AIServices) account to reuse. When set, the template will NOT create a new account." + } + }, + "skipModelDeployment": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Optional. When true, skip the model deployment. Recommended when reusing an existing account that already has the required model deployments." + } + }, "aiSearchResourceId": { "type": "string", "defaultValue": "", @@ -224,10 +245,18 @@ "variables": { "uniqueSuffix": "[substring(uniqueString(format('{0}-{1}', resourceGroup().id, parameters('deploymentTimestamp'))), 0, 4)]", "accountName": "[toLower(format('{0}{1}', parameters('aiServices'), variables('uniqueSuffix')))]", + "useExistingAccount": "[not(empty(parameters('existingAiFoundryAccountResourceId')))]", + "existingAccountIdParts": "[split(parameters('existingAiFoundryAccountResourceId'), '/')]", + "existingAccountSubscriptionId": "[if(variables('useExistingAccount'), variables('existingAccountIdParts')[2], subscription().subscriptionId)]", + "existingAccountResourceGroupName": "[if(variables('useExistingAccount'), variables('existingAccountIdParts')[4], resourceGroup().name)]", "projectName": "[toLower(format('{0}{1}', parameters('firstProjectName'), variables('uniqueSuffix')))]", - "cosmosDBName": "[toLower(format('{0}{1}cosmosdb', parameters('aiServices'), variables('uniqueSuffix')))]", + "aiServicesSanitized": "[toLower(replace(parameters('aiServices'), '-', ''))]", + "storagePrefixMax": 18, + "storagePrefix": "[if(greater(length(variables('aiServicesSanitized')), variables('storagePrefixMax')), substring(variables('aiServicesSanitized'), 0, variables('storagePrefixMax')), variables('aiServicesSanitized'))]", + "azureStorageName": "[format('{0}{1}st', variables('storagePrefix'), variables('uniqueSuffix'))]", + "cosmosDBNameRaw": "[toLower(format('{0}{1}cosmosdb', parameters('aiServices'), variables('uniqueSuffix')))]", + "cosmosDBName": "[if(greater(length(variables('cosmosDBNameRaw')), 44), substring(variables('cosmosDBNameRaw'), 0, 44), variables('cosmosDBNameRaw'))]", "aiSearchName": "[toLower(format('{0}{1}search', parameters('aiServices'), variables('uniqueSuffix')))]", - "azureStorageName": "[toLower(format('{0}{1}storage', parameters('aiServices'), variables('uniqueSuffix')))]", "storagePassedIn": "[not(equals(parameters('azureStorageAccountResourceId'), ''))]", "searchPassedIn": "[not(equals(parameters('aiSearchResourceId'), ''))]", "cosmosPassedIn": "[not(equals(parameters('azureCosmosDBAccountResourceId'), ''))]", @@ -246,7 +275,8 @@ "vnetResourceGroupName": "[if(variables('existingVnetPassedIn'), variables('vnetParts')[4], resourceGroup().name)]", "existingVnetName": "[if(variables('existingVnetPassedIn'), last(variables('vnetParts')), parameters('vnetName'))]", "trimVnetName": "[trim(variables('existingVnetName'))]", - "resolvedDnsZonesSubscriptionId": "[if(empty(parameters('dnsZonesSubscriptionId')), subscription().subscriptionId, parameters('dnsZonesSubscriptionId'))]" + "normalizedDnsZonesSubscriptionId": "[if(empty(parameters('dnsZonesSubscriptionId')), '', if(startsWith(toLower(parameters('dnsZonesSubscriptionId')), '/subscriptions/'), split(parameters('dnsZonesSubscriptionId'), '/')[2], parameters('dnsZonesSubscriptionId')))]", + "resolvedDnsZonesSubscriptionId": "[if(empty(variables('normalizedDnsZonesSubscriptionId')), subscription().subscriptionId, variables('normalizedDnsZonesSubscriptionId'))]" }, "resources": [ { @@ -288,6 +318,9 @@ }, "existingVnetSubscriptionId": { "value": "[variables('vnetSubscriptionId')]" + }, + "reuseExistingSubnets": { + "value": "[parameters('reuseExistingSubnets')]" } }, "template": { @@ -296,8 +329,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.40.2.10011", - "templateHash": "9522150535342725348" + "version": "0.43.8.12551", + "templateHash": "1854398928287753773" } }, "parameters": { @@ -368,6 +401,13 @@ "metadata": { "description": "Address prefix for the private endpoint subnet" } + }, + "reuseExistingSubnets": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "When true and useExistingVnet=true, do NOT modify the existing subnets, reference them as-is." + } } }, "resources": [ @@ -410,8 +450,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.40.2.10011", - "templateHash": "1725519900292599397" + "version": "0.43.8.12551", + "templateHash": "6615847355060514198" } }, "parameters": { @@ -575,6 +615,9 @@ }, "peSubnetPrefix": { "value": "[parameters('peSubnetPrefix')]" + }, + "reuseExistingSubnets": { + "value": "[parameters('reuseExistingSubnets')]" } }, "template": { @@ -583,8 +626,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.40.2.10011", - "templateHash": "12834877200138369468" + "version": "0.43.8.12551", + "templateHash": "8011604849876491209" } }, "parameters": { @@ -635,10 +678,18 @@ "metadata": { "description": "Address prefix for the private endpoint subnet (only needed if creating new subnet)" } + }, + "reuseExistingSubnets": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "When true, do NOT modify the existing subnets, reference them as-is. Recommended when the caller manages subnet config (NSG/RT/PE policies) outside this template." + } } }, "resources": [ { + "condition": "[not(parameters('reuseExistingSubnets'))]", "type": "Microsoft.Resources/deployments", "apiVersion": "2025-04-01", "name": "[format('agent-subnet-{0}', uniqueString(deployment().name, parameters('agentSubnetName')))]", @@ -673,8 +724,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.40.2.10011", - "templateHash": "16684585228229443328" + "version": "0.43.8.12551", + "templateHash": "11349014942847443024" } }, "parameters": { @@ -729,6 +780,7 @@ } }, { + "condition": "[not(parameters('reuseExistingSubnets'))]", "type": "Microsoft.Resources/deployments", "apiVersion": "2025-04-01", "name": "[format('pe-subnet-{0}', uniqueString(deployment().name, parameters('peSubnetName')))]", @@ -756,8 +808,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.40.2.10011", - "templateHash": "16684585228229443328" + "version": "0.43.8.12551", + "templateHash": "11349014942847443024" } }, "parameters": { @@ -920,6 +972,12 @@ }, "agentSubnetId": { "value": "[reference(resourceId('Microsoft.Resources/deployments', format('vnet-{0}-{1}-deployment', variables('trimVnetName'), variables('uniqueSuffix'))), '2025-04-01').outputs.agentSubnetId.value]" + }, + "existingAccountResourceId": { + "value": "[parameters('existingAiFoundryAccountResourceId')]" + }, + "skipModelDeployment": { + "value": "[parameters('skipModelDeployment')]" } }, "template": { @@ -928,8 +986,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.40.2.10011", - "templateHash": "481468620289904255" + "version": "0.43.8.12551", + "templateHash": "2832622634761787610" } }, "parameters": { @@ -960,10 +1018,32 @@ "networkInjection": { "type": "string", "defaultValue": "true" + }, + "existingAccountResourceId": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Optional. Full ARM resource ID of an existing AI Foundry (CognitiveServices/accounts kind=AIServices) account to reuse. When set, the template will NOT create a new account." + } + }, + "skipModelDeployment": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Optional. When true, skip the model deployment. Recommended when reusing an existing account that already has the required model deployments." + } } }, + "variables": { + "useExistingAccount": "[not(empty(parameters('existingAccountResourceId')))]", + "existingParts": "[split(parameters('existingAccountResourceId'), '/')]", + "existingAccountSub": "[if(variables('useExistingAccount'), variables('existingParts')[2], subscription().subscriptionId)]", + "existingAccountRg": "[if(variables('useExistingAccount'), variables('existingParts')[4], resourceGroup().name)]", + "existingAccountName": "[if(variables('useExistingAccount'), last(variables('existingParts')), parameters('accountName'))]" + }, "resources": [ { + "condition": "[not(variables('useExistingAccount'))]", "type": "Microsoft.CognitiveServices/accounts", "apiVersion": "2025-04-01-preview", "name": "[parameters('accountName')]", @@ -990,6 +1070,7 @@ } }, { + "condition": "[and(not(variables('useExistingAccount')), not(parameters('skipModelDeployment')))]", "type": "Microsoft.CognitiveServices/accounts/deployments", "apiVersion": "2025-04-01-preview", "name": "[format('{0}/{1}', parameters('accountName'), parameters('modelName'))]", @@ -1012,19 +1093,19 @@ "outputs": { "accountName": { "type": "string", - "value": "[parameters('accountName')]" + "value": "[if(variables('useExistingAccount'), variables('existingAccountName'), parameters('accountName'))]" }, "accountID": { "type": "string", - "value": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('accountName'))]" + "value": "[if(variables('useExistingAccount'), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('existingAccountSub'), variables('existingAccountRg')), 'Microsoft.CognitiveServices/accounts', variables('existingAccountName')), resourceId('Microsoft.CognitiveServices/accounts', parameters('accountName')))]" }, "accountTarget": { "type": "string", - "value": "[reference(resourceId('Microsoft.CognitiveServices/accounts', parameters('accountName')), '2025-04-01-preview').endpoint]" + "value": "[if(variables('useExistingAccount'), reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('existingAccountSub'), variables('existingAccountRg')), 'Microsoft.CognitiveServices/accounts', variables('existingAccountName')), '2025-04-01-preview').endpoint, reference(resourceId('Microsoft.CognitiveServices/accounts', parameters('accountName')), '2025-04-01-preview').endpoint)]" }, "accountPrincipalId": { "type": "string", - "value": "[reference(resourceId('Microsoft.CognitiveServices/accounts', parameters('accountName')), '2025-04-01-preview', 'full').identity.principalId]" + "value": "[if(variables('useExistingAccount'), reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('existingAccountSub'), variables('existingAccountRg')), 'Microsoft.CognitiveServices/accounts', variables('existingAccountName')), '2025-04-01-preview', 'full').identity.principalId, reference(resourceId('Microsoft.CognitiveServices/accounts', parameters('accountName')), '2025-04-01-preview', 'full').identity.principalId)]" } } } @@ -1068,8 +1149,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.40.2.10011", - "templateHash": "13265653374239491155" + "version": "0.43.8.12551", + "templateHash": "680740366057404341" } }, "parameters": { @@ -1226,8 +1307,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.40.2.10011", - "templateHash": "1228922674432785221" + "version": "0.43.8.12551", + "templateHash": "14267398792843698911" } }, "parameters": { @@ -1509,8 +1590,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.40.2.10011", - "templateHash": "6108847882161636110" + "version": "0.43.8.12551", + "templateHash": "16240696292444732987" } }, "parameters": { @@ -2044,8 +2125,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.40.2.10011", - "templateHash": "1007872875847158534" + "version": "0.43.8.12551", + "templateHash": "5316230502381000043" } }, "parameters": { @@ -2219,8 +2300,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.40.2.10011", - "templateHash": "12606978205101260380" + "version": "0.43.8.12551", + "templateHash": "16292565234555392040" } }, "parameters": { @@ -2274,8 +2355,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.40.2.10011", - "templateHash": "15991712531324316353" + "version": "0.43.8.12551", + "templateHash": "10985949897065090550" } }, "parameters": { @@ -2332,8 +2413,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.40.2.10011", - "templateHash": "7256419427588810161" + "version": "0.43.8.12551", + "templateHash": "17331576029113373855" } }, "parameters": { @@ -2396,8 +2477,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.40.2.10011", - "templateHash": "15867124606695536257" + "version": "0.43.8.12551", + "templateHash": "15580039133385622424" } }, "parameters": { @@ -2446,6 +2527,81 @@ "[resourceId('Microsoft.Resources/deployments', format('{0}-private-endpoint', variables('uniqueSuffix')))]" ] }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "[format('account-caphost-{0}-deployment', variables('uniqueSuffix'))]", + "subscriptionId": "[variables('existingAccountSubscriptionId')]", + "resourceGroup": "[variables('existingAccountResourceGroupName')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "accountName": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('accountName'), variables('uniqueSuffix'))), '2025-04-01').outputs.accountName.value]" + }, + "agentSubnetResourceId": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', format('vnet-{0}-{1}-deployment', variables('trimVnetName'), variables('uniqueSuffix'))), '2025-04-01').outputs.agentSubnetId.value]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.43.8.12551", + "templateHash": "18033747207003499362" + } + }, + "parameters": { + "accountName": { + "type": "string", + "metadata": { + "description": "Name of the AI Foundry (Cognitive Services) account" + } + }, + "accountCapHost": { + "type": "string", + "defaultValue": "caphostacct", + "metadata": { + "description": "Name of the account-level capabilityHost" + } + }, + "agentSubnetResourceId": { + "type": "string", + "metadata": { + "description": "ARM resource ID of the customer agent subnet (delegated to Microsoft.App/environments)" + } + } + }, + "resources": [ + { + "type": "Microsoft.CognitiveServices/accounts/capabilityHosts", + "apiVersion": "2025-04-01-preview", + "name": "[format('{0}/{1}', parameters('accountName'), parameters('accountCapHost'))]", + "properties": { + "capabilityHostKind": "Agents", + "customerSubnet": "[parameters('agentSubnetResourceId')]" + } + } + ], + "outputs": { + "accountCapHostName": { + "type": "string", + "value": "[parameters('accountCapHost')]" + } + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('accountName'), variables('uniqueSuffix')))]", + "[resourceId('Microsoft.Resources/deployments', format('{0}-private-endpoint', variables('uniqueSuffix')))]", + "[resourceId('Microsoft.Resources/deployments', format('vnet-{0}-{1}-deployment', variables('trimVnetName'), variables('uniqueSuffix')))]" + ] + }, { "type": "Microsoft.Resources/deployments", "apiVersion": "2025-04-01", @@ -2481,8 +2637,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.40.2.10011", - "templateHash": "687161311666023487" + "version": "0.43.8.12551", + "templateHash": "9752585897450875181" } }, "parameters": { @@ -2538,6 +2694,7 @@ } }, "dependsOn": [ + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('existingAccountSubscriptionId'), variables('existingAccountResourceGroupName')), 'Microsoft.Resources/deployments', format('account-caphost-{0}-deployment', variables('uniqueSuffix')))]", "[resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('accountName'), variables('uniqueSuffix')))]", "[resourceId('Microsoft.Resources/deployments', format('dependencies-{0}-deployment', variables('uniqueSuffix')))]", "[resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('projectName'), variables('uniqueSuffix')))]", @@ -2575,8 +2732,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.40.2.10011", - "templateHash": "3625425571119261380" + "version": "0.43.8.12551", + "templateHash": "13556924300142468207" } }, "parameters": { @@ -2654,8 +2811,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.40.2.10011", - "templateHash": "14722474518981746838" + "version": "0.43.8.12551", + "templateHash": "9820545713493490823" } }, "parameters": { diff --git a/infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/diagrams/deployment_flow.png b/infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/diagrams/deployment_flow.png new file mode 100644 index 000000000..df9269567 Binary files /dev/null and b/infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/diagrams/deployment_flow.png differ diff --git a/infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/diagrams/layer1_network_foundation.png b/infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/diagrams/layer1_network_foundation.png new file mode 100644 index 000000000..18e1a36c6 Binary files /dev/null and b/infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/diagrams/layer1_network_foundation.png differ diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/diagrams/layer2_data_resources.png b/infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/diagrams/layer2_data_resources.png similarity index 100% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/diagrams/layer2_data_resources.png rename to infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/diagrams/layer2_data_resources.png diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/diagrams/layer3_ai_services.png b/infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/diagrams/layer3_ai_services.png similarity index 100% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/diagrams/layer3_ai_services.png rename to infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/diagrams/layer3_ai_services.png diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/diagrams/layer4_project_connections.png b/infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/diagrams/layer4_project_connections.png similarity index 100% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/diagrams/layer4_project_connections.png rename to infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/diagrams/layer4_project_connections.png diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/diagrams/layer5_capability_host.png b/infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/diagrams/layer5_capability_host.png similarity index 100% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/diagrams/layer5_capability_host.png rename to infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/diagrams/layer5_capability_host.png diff --git a/infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/main.bicep b/infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/main.bicep index 8c8a9a601..1c760fceb 100644 --- a/infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/main.bicep +++ b/infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/main.bicep @@ -62,8 +62,14 @@ param projectDescription string = 'A project for the AI Foundry account with net param displayName string = 'network secured agent project' // Existing Virtual Network parameters -@description('Virtual Network name for the Agent to create new or existing virtual network') -param vnetName string = 'agent-vnet-test' +// vnetName precedence + UX clarity. +// When existingVnetResourceId is set, vnetName is IGNORED and the actual name +// is derived from the resource ID (`last(vnetParts)`). The original default +// 'agent-vnet-test' was misleading: users who copied it as-is alongside +// existingVnetResourceId thought they were targeting a specific VNet but the +// resource ID won, hiding mistakes. Default is now empty. +@description('Virtual Network name. Required ONLY when creating a NEW VNet (existingVnetResourceId is empty). When existingVnetResourceId is set, this value is IGNORED, the name is derived from the resource ID. If you supply both they should match; otherwise the resource ID wins.') +param vnetName string = '' @description('The name of Agents Subnet to create new or existing subnet for agents') param agentSubnetName string = 'agent-subnet' @@ -84,6 +90,29 @@ param agentSubnetPrefix string = '' @description('Address prefix for the private endpoint subnet') param peSubnetPrefix string = '' +// Non-destructive subnet handling. +// Set to true when bringing your own pre-configured subnets (NSG/RT/PE policies +// already set by your platform team). Prevents the template from doing a PUT +// that would reset privateEndpointNetworkPolicies and trip tenant policies. +@description('When true and existingVnetResourceId is set, the template will NOT modify your existing subnets.') +param reuseExistingSubnets bool = false + +// True BYO Foundry account. +// When set, the template references the existing AI Foundry account instead of +// creating a new one with a deterministic suffix (which orphans on re-runs). +@description('Optional. Full ARM resource ID of an existing AI Foundry (CognitiveServices/accounts kind=AIServices) account to reuse. When set, the template will NOT create a new account.') +param existingAiFoundryAccountResourceId string = '' + +@description('Optional. When true, skip the model deployment. Recommended when reusing an existing account that already has the required model deployments.') +param skipModelDeployment bool = false + +// Re-derive BYO account context at main.bicep level so we can scope the +// account-level capabilityHost module to the right RG/subscription. +var useExistingAccount = !empty(existingAiFoundryAccountResourceId) +var existingAccountIdParts = split(existingAiFoundryAccountResourceId, '/') +var existingAccountSubscriptionId = useExistingAccount ? existingAccountIdParts[2] : subscription().subscriptionId +var existingAccountResourceGroupName = useExistingAccount ? existingAccountIdParts[4] : resourceGroup().name + @description('The AI Search Service full ARM Resource ID. This is an optional field, and if not provided, the resource will be created.') param aiSearchResourceId string = '' @description('The AI Storage Account full ARM Resource ID. This is an optional field, and if not provided, the resource will be created.') @@ -120,9 +149,20 @@ param dnsZoneNames array = [ var projectName = toLower('${firstProjectName}${uniqueSuffix}') -var cosmosDBName = toLower('${aiServices}${uniqueSuffix}cosmosdb') +// Sanitize aiServices for storage account name: lowercase, no hyphens, max 24 chars total. +// Reserve last 6 chars for `${uniqueSuffix}st` so uniqueness is preserved when prefix is truncated. +var aiServicesSanitized = toLower(replace(aiServices, '-', '')) +var storagePrefixMax = 18 // 24 total - 4 (uniqueSuffix) - 2 ('st' marker) +var storagePrefix = length(aiServicesSanitized) > storagePrefixMax + ? substring(aiServicesSanitized, 0, storagePrefixMax) + : aiServicesSanitized +var azureStorageName = '${storagePrefix}${uniqueSuffix}st' + +// Cosmos DB allows hyphens but enforces 44-char max. Cap defensively. +var cosmosDBNameRaw = toLower('${aiServices}${uniqueSuffix}cosmosdb') +var cosmosDBName = length(cosmosDBNameRaw) > 44 ? substring(cosmosDBNameRaw, 0, 44) : cosmosDBNameRaw + var aiSearchName = toLower('${aiServices}${uniqueSuffix}search') -var azureStorageName = toLower('${aiServices}${uniqueSuffix}storage') // Check if existing resources have been passed in var storagePassedIn = azureStorageAccountResourceId != '' @@ -149,8 +189,16 @@ var vnetResourceGroupName = existingVnetPassedIn ? vnetParts[4] : resourceGroup( var existingVnetName = existingVnetPassedIn ? last(vnetParts) : vnetName var trimVnetName = trim(existingVnetName) -// Resolve DNS zones subscription ID - use current subscription if not specified -var resolvedDnsZonesSubscriptionId = empty(dnsZonesSubscriptionId) ? subscription().subscriptionId : dnsZonesSubscriptionId +// Resolve DNS zones subscription ID - use current subscription if not specified. +// Accept either form: bare GUID or "/subscriptions/". +// The full ARM path form previously broke the existing-zone cross-sub references +// silently (the subscriptionId field needs the bare GUID). +var normalizedDnsZonesSubscriptionId = empty(dnsZonesSubscriptionId) + ? '' + : (startsWith(toLower(dnsZonesSubscriptionId), '/subscriptions/') + ? split(dnsZonesSubscriptionId, '/')[2] + : dnsZonesSubscriptionId) +var resolvedDnsZonesSubscriptionId = empty(normalizedDnsZonesSubscriptionId) ? subscription().subscriptionId : normalizedDnsZonesSubscriptionId @description('The name of the project capability host to be created') param projectCapHost string = 'caphostproj' @@ -169,6 +217,7 @@ module vnet 'modules-network-secured/network-agent-vnet.bicep' = { agentSubnetPrefix: agentSubnetPrefix peSubnetPrefix: peSubnetPrefix existingVnetSubscriptionId: vnetSubscriptionId + reuseExistingSubnets: reuseExistingSubnets } } @@ -187,6 +236,8 @@ module aiAccount 'modules-network-secured/ai-account-identity.bicep' = { modelSkuName: modelSkuName modelCapacity: modelCapacity agentSubnetId: vnet.outputs.agentSubnetId + existingAccountResourceId: existingAiFoundryAccountResourceId + skipModelDeployment: skipModelDeployment } } /* @@ -365,6 +416,21 @@ module aiSearchRoleAssignments 'modules-network-secured/ai-search-role-assignmen ] } +// Account-level capabilityHost (bootstraps before project caphost). +// The current sample relies on createCapHost.sh being run manually; making it +// declarative keeps the flow idempotent and works for both new and BYO accounts. +module addAccountCapabilityHost 'modules-network-secured/add-account-capability-host.bicep' = { + name: 'account-caphost-${uniqueSuffix}-deployment' + scope: resourceGroup(existingAccountSubscriptionId, existingAccountResourceGroupName) + params: { + accountName: aiAccount.outputs.accountName + agentSubnetResourceId: vnet.outputs.agentSubnetId + } + dependsOn: [ + privateEndpointAndDNS + ] +} + // This module creates the capability host for the project and account module addProjectCapabilityHost 'modules-network-secured/add-project-capability-host.bicep' = { name: 'capabilityHost-configuration-${uniqueSuffix}-deployment' @@ -377,6 +443,7 @@ module addProjectCapabilityHost 'modules-network-secured/add-project-capability- projectCapHost: projectCapHost } dependsOn: [ + addAccountCapabilityHost // account caphost must exist first aiSearch // Ensure AI Search exists storage // Ensure Storage exists cosmosDB diff --git a/infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/modules-network-secured/add-account-capability-host.bicep b/infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/modules-network-secured/add-account-capability-host.bicep new file mode 100644 index 000000000..d4e502eb6 --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/modules-network-secured/add-account-capability-host.bicep @@ -0,0 +1,40 @@ +// Account-level capabilityHost. +// +// The current template ONLY creates the project-level capabilityHost in Bicep +// and relies on the `createCapHost.sh` script being run manually before +// deployment to bootstrap the account-level capabilityHost. When the account +// is BYO (already-existing Foundry account that never had the script run), +// the project capability host creation fails with: +// "Foundry Account capabilityHost Not Found, please retry again after +// creating capabilityHost for the Foundry Account." +// +// This module bootstraps the account-level capabilityHost in Bicep so the +// flow is fully declarative and idempotent regardless of whether the account +// is freshly created or pre-existing. + +@description('Name of the AI Foundry (Cognitive Services) account') +param accountName string + +@description('Name of the account-level capabilityHost') +param accountCapHost string = 'caphostacct' + +@description('ARM resource ID of the customer agent subnet (delegated to Microsoft.App/environments)') +param agentSubnetResourceId string + +resource account 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { + name: accountName +} + +resource accountCapabilityHost 'Microsoft.CognitiveServices/accounts/capabilityHosts@2025-04-01-preview' = { + name: accountCapHost + parent: account + properties: { + // Bicep type defs reject this property; ARM API requires it. + #disable-next-line BCP037 + capabilityHostKind: 'Agents' + #disable-next-line BCP037 + customerSubnet: agentSubnetResourceId + } +} + +output accountCapHostName string = accountCapabilityHost.name diff --git a/infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/modules-network-secured/add-project-capability-host.bicep b/infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/modules-network-secured/add-project-capability-host.bicep index dd2ac3297..e5a001a33 100644 --- a/infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/modules-network-secured/add-project-capability-host.bicep +++ b/infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/modules-network-secured/add-project-capability-host.bicep @@ -23,6 +23,11 @@ resource projectCapabilityHost 'Microsoft.CognitiveServices/accounts/projects/ca name: projectCapHost parent: project properties: { + // Bicep type definitions for capabilityHosts are stale and reject + // `capabilityHostKind`, but the ARM API REQUIRES it (without it the + // capability host is created with no kind and downstream agents fail). + // Suppressing the false-positive BCP037 since runtime validation passes. + #disable-next-line BCP037 capabilityHostKind: 'Agents' vectorStoreConnections: vectorStoreConnections storageConnections: storageConnections diff --git a/infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/modules-network-secured/ai-account-identity.bicep b/infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/modules-network-secured/ai-account-identity.bicep index 47f72a3be..e3576f931 100644 --- a/infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/modules-network-secured/ai-account-identity.bicep +++ b/infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/modules-network-secured/ai-account-identity.bicep @@ -8,8 +8,24 @@ param modelCapacity int param agentSubnetId string param networkInjection string = 'true' +// True BYO Foundry account. +// When existingAccountResourceId is set, reference the existing AI Foundry +// (Cognitive Services AIServices kind) account instead of creating a new one +// with a deterministic suffix (which orphans on re-runs and collides on conflict). +@description('Optional. Full ARM resource ID of an existing AI Foundry (CognitiveServices/accounts kind=AIServices) account to reuse. When set, the template will NOT create a new account.') +param existingAccountResourceId string = '' + +@description('Optional. When true, skip the model deployment. Recommended when reusing an existing account that already has the required model deployments.') +param skipModelDeployment bool = false + +var useExistingAccount = !empty(existingAccountResourceId) +var existingParts = split(existingAccountResourceId, '/') +var existingAccountSub = useExistingAccount ? existingParts[2] : subscription().subscriptionId +var existingAccountRg = useExistingAccount ? existingParts[4] : resourceGroup().name +var existingAccountName = useExistingAccount ? last(existingParts) : accountName + #disable-next-line BCP036 -resource account 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' = { +resource account 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' = if (!useExistingAccount) { name: accountName location: location sku: { @@ -40,8 +56,14 @@ resource account 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' = { } } +// Reference to existing account (cross-RG / cross-sub aware) +resource existingAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { + name: existingAccountName + scope: resourceGroup(existingAccountSub, existingAccountRg) +} + #disable-next-line BCP081 -resource modelDeployment 'Microsoft.CognitiveServices/accounts/deployments@2025-04-01-preview'= { +resource modelDeployment 'Microsoft.CognitiveServices/accounts/deployments@2025-04-01-preview' = if (!useExistingAccount && !skipModelDeployment) { parent: account name: modelName sku : { @@ -57,7 +79,8 @@ resource modelDeployment 'Microsoft.CognitiveServices/accounts/deployments@2025- } } -output accountName string = account.name -output accountID string = account.id -output accountTarget string = account.properties.endpoint -output accountPrincipalId string = account.identity.principalId +// Outputs use ARM short-circuit ternary so only the chosen branch is evaluated. +output accountName string = useExistingAccount ? existingAccount.name : account.name +output accountID string = useExistingAccount ? existingAccount.id : account.id +output accountTarget string = useExistingAccount ? existingAccount.properties.endpoint : account.properties.endpoint +output accountPrincipalId string = useExistingAccount ? existingAccount.identity.principalId : account.identity.principalId diff --git a/infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/modules-network-secured/existing-vnet.bicep b/infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/modules-network-secured/existing-vnet.bicep index b371d61e5..c25317e05 100644 --- a/infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/modules-network-secured/existing-vnet.bicep +++ b/infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/modules-network-secured/existing-vnet.bicep @@ -35,6 +35,18 @@ param agentSubnetPrefix string = '' @description('Address prefix for the private endpoint subnet (only needed if creating new subnet)') param peSubnetPrefix string = '' +// Non-destructive subnet handling. +// When the caller already has correctly-configured subnets (delegations, NSGs, +// route tables, privateEndpointNetworkPolicies), the original template would +// PUT a slim subnet body (only addressPrefix + delegations) and ARM would +// silently RESET privateEndpointNetworkPolicies (and clobber NSG/RT references) +// to defaults. In tenants that enforce a policy on those properties, this +// fails with RequestDisallowedByPolicy. +// When reuseExistingSubnets=true we skip the subnet PUT entirely and just +// reference the existing subnet IDs in the outputs. +@description('When true, do NOT modify the existing subnets, reference them as-is. Recommended when the caller manages subnet config (NSG/RT/PE policies) outside this template.') +param reuseExistingSubnets bool = false + // Get the address space (array of CIDR strings) var vnetAddressSpace = existingVNet.properties.addressSpace.addressPrefixes[0] @@ -48,7 +60,7 @@ resource existingVNet 'Microsoft.Network/virtualNetworks@2024-05-01' existing = } // Create the agent subnet if requested -module agentSubnet 'subnet.bicep' = { +module agentSubnet 'subnet.bicep' = if (!reuseExistingSubnets) { name: 'agent-subnet-${uniqueString(deployment().name, agentSubnetName)}' scope: resourceGroup(vnetResourceGroupName) params: { @@ -67,7 +79,7 @@ module agentSubnet 'subnet.bicep' = { } // Create the private endpoint subnet if requested -module peSubnet 'subnet.bicep' = { +module peSubnet 'subnet.bicep' = if (!reuseExistingSubnets) { name: 'pe-subnet-${uniqueString(deployment().name, peSubnetName)}' scope: resourceGroup(vnetResourceGroupName) params: { diff --git a/infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/modules-network-secured/network-agent-vnet.bicep b/infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/modules-network-secured/network-agent-vnet.bicep index bad8a4f27..87aaf5407 100644 --- a/infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/modules-network-secured/network-agent-vnet.bicep +++ b/infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/modules-network-secured/network-agent-vnet.bicep @@ -28,6 +28,10 @@ param agentSubnetPrefix string = '' @description('Address prefix for the private endpoint subnet') param peSubnetPrefix string = '' +// Non-destructive subnet handling. See existing-vnet.bicep. +@description('When true and useExistingVnet=true, do NOT modify the existing subnets, reference them as-is.') +param reuseExistingSubnets bool = false + // Create new VNet if needed module newVNet 'vnet.bicep' = if (!useExistingVnet) { name: 'vnet-deployment' @@ -53,6 +57,7 @@ module existingVNet 'existing-vnet.bicep' = if (useExistingVnet) { peSubnetName: peSubnetName agentSubnetPrefix: agentSubnetPrefix peSubnetPrefix: peSubnetPrefix + reuseExistingSubnets: reuseExistingSubnets } } diff --git a/infrastructure/infrastructure-setup-bicep/15a-private-network-evaluation-only-setup/README.md b/infrastructure/infrastructure-setup-bicep/15a-private-network-evaluation-only-setup/README.md index cffad0739..d175061bc 100644 --- a/infrastructure/infrastructure-setup-bicep/15a-private-network-evaluation-only-setup/README.md +++ b/infrastructure/infrastructure-setup-bicep/15a-private-network-evaluation-only-setup/README.md @@ -1,5 +1,5 @@ --- -description: This set of templates demonstrates how to set up a network-secured Azure AI Foundry environment for evaluation scenarios without Cosmos DB, AI Search, or project capability host. +description: This set of templates demonstrates how to set up a network-secured Microsoft Foundry environment for evaluation scenarios without Cosmos DB, AI Search, or project capability host. page_type: sample products: - azure @@ -10,7 +10,11 @@ languages: - json --- -# Azure AI Foundry: Evaluation-Only Setup with Private Network Isolation +# Microsoft Foundry: Evaluation-Only Setup with Network Isolation + + +> **NEW** +> For support on deploying the right network isolation template, check out the [GitHub Copilot for Azure skill for private networking](https://github.com/microsoft/GitHub-Copilot-for-Azure/blob/main/plugin/skills/microsoft-foundry/resource/private-network/private-network.md) set-up! > **IMPORTANT** > @@ -18,7 +22,7 @@ languages: --- ## Overview -This infrastructure-as-code (IaC) solution deploys a **minimal** network-secured Azure AI Foundry environment with private networking and role-based access control (RBAC), intended for evaluation and testing purposes. +This infrastructure-as-code (IaC) solution deploys a **minimal** network-secured Microsoft Foundry environment with private networking and role-based access control (RBAC), intended for evaluation and testing purposes. Unlike the full standard agent setup, this template: - **Does NOT** create an Azure Cosmos DB account (no thread/conversation storage) @@ -35,23 +39,51 @@ What it **does** deploy: --- -## Key Information +## When to Use This Template + +Use this template when you need: +- **Evaluation and testing only** β€” Run model evaluations without deploying full agent infrastructure +- **Minimal private networking** β€” Private endpoints for AI Services and Storage only (no Cosmos DB or AI Search) +- **Lower cost** β€” Fewer resources deployed compared to the full standard agent setup +- **Quick iteration** β€” Faster deployment time with a smaller resource footprint + +### Template Decision Guide + +Use the table below to choose the right infrastructure template for your scenario: + +| Template | Agent Type | Networking | Identity | Key Use Case | +|----------|-----------|------------|----------|-------------| +| [**15a** (this template)](../15a-private-network-evaluation-only-setup/) | Evaluation only | BYO VNet + Private Endpoints | System Assigned MI | Minimal setup for evaluation β€” no Cosmos DB, AI Search, or capability host | +| [**15**](../15-private-network-standard-agent-setup/) | Standard (BYO resources) | BYO VNet + Private Endpoints | System Assigned MI | E2E network isolation with full agent capabilities | +| [**19**](../19-private-network-agent-tools/) | Standard (BYO resources) | BYO VNet + Private Endpoints | System Assigned MI | Same as 15 **plus** tools behind VNet (MCP, OpenAPI, Functions, A2A) | +| [**17**](../17-private-network-standard-user-assigned-identity-agent-setup/) | Standard (BYO resources) | BYO VNet + Private Endpoints | **User Assigned MI** | Same as 15 but with user-managed identity | +| [**16**](../16-private-network-standard-agent-apim-setup-preview/) | Standard (BYO resources) | BYO VNet + Private Endpoints | System Assigned MI | Same as 15 **plus** private APIM integration (preview) | +| [**18**](../18-managed-virtual-network-preview/) | Standard (BYO resources) | **Managed VNet** (Microsoft-managed) | System Assigned MI | Network isolation without managing your own VNet (preview) | +| [**11**](../11-private-network-basic-vnet/) | **Basic** (platform-managed) | BYO VNet injection | System Assigned MI | Basic agents with VNet isolation β€” no BYO resources needed | +| [**41**](../41-standard-agent-setup/) | Standard (BYO resources) | **Public** (no VNet) | System Assigned MI | Standard agents without network isolation | +| [**40**](../40-basic-agent-setup/) | **Basic** (platform-managed) | **Public** (no VNet) | System Assigned MI | Simplest setup β€” no BYO resources, no private networking | + +--- + +## Deploy to Azure -**Region and Resource Placement Requirements** -- **All Foundry workspace resources should be in the same region as the VNet**, including the Storage Account, Foundry Account, Project, and Managed Identity. The only exception is within the Foundry Account, you may choose to deploy your model to a different region. - - **Note:** Your Virtual Network can be in a different resource group than your Foundry workspace resources. + +[![Deploy To Azure](https://raw.githubusercontent.com/Azure/azure-quickstart-templates/master/1-CONTRIBUTION-GUIDE/images/deploytoazure.svg?sanitize=true)](https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fraw.githubusercontent.com%2Fazure-ai-foundry%2Ffoundry-samples%2Frefs%2Fheads%2Fmain%2Finfrastructure%2Finfrastructure-setup-bicep%2F15a-private-network-evaluation-only-setup%2Fazuredeploy.json) --- ## Prerequisites 1. **Active Azure subscription with appropriate permissions** - - **Azure AI Account Owner**: Needed to create a cognitive services account and project - - **Owner or Role Based Access Administrator**: Needed to assign RBAC to the storage account - - **Azure AI User**: Needed to create and use evaluation workloads + - **Foundry Account Owner**: Needed to create the Microsoft Foundry account and project. + - **Owner or Role Based Access Administrator**: Needed to assign RBAC on the Azure resources used by this template. + - **Foundry User**: Needed to create and use agents, projects, or evaluation workloads after deployment. + In this template, the required RBAC assignments apply only to the storage account. 1. **Register Resource Providers** + Make sure you have an active Azure subscription that allows registering resource providers. For example, subnet delegation requires the Microsoft.App provider to be registered in your subscription. If it's not already registered, run the commands below: + ```bash az provider register --namespace 'Microsoft.KeyVault' az provider register --namespace 'Microsoft.CognitiveServices' @@ -63,8 +95,8 @@ What it **does** deploy: 1. Network administrator permissions (if operating in a restricted or enterprise environment) -1. Sufficient quota for all resources in your target Azure region - * If no parameters are passed in, this template creates an Azure AI Foundry resource, Foundry project, and Azure Storage account +1. Sufficient quota for all resources required by this template in the target Azure region, including model deployment quota. + * If no parameters are passed in, this template creates an Microsoft Foundry resource, Foundry project, and Azure Storage account 1. Azure CLI installed and configured on your local workstation or deployment pipeline server @@ -73,43 +105,80 @@ What it **does** deploy: ## Pre-Deployment Steps ### Networking Requirements -1. Review network requirements and plan Virtual Network address space (e.g., 192.168.0.0/16) +1. Review network requirements and plan Virtual Network address space (e.g., 192.168.0.0/16 or an alternative non-overlapping address space) 2. Two subnets are needed: - - **Agent Subnet** (e.g., 192.168.0.0/24): Hosts Agent client for workloads, delegated to Microsoft.App/environments + - **Agent Subnet** (e.g., 192.168.0.0/24): Hosts Agent client for workloads, delegated to Microsoft.App/environments. The recommended size should be /24 for this delegated subnet. - **Private endpoint Subnet** (e.g., 192.168.1.0/24): Hosts private endpoints - - Ensure that the address spaces do not overlap with any existing networks + - Ensure that the address spaces for the used VNET does not overlap with any existing networks in your Azure environment or reserved IP ranges like the following: 169.254.0.0/16,172.30.0.0/16,172.31.0.0/16,192.0.2.0/24,0.0.0.0/8,127.0.0.0/8,100.100.0.0/17,100.100.192.0/19,100.100.224.0/19,100.64.0.0/11. + This includes all address space(s) you have in your VNET if you have more than one, and peered VNETs. > **Notes:** - If you do not provide an existing virtual network, the template will create a new virtual network with the default address spaces and subnets described above. - You must ensure the subnet is not already in use by another account. - - You must ensure the subnet is exclusively delegated to __Microsoft.App/environments__. + - You must ensure the subnet is exclusively delegated to __Microsoft.App/environments__ and cannot be used by any other Azure resources. + - Your Foundry resource and the virtual network created for delegation must be in the same region. ---- +### Limitations / Known Issues -## Template Customization +1. The delegated agent subnet must be exclusively used by a single Foundry account. It cannot be shared across accounts. +2. The Foundry resource and the virtual network must be in the same Azure region. +3. For the virtual network IP range, you may use any Private Class A, B or C IP range. Private Class A IP address ranges (10.x.x.x) are only supported in the following regions: **Australia East, Brazil South, Canada East, East US, East US 2, France Central, Germany West Central, Italy North, Japan East, South Africa North, South Central US, South India, Spain Central, Sweden Central, UAE North, UK South, West US, West US 3.** Use Class B (172.16.x.x) or C (192.168.x.x) ranges for other regions. You may not use any other IP range that overlaps to the list above or uses public IP ranges. +4. This template does **not** deploy Cosmos DB, AI Search, or a project capability host. Stateful agents are not supported. Use [template 15](../15-private-network-standard-agent-setup/) for full agent capabilities. +5. There is no upgrade path from this evaluation template to the full standard agent setup. A redeployment with template 15 is required. + +### Template Customization Note: If not provided, the following resources will be created automatically for you: - VNet and two subnets - Azure Storage -### Parameters +#### Parameters + +| Parameter | Description | Default | Required | +|-----------|-------------|---------|----------| +| `location` | Azure region for deployment | `eastus` | Yes | +| `aiServices` | Base name for the AI Services resource | `aiservices` | No | +| `firstProjectName` | Name for the Foundry project | `project` | No | +| `modelName` | Model to deploy | `gpt-4.1` | No | +| `modelFormat` | Model provider | `OpenAI` | No | +| `modelVersion` | Model version | `2025-04-14` | No | +| `modelSkuName` | Model deployment SKU | `GlobalStandard` | No | +| `modelCapacity` | Tokens per minute (TPM) capacity | `30` | No | +| `vnetName` | Virtual Network name | `agent-vnet-test` | No | +| `agentSubnetName` | Subnet name for agent workloads | `agent-subnet` | No | +| `agentSubnetPrefix` | Address prefix for agent subnet | `192.168.0.0/24` | No | +| `peSubnetName` | Subnet name for private endpoints | `pe-subnet` | No | +| `peSubnetPrefix` | Address prefix for PE subnet | `192.168.1.0/24` | No | +| `existingVnetResourceId` | Full ARM Resource ID of an existing VNet | `''` (creates new) | No | +| `vnetAddressPrefix` | Address space for new VNet | `192.168.0.0/16` | No | +| `azureStorageAccountResourceId` | ARM Resource ID of existing Storage account | `''` (creates new) | No | +| `dnsZonesSubscriptionId` | Subscription ID for existing DNS zones | `''` (current sub) | No | +| `existingDnsZones` | Map of DNS zone names to resource groups | All empty (creates new) | No | + +#### BYO Resource Details 1. **Use Existing Virtual Network and Subnets** To use an existing VNet and subnets, set the `existingVnetResourceId` parameter to the full Azure Resource ID of the target VNet: -``` -param existingVnetResourceId = "/subscriptions//resourceGroups//providers/Microsoft.Network/virtualNetworks/" -param agentSubnetName string = 'agent-subnet' -param peSubnetName string = 'pe-subnet' -``` +- param existingVnetResourceId = "/subscriptions//resourceGroups//providers/Microsoft.Network/virtualNetworks/" +- param agentSubnetName string = 'agent-subnet' //optional, default is 'agent-subnet' +- param agentSubnetPrefix string = '192.168.0.0/24' //optional, default is '192.168.0.0/24' +- param peSubnetName string = 'pe-subnet' //optional, default is 'pe-subnet' +- param peSubnetPrefix string = '192.168.1.0/24' //optional, default is '192.168.1.0/24' +- param dnsZonesSubscriptionId string = '' //optional, leave empty to use current subscription +- param existingDnsZones = { + + 'privatelink.services.ai.azure.com': 'privzoneRG' //add resource group name where your private DNS zone is located + + 'privatelink.openai.azure.com': '' //Leave empty to create new private dns zone... } + +πŸ’‘ If subnets information is provided then make sure it exist within the specified VNet to avoid deployment errors. If subnet information is not provided, the template will create subnets with the default address space. 2. **Use an existing Azure Storage account** -To use an existing Azure Storage account: -``` -param azureStorageAccountResourceId string = /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Storage/storageAccounts/{storageAccountName} -``` +To use an existing Azure Storage account, set the azureStorageAccountResourceId parameter to the full Azure resource Id: +- param azureStorageAccountResourceId string = /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Storage/storageAccounts/{storageAccountName} --- @@ -127,42 +196,135 @@ param azureStorageAccountResourceId string = /subscriptions/{subscriptionId}/res az deployment group create --resource-group --template-file main.bicep --parameters main.bicepparam ``` -> **Note:** To access your Foundry resource securely, use either a VM, VPN, or ExpressRoute. +> **Note:** To access a private Foundry resource securely, use one of the following: +> - A VM or jump box on the virtual network, optionally accessed through Azure Bastion +> - Azure VPN Gateway +> - Azure ExpressRoute + +### Cleanup + +To delete all resources created by this template: + +```bash +az group delete --name --yes --no-wait +``` --- -## Architecture +## Architecture Deep Dive + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Secure Access (VPN Gateway / ExpressRoute / Azure Bastion) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Microsoft Foundry β”‚ + β”‚ (publicNetworkAccess: β”‚ + β”‚ DISABLED) β”‚ + β”‚ β”‚ + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ + β”‚ β”‚ Foundry Project β”‚ β”‚ + β”‚ β”‚ (Evaluation Only) β”‚ β”‚ + β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ Subnet Delegation + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ BYO Virtual Network β”‚ + β”‚ (192.168.0.0/16) β”‚ + β”‚ β”‚ + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ + β”‚ β”‚ Agent Subnet β”‚ β”‚ + β”‚ β”‚ (192.168.0.0/24) β”‚ β”‚ ◄── Delegated to + β”‚ β”‚ Microsoft.App/envs β”‚ β”‚ Microsoft.App/environments + β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β”‚ β”‚ + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ + β”‚ β”‚ PE Subnet β”‚ β”‚ + β”‚ β”‚ (192.168.1.0/24) β”‚ β”‚ + β”‚ β”‚ β”‚ β”‚ + β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ + β”‚ β”‚ β”‚Storage β”‚ β”‚Foundry β”‚ β”‚ β”‚ ◄── Private endpoints + β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ (no public access) + β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` ### Azure Resources Created -| Resource | Type | Description | -|----------|------|-------------| -| Azure AI Foundry | `Microsoft.CognitiveServices/accounts` | AI Services account with disabled public access | -| AI Model Deployment | `Microsoft.CognitiveServices/accounts/deployments` | Model deployment (e.g., gpt-4.1) | -| Foundry Project | `Microsoft.CognitiveServices/accounts/projects` | Project with system-assigned managed identity | -| Storage Account | `Microsoft.Storage/storageAccounts` | StorageV2 with disabled public access | -| Virtual Network | `Microsoft.Network/virtualNetworks` | VNet with agent and PE subnets | -| Private Endpoints | `Microsoft.Network/privateEndpoints` | For AI Services and Storage | +Microsoft Foundry (Cognitive Services) +- Type: Microsoft.CognitiveServices/accounts +- API version: 2025-04-01-preview +- Kind: AIServices +- SKU: S0 +- Identity: System-assigned +- Features: + - Custom subdomain name + - Disabled public network access + - Network ACLs with Azure Services bypass + +AI Model Deployment +- Type: Microsoft.CognitiveServices/accounts/deployments +- API version: 2025-04-01-preview +- SKU: Based on modelSkuName parameter, capacity set by modelCapacity +- Model properties: + - Name: From modelName parameter + - Format: From modelFormat parameter + - Version: From modelVersion parameter + +Foundry Project +- Type: Microsoft.CognitiveServices/accounts/projects +- Identity: System-assigned managed identity +- Connections: Storage account only (no Cosmos DB or AI Search connections) + +Storage Account +- Type: Microsoft.Storage/storageAccounts +- Kind: StorageV2 +- SKU: ZRS or GRS (region dependent; use Standard_GRS if ZRS not available) +- Features: + - Blob service + - Minimum TLS Version: 1.2 + - Block public blob access + - Disabled public network access + - Force Azure AD authentication (SharedKey access disabled) ### Network Security Design -**Private Endpoints** are created for: -- Azure AI Foundry (account) +Network Security +- Public network access disabled +- Private endpoints for AI Services and Storage +- Network ACLs with deny by default + +**Network Infrastructure** +- A Virtual Network (192.168.0.0/16) is created (if existing isn't passed in) +- Agent Subnet (192.168.0.0/24): Hosts Agent client +- Private endpoint Subnet (192.168.1.0/24): Hosts private endpoints + +**Private Endpoints** +Private endpoints ensure secure, internal-only connectivity. Private endpoints are created for: +- Microsoft Foundry (account) - Azure Storage (blob) -**Private DNS Zones**: -| Private Link Resource Type | Sub Resource | Private DNS Zone Name | -|----------------------------|--------------|------------------------| -| **Azure AI Foundry** | account | `privatelink.cognitiveservices.azure.com`
`privatelink.openai.azure.com`
`privatelink.services.ai.azure.com` | -| **Azure Storage** | blob | `privatelink.blob.core.windows.net` | +**Private DNS Zones** +| Private Link Resource Type | Sub Resource | Private DNS Zone Name | Public DNS Zone Forwarders | +|----------------------------|--------------|------------------------|-----------------------------| +| **Microsoft Foundry** | account | `privatelink.cognitiveservices.azure.com`
`privatelink.openai.azure.com`
`privatelink.services.ai.azure.com` | `cognitiveservices.azure.com`
`openai.azure.com`
`services.ai.azure.com` | +| **Azure Storage** | blob | `privatelink.blob.core.windows.net` | `blob.core.windows.net` | + +### Authentication & Authorization -### Role Assignments +- **Managed Identity** + - Zero-trust security model + - No credential storage + - Platform-managed rotation + - This template uses System Managed Identity -- **AI Services Account** - - Azure AI User (`53ca6127-db72-4b80-b1b0-d745d6d5456d`) β€” grants the project MI data-plane access -- **Azure Storage Account** - - Storage Blob Data Contributor (`ba92f5b4-2d11-453d-a403-e96b0029c9fe`) - - Storage Blob Data Owner (`b7e6dc6d-f1e8-4753-8033-0f276bb0955b`) β€” scoped to project containers +- **Role Assignments** + - **AI Services Account** + - Foundry User (`53ca6127-db72-4b80-b1b0-d745d6d5456d`) β€” grants the project MI data-plane access + - **Azure Storage Account** + - Storage Blob Data Contributor (`ba92f5b4-2d11-453d-a403-e96b0029c9fe`) + - Storage Blob Data Owner (`b7e6dc6d-f1e8-4753-8033-0f276bb0955b`) β€” scoped to project containers --- @@ -170,8 +332,8 @@ param azureStorageAccountResourceId string = /subscriptions/{subscriptionId}/res ```text modules-network-secured/ -β”œβ”€β”€ ai-account-identity.bicep # Azure AI Foundry deployment and configuration -β”œβ”€β”€ ai-account-role-assignment.bicep # Azure AI User role assignment on the account +β”œβ”€β”€ ai-account-identity.bicep # Microsoft Foundry deployment and configuration +β”œβ”€β”€ ai-account-role-assignment.bicep # Foundry User role assignment on the account β”œβ”€β”€ ai-project-identity.bicep # Foundry project deployment with storage connection β”œβ”€β”€ azure-storage-account-role-assignment.bicep # Storage Account RBAC configuration β”œβ”€β”€ blob-storage-container-role-assignments.bicep # Blob Storage Container RBAC configuration @@ -185,24 +347,14 @@ modules-network-secured/ └── vnet.bicep # Deploying a new virtual network ``` ---- - -## Comparison with Standard Agent Setup - -| Feature | This Template (Evaluation-Only) | Standard Agent Setup (15) | -|---------|-------------------------------|--------------------------| -| AI Services + Model | βœ… | βœ… | -| Project | βœ… | βœ… | -| Storage Account | βœ… | βœ… | -| VNet + Private Endpoints | βœ… (AI + Storage) | βœ… (AI + Storage + Search + Cosmos) | -| Cosmos DB | ❌ | βœ… | -| AI Search | ❌ | βœ… | -| Project Capability Host | ❌ | βœ… | -| Stateful Agents | ❌ | βœ… | +## Maintenance ---- +### Regular Tasks -## Maintenance +1. Review role assignments +2. Monitor network security +3. Check service health +4. Update configurations as needed ### Troubleshooting @@ -210,3 +362,11 @@ modules-network-secured/ 2. Check DNS resolution 3. Validate role assignments 4. Review network security groups + +## References + +- [Configure private link for Microsoft Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/configure-private-link?tabs=azure-portal&pivots=fdp-project) +- [Microsoft Foundry RBAC roles](https://learn.microsoft.com/en-us/azure/ai-foundry/concepts/rbac-azure-ai-foundry?pivots=fdp-project) +- [Private Endpoint documentation](https://learn.microsoft.com/en-us/azure/private-link/) +- [Azure RBAC documentation](https://learn.microsoft.com/en-us/azure/role-based-access-control/) +- [Network security best practices](https://learn.microsoft.com/en-us/azure/security/fundamentals/network-best-practices) diff --git a/infrastructure/infrastructure-setup-bicep/15a-private-network-evaluation-only-setup/azuredeploy.json b/infrastructure/infrastructure-setup-bicep/15a-private-network-evaluation-only-setup/azuredeploy.json index 58cc95a6a..c161879c6 100644 --- a/infrastructure/infrastructure-setup-bicep/15a-private-network-evaluation-only-setup/azuredeploy.json +++ b/infrastructure/infrastructure-setup-bicep/15a-private-network-evaluation-only-setup/azuredeploy.json @@ -4,8 +4,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.42.1.51946", - "templateHash": "3823237790915182165" + "version": "0.43.8.12551", + "templateHash": "16354343630624836697" } }, "parameters": { @@ -261,8 +261,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.42.1.51946", - "templateHash": "17284036880023313732" + "version": "0.43.8.12551", + "templateHash": "11296469007124090352" } }, "parameters": { @@ -375,8 +375,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.42.1.51946", - "templateHash": "7044568158736020946" + "version": "0.43.8.12551", + "templateHash": "6615847355060514198" } }, "parameters": { @@ -548,8 +548,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.42.1.51946", - "templateHash": "9399102347973763543" + "version": "0.43.8.12551", + "templateHash": "8341621792914014201" } }, "parameters": { @@ -638,8 +638,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.42.1.51946", - "templateHash": "8293031405760656222" + "version": "0.43.8.12551", + "templateHash": "11349014942847443024" } }, "parameters": { @@ -721,8 +721,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.42.1.51946", - "templateHash": "8293031405760656222" + "version": "0.43.8.12551", + "templateHash": "11349014942847443024" } }, "parameters": { @@ -893,8 +893,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.42.1.51946", - "templateHash": "1167841404925031150" + "version": "0.43.8.12551", + "templateHash": "1400943577065869287" } }, "parameters": { @@ -1027,8 +1027,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.42.1.51946", - "templateHash": "11208930521589341070" + "version": "0.43.8.12551", + "templateHash": "14681023185116728141" } }, "parameters": { @@ -1120,8 +1120,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.42.1.51946", - "templateHash": "4263282633179188093" + "version": "0.43.8.12551", + "templateHash": "12551648450543487356" } }, "parameters": { @@ -1257,8 +1257,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.42.1.51946", - "templateHash": "14317702821708481391" + "version": "0.43.8.12551", + "templateHash": "4199539764441797485" } }, "parameters": { @@ -1596,8 +1596,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.42.1.51946", - "templateHash": "15731446090362490111" + "version": "0.43.8.12551", + "templateHash": "17079120784923696162" } }, "parameters": { @@ -1709,8 +1709,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.42.1.51946", - "templateHash": "5729541472937730744" + "version": "0.43.8.12551", + "templateHash": "16292565234555392040" } }, "parameters": { @@ -1764,8 +1764,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.42.1.51946", - "templateHash": "6805612394357697185" + "version": "0.43.8.12551", + "templateHash": "10985949897065090550" } }, "parameters": { @@ -1820,8 +1820,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.42.1.51946", - "templateHash": "2328134986536572193" + "version": "0.43.8.12551", + "templateHash": "4519973268515204861" } }, "parameters": { @@ -1887,8 +1887,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.42.1.51946", - "templateHash": "14861833638743424490" + "version": "0.43.8.12551", + "templateHash": "13556924300142468207" } }, "parameters": { diff --git a/infrastructure/infrastructure-setup-bicep/15a-private-network-evaluation-only-setup/main.bicep b/infrastructure/infrastructure-setup-bicep/15a-private-network-evaluation-only-setup/main.bicep index 7a4816fed..4c88201d4 100644 --- a/infrastructure/infrastructure-setup-bicep/15a-private-network-evaluation-only-setup/main.bicep +++ b/infrastructure/infrastructure-setup-bicep/15a-private-network-evaluation-only-setup/main.bicep @@ -269,7 +269,7 @@ module storageAccountRoleAssignment 'modules-network-secured/azure-storage-accou } /* - Assigns the project SMI the Azure AI User role on the AI Services account + Assigns the project SMI the Foundry User role on the AI Services account */ module aiAccountRoleAssignment 'modules-network-secured/ai-account-role-assignment.bicep' = { name: 'ai-account-ra-${uniqueSuffix}-deployment' diff --git a/infrastructure/infrastructure-setup-bicep/15a-private-network-evaluation-only-setup/modules-network-secured/ai-account-role-assignment.bicep b/infrastructure/infrastructure-setup-bicep/15a-private-network-evaluation-only-setup/modules-network-secured/ai-account-role-assignment.bicep index 7f9c999a2..fa73c8c5c 100644 --- a/infrastructure/infrastructure-setup-bicep/15a-private-network-evaluation-only-setup/modules-network-secured/ai-account-role-assignment.bicep +++ b/infrastructure/infrastructure-setup-bicep/15a-private-network-evaluation-only-setup/modules-network-secured/ai-account-role-assignment.bicep @@ -1,4 +1,4 @@ -// Assigns the Azure AI User role to the project managed identity on the AI Services account +// Assigns the Foundry User role to the project managed identity on the AI Services account @description('Name of the AI Services account') param accountName string @@ -11,7 +11,7 @@ resource account 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' exist scope: resourceGroup() } -// Azure AI User: 53ca6127-db72-4b80-b1b0-d745d6d5456d +// Foundry User: 53ca6127-db72-4b80-b1b0-d745d6d5456d resource azureAIUserRole 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { name: '53ca6127-db72-4b80-b1b0-d745d6d5456d' scope: resourceGroup() diff --git a/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/README.md b/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/README.md similarity index 51% rename from infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/README.md rename to infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/README.md index 6777a09e9..155fe8b5f 100644 --- a/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/README.md +++ b/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/README.md @@ -1,5 +1,5 @@ --- -description: This set of templates demonstrates how to set up Azure AI Agent Service with virtual network isolation with private network links to connect the agent to your secure data. +description: This set of templates demonstrates how to set up Azure AI Agent Service with virtual network isolation, private network links, and private Azure API Management (APIM) integration to connect the agent to your secure data. page_type: sample products: - azure @@ -10,46 +10,60 @@ languages: - json --- -# Azure AI Agent Service: Standard Agent Setup with E2E Network Isolation - -> **IMPORTANT** -> -> Class A subnet support is GA and available in the following regions. **Supported regions: Australia East, Brazil South, Canada East, East US, East US 2, France Central, Germany West Central, Italy North, Japan East, South Africa North, South Central US, South India, Spain Central, Sweden Central, UAE North, UK South, West Europe, West US, West US 3.** -> -> Class B and C subnet support is already GA and available in all regions supported by Azure AI Foundry Agent Service. Deployment templates and setup steps are identical for Class A, B, and C subnets. For more on the supported regions of the Azure AI Foundry Agent service, see [Models supported by Azure AI Foundry Agent Service](https://learn.microsoft.com/en-us/azure/ai-foundry/agents/concepts/model-region-support?tabs=global-standard) - -> **IMPORTANT** -> -> To use your existing APIM resource with Azure AI Foundry in a network isolated environment to build Agents, please deploy this template. The feature is currently in preview with a code first experience and no Foundry UI support. +# Microsoft Foundry: Standard Agent Setup with Private APIM and E2E Network Isolation +> **NEW** +> For support on deploying the right network isolation template, check out the [GitHub Copilot for Azure skill for private networking](https://github.com/microsoft/GitHub-Copilot-for-Azure/blob/main/plugin/skills/microsoft-foundry/resource/private-network/private-network.md) set-up! --- ## Overview -This infrastructure-as-code (IaC) solution deploys a network-secured Azure AI agent environment with private networking and role-based access control (RBAC). +This infrastructure-as-code (IaC) solution deploys a network-secured Azure AI agent environment with private networking, role-based access control (RBAC), and **private Azure API Management (APIM) integration**. This template extends the [standard agent setup (template 15)](../15-private-network-standard-agent-setup/) by adding support for connecting an **existing Azure API Management service** behind a private endpoint within your VNet. This allows you to expose APIs through APIM while keeping all traffic within your private network boundary. Standard setup supports private network isolation through utilizing **Bring Your Own Virtual Network (BYO VNet)** approach, also known as **custom VNet support with subnet delegation.** -This implementation gives you full control over the inbound and outbound communication paths for your agent. You can restrict access to only the resources explicitly required by your agent, such as storage accounts, databases, or APIs, while blocking all other traffic by default. This approach ensures that your agent operates within a tightly scoped network boundary, reducing the risk of data leakage or unauthorized access. By default, this setup simplifies security configuration while enforcing strong isolation guarantees, ensuring that each agent deployment remains secure, compliant, and aligned with enterprise networking policies. +This implementation gives you full control over the inbound and outbound communication paths for your agent. You can restrict access to only the resources explicitly required by your agent, such as storage accounts, databases, or APIs (via private APIM), while blocking all other traffic by default. This approach ensures that your agent operates within a tightly scoped network boundary, reducing the risk of data leakage or unauthorized access. By default, this setup simplifies security configuration while enforcing strong isolation guarantees, ensuring that each agent deployment remains secure, compliant, and aligned with enterprise networking policies. --- -## Key Information +## When to Use This Template + +Use this template when you need: +- **Full end-to-end network isolation** β€” All resources behind private endpoints with no public internet access +- **BYO VNet control** β€” You manage your own virtual network, subnets, and network security groups +- **Standard agent setup with BYO resources** β€” Customer-managed Storage, Cosmos DB, and AI Search for data residency and compliance +- **Private APIM integration** β€” Connect your existing Azure API Management service to the agent environment +- **System Assigned Managed Identity** β€” Simplified identity management with platform-managed credentials + +### Template Decision Guide + +Use the table below to choose the right infrastructure template for your scenario: -**Region and Resource Placement Requirements** -- **All Foundry workspace resources should be in the same region as the VNet**, including CosmosDB, Storage Account, AI Search, Foundry Account, Project, Managed Identity. The only exception is within the Foundry Account, you may choose to deploy your model to a different region, and any cross-region communication will be handled securely within our network infrastructure. - - **Note:** Your Virtual Network can be in a different resource group than your Foundry workspace resources +| Template | Agent Type | Networking | Identity | Key Use Case | +|----------|-----------|------------|----------|-------------| +| [**15**](../15-private-network-standard-agent-setup/) | Standard (BYO resources) | BYO VNet + Private Endpoints | System Assigned MI | E2E network isolation with full agent capabilities | +| [**19**](../19-private-network-agent-tools/) | Standard (BYO resources) | BYO VNet + Private Endpoints | System Assigned MI | Same as 15 **plus** tools behind VNet (MCP, OpenAPI, Functions, A2A) | +| [**17**](../17-private-network-standard-user-assigned-identity-agent-setup/) | Standard (BYO resources) | BYO VNet + Private Endpoints | **User Assigned MI** | Same as 15 but with user-managed identity | +| [**16** (this template)](../16-private-network-standard-agent-apim-setup/) | Standard (BYO resources) | BYO VNet + Private Endpoints | System Assigned MI | Same as 15 **plus** private APIM integration | +| [**18**](../18-managed-virtual-network/) | Standard (BYO resources) | **Managed VNet** (Microsoft-managed) | System Assigned MI | Network isolation without managing your own VNet| +| [**15a**](../15a-private-network-evaluation-only-setup/) | Evaluation only | BYO VNet + Private Endpoints | System Assigned MI | Minimal setup for evaluation β€” no Cosmos DB, AI Search, or capability host | +| [**11**](../11-private-network-basic-vnet/) | **Basic** (platform-managed) | BYO VNet injection | System Assigned MI | Basic agents with VNet isolation β€” no BYO resources needed | +| [**41**](../41-standard-agent-setup/) | Standard (BYO resources) | **Public** (no VNet) | System Assigned MI | Standard agents without network isolation | +| [**40**](../40-basic-agent-setup/) | **Basic** (platform-managed) | **Public** (no VNet) | System Assigned MI | Simplest setup β€” no BYO resources, no private networking | +--- + +## Deploy to Azure -[![Deploy To Azure](https://raw.githubusercontent.com/Azure/azure-quickstart-templates/master/1-CONTRIBUTION-GUIDE/images/deploytoazure.svg?sanitize=true)](https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fraw.githubusercontent.com%2Fazure-ai-foundry%2Ffoundry-samples%2Frefs%2Fheads%2Fmain%2Finfrastructure%2Finfrastructure-setup-bicep%2F16-private-network-standard-agent-apim-setup-preview%2Fazuredeploy.json) +[![Deploy To Azure](https://raw.githubusercontent.com/Azure/azure-quickstart-templates/master/1-CONTRIBUTION-GUIDE/images/deploytoazure.svg?sanitize=true)](https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fraw.githubusercontent.com%2Fazure-ai-foundry%2Ffoundry-samples%2Frefs%2Fheads%2Fmain%2Finfrastructure%2Finfrastructure-setup-bicep%2F16-private-network-standard-agent-apim-setup%2Fazuredeploy.json) --- ## Prerequisites 1. **Active Azure subscription with appropriate permissions** - - **Azure AI Account Owner**: Needed to create a cognitive services account and project - - **Owner or Role Based Access Administrator**: Needed to assign RBAC to the required resources (Cosmos DB, Azure AI Search, Storage) - - **Azure AI User**: Needed to create and edit agents + - **Foundry Account Owner**: Needed to create the Microsoft Foundry account and project. + - **Owner or Role Based Access Administrator**: Needed to assign RBAC on the Azure resources used by this template. + - **Foundry User**: Needed to create and use agents, projects, or evaluation workloads after deployment. 1. **Register Resource Providers** @@ -63,12 +77,13 @@ This implementation gives you full control over the inbound and outbound communi az provider register --namespace 'Microsoft.Network' az provider register --namespace 'Microsoft.App' az provider register --namespace 'Microsoft.ContainerService' + az provider register --namespace 'Microsoft.ApiManagement' ``` 1. Network administrator permissions (if operating in a restricted or enterprise environment) -1. Sufficient quota for all resources in your target Azure region - * If no parameters are passed in, this template creates an Azure AI Foundry resource, Foundry project, Azure Cosmos DB for NoSQL, Azure AI Search, and Azure Storage account +1. Sufficient quota for all resources required by this template in the target Azure region, including model deployment quota. + * If no parameters are passed in, this template creates an Microsoft Foundry resource, Foundry project, Azure Cosmos DB for NoSQL, Azure AI Search, and Azure Storage account. If an existing APIM resource ID is provided, a private endpoint is also created for APIM. 1. Azure CLI installed and configured on your local workstation or deployment pipeline server --- @@ -86,24 +101,29 @@ This implementation gives you full control over the inbound and outbound communi > **Notes:** - If you do not provide an existing virtual network, the template will create a new virtual network with the default address spaces and subnets described above. If you use an existing virtual network, make sure it already contains two subnets (Agent and Private Endpoint) before deploying the template. - You must ensure the Foundry account was successfully created so that underlying caphost has also succeeded. Then proceed to deploying the project caphost bicep. - - You must ensure the subnet is not already in use by another account. It must be an exclusive subnet for the Foundry account. - You must ensure the subnet is exclusively delegated to __Microsoft.App/environments__ and cannot be used by any other Azure resources. +### Limitations / Known Issues + +1. The delegated agent subnet must be exclusively used by a single Foundry account. It cannot be shared across accounts. +2. The Foundry resource and the virtual network must be in the same Azure region. BYO resources (Storage, Cosmos DB, AI Search) may be in different regions. +3. For the virtual network IP range, you may use any Private Class A, B or C IP range. Private Class A IP address ranges (10.x.x.x) are only supported in the following regions: **Australia East, Brazil South, Canada East, East US, East US 2, France Central, Germany West Central, Italy North, Japan East, South Africa North, South Central US, South India, Spain Central, Sweden Central, UAE North, UK South, West US, West US 3.** Use Class B (172.16.x.x) or C (192.168.x.x) ranges for other regions. You may not use any other IP range that overlaps to the list above or uses public IP ranges. +4. This template does **not** support tools (MCP servers, OpenAPI tools, Azure Functions, A2A) behind the VNet. Use [template 19](../19-private-network-agent-tools/) for that scenario. +5. There is no upgrade path from BYO VNet (this template) to Managed Virtual Network (template 18). A Foundry resource redeployment is required. +6. All projects within the same Foundry account share model deployments. Per-project model isolation is not supported. +7. Cosmos DB is deployed as single-region. Multi-region replication must be configured manually post-deployment. + ### Account Deletion Prerequisites and Cleanup Guidance -Before deleting an **Account** resource, it is essential to first delete the associated **Account Capability Host**. -Failure to do so may result in residual dependenciesβ€”such as subnets and other provisioned resources (e.g., ACA applications)β€”remaining linked to the capability host. -This can lead to errors such as **"Subnet already in use"** when attempting to reuse the same subnet in a different account deployment. +Before deleting an **Account** resource, it is essential to first delete the associated **Account Capability Host**. Failure to do so may result in residual dependenciesβ€”such as subnets and other provisioned resources (e.g., ACA applications)β€”remaining linked to the capability host. This can lead to errors such as **"Subnet already in use"** when attempting to reuse the same subnet in a different account deployment. **Cleanup Options** -**1. Full Account Removal**: -You may delete and purge the account. -The service will automatically handle the deletion of the associated capability host and any linked resources in the background. +**1. Full Account Removal**: To completely remove an account, you must delete and purge the account. Simply deleting the account is not sufficient, you must purge so that deletion of the associated capability host is triggered. The service will automatically handle the removal of the capability host and any linked resources in the background. To purge the account, use the following [link](https://learn.microsoft.com/en-us/azure/ai-services/recover-purge-resources?tabs=azure-portal#purge-a-deleted-resource). Please allow approximately max of 20 minutes for all resources to be fully unlinked from the account. + +**2. Retain Account, Remove Capability Host**: If you intend to retain the account but remove the capability host, execute the script `deleteCaphost.sh` located in this folder. After deletion, allow approximately max of 20 minutes for all resources to be fully unlinked from the account. To recreate the capability host for the account, use the script `createCaphost.sh` located in the same folder. -**2. Retain Account, Remove Capability Host**: -If you intend to retain the account but remove the capability host, you can use the script `deleteCaphost.sh` located in this folder. > **Important**: Before deleting the account capability host, ensure that the **project capability host** is deleted. @@ -117,10 +137,36 @@ Note: If not provided, the following resources will be created automatically for - Azure AI Search - Azure Storage -**Optional Integration:** API Management services can be integrated by providing an existing API Management service resource ID. +**Private APIM Integration (Optional):** This template supports connecting an **existing Azure API Management service** behind a private endpoint. APIM is not created by this template β€” you must provide the ARM Resource ID of an existing APIM instance via the `apiManagementResourceId` parameter. When provided, a private endpoint and DNS zone for APIM (`privatelink.azure-api.net`) are created within your VNet. #### Parameters +| Parameter | Description | Default | Required | +|-----------|-------------|---------|----------| +| `location` | Azure region for deployment | `eastus` | Yes | +| `aiServices` | Base name for the AI Services resource | `aiservices` | No | +| `firstProjectName` | Name for the Foundry project | `project` | No | +| `modelName` | Model to deploy | `gpt-4.1` | No | +| `modelFormat` | Model provider | `OpenAI` | No | +| `modelVersion` | Model version | `2025-04-14` | No | +| `modelSkuName` | Model deployment SKU | `GlobalStandard` | No | +| `modelCapacity` | Tokens per minute (TPM) capacity | `30` | No | +| `vnetName` | Virtual Network name | `agent-vnet-test` | No | +| `agentSubnetName` | Subnet name for agent workloads | `agent-subnet` | No | +| `agentSubnetPrefix` | Address prefix for agent subnet | `192.168.0.0/24` | No | +| `peSubnetName` | Subnet name for private endpoints | `pe-subnet` | No | +| `peSubnetPrefix` | Address prefix for PE subnet | `192.168.1.0/24` | No | +| `existingVnetResourceId` | Full ARM Resource ID of an existing VNet | `''` (creates new) | No | +| `vnetAddressPrefix` | Address space for new VNet | `192.168.0.0/16` | No | +| `aiSearchResourceId` | ARM Resource ID of existing AI Search | `''` (creates new) | No | +| `azureStorageAccountResourceId` | ARM Resource ID of existing Storage account | `''` (creates new) | No | +| `azureCosmosDBAccountResourceId` | ARM Resource ID of existing Cosmos DB | `''` (creates new) | No | +| `apiManagementResourceId` | ARM Resource ID of existing API Management service | `''` (no APIM) | No | +| `dnsZonesSubscriptionId` | Subscription ID for existing DNS zones | `''` (current sub) | No | +| `existingDnsZones` | Map of DNS zone names to resource groups | All empty (creates new) | No | + +#### BYO Resource Details + 1. **Use Existing Virtual Network and Subnets** To use an existing VNet and subnets, set the existingVnetResourceId parameter to the full Azure Resource ID of the target VNet and its address range, and provide the names of the two required subnets. If the existing VNet is associated with private DNS zones, set the existingDnsZones parameter to the resource group name in which the zones are located. For example: @@ -129,6 +175,7 @@ To use an existing VNet and subnets, set the existingVnetResourceId parameter to - param agentSubnetPrefix string = '192.168.0.0/24' //optional, default is '192.168.0.0/24' - param peSubnetName string = 'pe-subnet' //optional, default is 'pe-subnet' - param peSubnetPrefix string = '192.168.1.0/24' //optional, default is '192.168.1.0/24' +- param dnsZonesSubscriptionId string = '' //optional, leave empty to use current subscription, or set to a subscription ID if DNS zones are in a different subscription - param existingDnsZones = { 'privatelink.services.ai.azure.com': 'privzoneRG' //add resource group name where your private DNS zone is located @@ -137,6 +184,10 @@ To use an existing VNet and subnets, set the existingVnetResourceId parameter to πŸ’‘ If subnets information is provided then make sure it exist within the specified VNet to avoid deployment errors. If subnet information is not provided, the template will create subnets with the default address space. +πŸ’‘ **Cross-Subscription DNS Zones**: All DNS zones specified in `existingDnsZones` will be referenced from the subscription specified in `dnsZonesSubscriptionId`. Leave this parameter empty (default) to use the current deployment subscription, or set it to a subscription ID if your DNS zones are located in a different subscription. + +⚠️ **Important**: When `dnsZonesSubscriptionId` is set to a different subscription, ALL DNS zones in `existingDnsZones` must have resource groups specified (non-empty values). The template does not support creating new DNS zones in a different subscription. Empty resource groups are only allowed when creating zones in the current deployment subscription. + 2. **Use an existing Azure Cosmos DB for NoSQL** @@ -197,21 +248,79 @@ Click the deploy to Azure button above to open the Azure portal and deploy the t az group create --name --location ``` - Deploy the main.bicep file - - Edit the main.bicepparams file to use an existing Virtual Network & subnets, Azure Cosmos DB, Azure Storage, and Azure AI Search. + - Edit the main.bicepparams file to use an existing Virtual Network & subnets, Azure Cosmos DB, Azure Storage, Azure AI Search, and optionally an existing APIM resource. ```bash az deployment group create --resource-group --template-file main.bicep --parameters main.bicepparam ``` -> **Note:** To access your Foundry resource securely, use either a VM, VPN, or ExpressRoute. +> **Note:** To access a private Foundry resource securely, use one of the following: +> - A VM or jump box on the virtual network, optionally accessed through Azure Bastion +> - Azure VPN Gateway +> - Azure ExpressRoute + +### Cleanup + +To delete all resources created by this template: + +```bash +az group delete --name --yes --no-wait +``` + +> **Important**: If you need to reuse the same subnet, follow the [Account Deletion Prerequisites and Cleanup Guidance](#account-deletion-prerequisites-and-cleanup-guidance) to properly purge the account and wait for the capability host to fully unlink (~20 minutes). --- ## Network Secured Agent Project Architecture Deep Dive +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Secure Access (VPN Gateway / ExpressRoute / Azure Bastion) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Microsoft Foundry β”‚ + β”‚ (publicNetworkAccess: β”‚ + β”‚ DISABLED) β”‚ + β”‚ β”‚ + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ + β”‚ β”‚ Foundry Project β”‚ β”‚ + β”‚ β”‚ (Agent Workspace) β”‚ β”‚ + β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ Subnet Delegation + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ BYO Virtual Network β”‚ + β”‚ (192.168.0.0/16) β”‚ + β”‚ β”‚ + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ + β”‚ β”‚ Agent Subnet β”‚ β”‚ + β”‚ β”‚ (192.168.0.0/24) β”‚ β”‚ ◄── Delegated to + β”‚ β”‚ Microsoft.App/envs β”‚ β”‚ Microsoft.App/environments + β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β”‚ β”‚ + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ + β”‚ β”‚ PE Subnet β”‚ β”‚ + β”‚ β”‚ (192.168.1.0/24) β”‚ β”‚ + β”‚ β”‚ β”‚ β”‚ + β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ + β”‚ β”‚ β”‚Storage β”‚ β”‚Cosmos β”‚ β”‚ β”‚ ◄── Private endpoints + β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ (no public access) + β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ + β”‚ β”‚ β”‚Search β”‚ β”‚Foundry β”‚ β”‚ β”‚ + β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ + β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ + β”‚ β”‚ β”‚APIM β”‚ (optional) β”‚ β”‚ + β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ + β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +> **Tip:** For detailed layer-by-layer deployment diagrams, see the `diagrams/` folder. + ### Core Components -**Azure AI Foundry** resource +**Microsoft Foundry** resource - Central orchestration point - Manages service connections - Set networking and policy configurations @@ -228,11 +337,11 @@ Click the deploy to Azure button above to open the Azure portal and deploy the t - BYO Search: All vector stores created by the agent leverage the customer’s Azure AI Search resource. - BYO Thread Storage: All customer messages and conversation history will be stored in the customer’s own Azure Cosmos DB account. -By bundling these BYO features (file storage, search, and thread storage), the standard setup guarantees that your deployment is secure by default. All data processed by Azure AI Foundry Agent Service is automatically stored at rest in your own Azure resources, helping you meet internal policies, compliance requirements, and enterprise security standards. +By bundling these BYO features (file storage, search, and thread storage), the standard setup guarantees that your deployment is secure by default. All data processed by Microsoft Foundry Agent Service is automatically stored at rest in your own Azure resources, helping you meet internal policies, compliance requirements, and enterprise security standards. ### Azure Resources Created -Azure AI Foundry (Cognitive Services) +Microsoft Foundry (Cognitive Services) - Type: Microsoft.CognitiveServices/accounts - API version: 2025-04-01-preview - Kind: AIServices @@ -288,6 +397,15 @@ Cosmos DB Account - Disabled local auth - Single region deployment +Azure API Management (Optional β€” BYO) +- Type: Microsoft.ApiManagement/service (existing resource, not created by this template) +- Integration: Connected via private endpoint when `apiManagementResourceId` is provided +- Private DNS Zone: `privatelink.azure-api.net` +- Sub Resource: Gateway +- Features: + - Private endpoint within the VNet for secure API access + - No public internet exposure when configured behind private endpoint + ### Network Security Design This implementation utilizes a BYO VNet (Bring Your Own Virtual Network) approach, also known as custom VNet support with subnet delegation. Within your existing virtual network, one delegated subnet will be created. @@ -303,7 +421,7 @@ Network Security **Private Endpoints** Private endpoints ensure secure, internal-only connectivity. Private endpoints are created for the following: -- Azure AI Foundry +- Microsoft Foundry - Azure AI Search - Azure Storage - Azure Cosmos DB @@ -312,7 +430,7 @@ Private endpoints ensure secure, internal-only connectivity. Private endpoints a **Private DNS Zones** | Private Link Resource Type | Sub Resource | Private DNS Zone Name | Public DNS Zone Forwarders | |----------------------------|--------------|------------------------|-----------------------------| -| **Azure AI Foundry** | account | `privatelink.cognitiveservices.azure.com`
`privatelink.openai.azure.com`
`privatelink.services.ai.azure.com` | `cognitiveservices.azure.com`
`openai.azure.com`
`services.ai.azure.com` | +| **Microsoft Foundry** | account | `privatelink.cognitiveservices.azure.com`
`privatelink.openai.azure.com`
`privatelink.services.ai.azure.com` | `cognitiveservices.azure.com`
`openai.azure.com`
`services.ai.azure.com` | | **Azure AI Search** | searchService| `privatelink.search.windows.net` | `search.windows.net` | | **Azure Cosmos DB** | Sql | `privatelink.documents.azure.com` | `documents.azure.com` | | **Azure Storage** | blob | `privatelink.blob.core.windows.net` | `blob.core.windows.net` | @@ -355,7 +473,7 @@ Private endpoints ensure secure, internal-only connectivity. Private endpoints a ```text modules-network-secured/ β”œβ”€β”€ add-project-capability-host.bicep # Configuring the project's capability host -β”œβ”€β”€ ai-account-identity.bicep # Azure AI Foundry deployment and configuration +β”œβ”€β”€ ai-account-identity.bicep # Microsoft Foundry deployment and configuration β”œβ”€β”€ ai-project-identity.bicep # Foundry project deployment and connection configuration β”œβ”€β”€ ai-search-role-assignments.bicep # AI Search RBAC configuration β”œβ”€β”€ azure-storage-account-role-assignments.bicep # Storage Account RBAC configuration @@ -394,8 +512,8 @@ modules-network-secured/ ## References -- [Azure AI Foundry Networking Documentation](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/configure-private-link?tabs=azure-portal&pivots=fdp-project) -- [Azure AI Foundry RBAC Documentation](https://learn.microsoft.com/en-us/azure/ai-foundry/concepts/rbac-azure-ai-foundry?pivots=fdp-project) +- [Microsoft Foundry Networking Documentation](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/configure-private-link?tabs=azure-portal&pivots=fdp-project) +- [Microsoft Foundry RBAC Documentation](https://learn.microsoft.com/en-us/azure/ai-foundry/concepts/rbac-azure-ai-foundry?pivots=fdp-project) - [Private Endpoint Documentation](https://learn.microsoft.com/en-us/azure/private-link/) - [RBAC Documentation](https://learn.microsoft.com/en-us/azure/role-based-access-control/) - [Network Security Best Practices](https://learn.microsoft.com/en-us/azure/security/fundamentals/network-best-practices) diff --git a/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/azuredeploy.json b/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/azuredeploy.json similarity index 98% rename from infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/azuredeploy.json rename to infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/azuredeploy.json index 3e10f19fa..ec4c0d0a3 100644 --- a/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/azuredeploy.json +++ b/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/azuredeploy.json @@ -4,8 +4,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "13929636312670904506" + "version": "0.43.1.21952", + "templateHash": "8413960230154535001" } }, "parameters": { @@ -297,8 +297,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "8505298823279202405" + "version": "0.43.1.21952", + "templateHash": "17355345610868234879" } }, "parameters": { @@ -411,8 +411,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "4954184648131521061" + "version": "0.43.1.21952", + "templateHash": "10901913041029806810" } }, "parameters": { @@ -584,8 +584,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "3152324712046183852" + "version": "0.43.1.21952", + "templateHash": "10509554109014084911" } }, "parameters": { @@ -674,8 +674,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "17043822047386586435" + "version": "0.43.1.21952", + "templateHash": "13091507001124186389" } }, "parameters": { @@ -757,8 +757,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "17043822047386586435" + "version": "0.43.1.21952", + "templateHash": "13091507001124186389" } }, "parameters": { @@ -929,8 +929,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "854097619778148359" + "version": "0.43.1.21952", + "templateHash": "14040588815014793423" } }, "parameters": { @@ -1069,8 +1069,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "15994578372975581196" + "version": "0.43.1.21952", + "templateHash": "5338128208054238484" } }, "parameters": { @@ -1248,8 +1248,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "2754228344238136934" + "version": "0.43.1.21952", + "templateHash": "18286092773595477616" } }, "parameters": { @@ -1537,8 +1537,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "16093594075579008850" + "version": "0.43.1.21952", + "templateHash": "884548399218956240" } }, "parameters": { @@ -2159,8 +2159,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "5095087340309076800" + "version": "0.43.1.21952", + "templateHash": "1726962209014429687" } }, "parameters": { @@ -2334,8 +2334,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "6910483561575524105" + "version": "0.43.1.21952", + "templateHash": "4283212754622059328" } }, "parameters": { @@ -2389,8 +2389,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "14683840003859985069" + "version": "0.43.1.21952", + "templateHash": "11244759636908765413" } }, "parameters": { @@ -2405,7 +2405,7 @@ { "type": "Microsoft.Authorization/roleAssignments", "apiVersion": "2022-04-01", - "scope": "[format('Microsoft.Storage/storageAccounts/{0}', parameters('azureStorageName'))]", + "scope": "[resourceId('Microsoft.Storage/storageAccounts', parameters('azureStorageName'))]", "name": "[guid(parameters('projectPrincipalId'), resourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe'), resourceId('Microsoft.Storage/storageAccounts', parameters('azureStorageName')))]", "properties": { "principalId": "[parameters('projectPrincipalId')]", @@ -2447,8 +2447,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "2161753938341361575" + "version": "0.43.1.21952", + "templateHash": "8930392032077836844" } }, "parameters": { @@ -2469,7 +2469,7 @@ { "type": "Microsoft.Authorization/roleAssignments", "apiVersion": "2022-04-01", - "scope": "[format('Microsoft.DocumentDB/databaseAccounts/{0}', parameters('cosmosDBName'))]", + "scope": "[resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('cosmosDBName'))]", "name": "[guid(parameters('projectPrincipalId'), resourceId('Microsoft.Authorization/roleDefinitions', '230815da-be43-4aae-9cb4-875f7bd000aa'), resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('cosmosDBName')))]", "properties": { "principalId": "[parameters('projectPrincipalId')]", @@ -2511,8 +2511,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "7968115481508840" + "version": "0.43.1.21952", + "templateHash": "15161371673245917725" } }, "parameters": { @@ -2533,7 +2533,7 @@ { "type": "Microsoft.Authorization/roleAssignments", "apiVersion": "2022-04-01", - "scope": "[format('Microsoft.Search/searchServices/{0}', parameters('aiSearchName'))]", + "scope": "[resourceId('Microsoft.Search/searchServices', parameters('aiSearchName'))]", "name": "[guid(parameters('projectPrincipalId'), resourceId('Microsoft.Authorization/roleDefinitions', '8ebe5a00-799e-43f5-93ac-243d3dce84a7'), resourceId('Microsoft.Search/searchServices', parameters('aiSearchName')))]", "properties": { "principalId": "[parameters('projectPrincipalId')]", @@ -2544,7 +2544,7 @@ { "type": "Microsoft.Authorization/roleAssignments", "apiVersion": "2022-04-01", - "scope": "[format('Microsoft.Search/searchServices/{0}', parameters('aiSearchName'))]", + "scope": "[resourceId('Microsoft.Search/searchServices', parameters('aiSearchName'))]", "name": "[guid(parameters('projectPrincipalId'), resourceId('Microsoft.Authorization/roleDefinitions', '7ca78c08-252a-4471-8644-bb5ff32d4ba0'), resourceId('Microsoft.Search/searchServices', parameters('aiSearchName')))]", "properties": { "principalId": "[parameters('projectPrincipalId')]", @@ -2596,8 +2596,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "17458377866351620215" + "version": "0.43.1.21952", + "templateHash": "13412710540818916374" } }, "parameters": { @@ -2690,8 +2690,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "13874725855824693255" + "version": "0.43.1.21952", + "templateHash": "13970727149060615708" } }, "parameters": { @@ -2721,7 +2721,7 @@ { "type": "Microsoft.Authorization/roleAssignments", "apiVersion": "2022-04-01", - "scope": "[format('Microsoft.Storage/storageAccounts/{0}', parameters('storageName'))]", + "scope": "[resourceId('Microsoft.Storage/storageAccounts', parameters('storageName'))]", "name": "[guid(resourceId('Microsoft.Authorization/roleDefinitions', 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b'), resourceId('Microsoft.Storage/storageAccounts', parameters('storageName')))]", "properties": { "principalId": "[parameters('aiProjectPrincipalId')]", @@ -2769,8 +2769,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "17187611271934567223" + "version": "0.43.1.21952", + "templateHash": "15639047449398800481" } }, "parameters": { diff --git a/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/azuredeploy.parameters.json b/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/azuredeploy.parameters.json similarity index 100% rename from infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/azuredeploy.parameters.json rename to infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/azuredeploy.parameters.json diff --git a/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/deleteCapHost.sh b/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/deleteCapHost.sh similarity index 100% rename from infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/deleteCapHost.sh rename to infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/deleteCapHost.sh diff --git a/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/main.bicep b/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/main.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/main.bicep rename to infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/main.bicep diff --git a/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/main.bicepparam b/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/main.bicepparam similarity index 100% rename from infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/main.bicepparam rename to infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/main.bicepparam diff --git a/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/metadata.json b/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/metadata.json similarity index 100% rename from infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/metadata.json rename to infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/metadata.json diff --git a/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/modules-network-secured/add-project-capability-host.bicep b/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/modules-network-secured/add-project-capability-host.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/modules-network-secured/add-project-capability-host.bicep rename to infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/modules-network-secured/add-project-capability-host.bicep diff --git a/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/modules-network-secured/ai-account-identity.bicep b/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/modules-network-secured/ai-account-identity.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/modules-network-secured/ai-account-identity.bicep rename to infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/modules-network-secured/ai-account-identity.bicep diff --git a/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/modules-network-secured/ai-project-identity.bicep b/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/modules-network-secured/ai-project-identity.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/modules-network-secured/ai-project-identity.bicep rename to infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/modules-network-secured/ai-project-identity.bicep diff --git a/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/modules-network-secured/ai-search-role-assignments.bicep b/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/modules-network-secured/ai-search-role-assignments.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/modules-network-secured/ai-search-role-assignments.bicep rename to infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/modules-network-secured/ai-search-role-assignments.bicep diff --git a/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/modules-network-secured/azure-storage-account-role-assignment.bicep b/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/modules-network-secured/azure-storage-account-role-assignment.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/modules-network-secured/azure-storage-account-role-assignment.bicep rename to infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/modules-network-secured/azure-storage-account-role-assignment.bicep diff --git a/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/modules-network-secured/blob-storage-container-role-assignments.bicep b/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/modules-network-secured/blob-storage-container-role-assignments.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/modules-network-secured/blob-storage-container-role-assignments.bicep rename to infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/modules-network-secured/blob-storage-container-role-assignments.bicep diff --git a/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/modules-network-secured/cosmos-container-role-assignments.bicep b/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/modules-network-secured/cosmos-container-role-assignments.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/modules-network-secured/cosmos-container-role-assignments.bicep rename to infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/modules-network-secured/cosmos-container-role-assignments.bicep diff --git a/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/modules-network-secured/cosmosdb-account-role-assignment.bicep b/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/modules-network-secured/cosmosdb-account-role-assignment.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/modules-network-secured/cosmosdb-account-role-assignment.bicep rename to infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/modules-network-secured/cosmosdb-account-role-assignment.bicep diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/modules-network-secured/existing-vnet.bicep b/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/modules-network-secured/existing-vnet.bicep similarity index 76% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/modules-network-secured/existing-vnet.bicep rename to infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/modules-network-secured/existing-vnet.bicep index b464dedba..b371d61e5 100644 --- a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/modules-network-secured/existing-vnet.bicep +++ b/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/modules-network-secured/existing-vnet.bicep @@ -13,6 +13,7 @@ This module works with existing virtual networks and required subnets. - Private endpoint subnet for secure connectivity */ + @description('The name of the existing virtual network') param vnetName string @@ -28,24 +29,17 @@ param agentSubnetName string = 'agent-subnet' @description('The name of Private Endpoint subnet') param peSubnetName string = 'pe-subnet' -@description('The name of MCP subnet for user-deployed Container Apps') -param mcpSubnetName string = 'mcp-subnet' - @description('Address prefix for the agent subnet (only needed if creating new subnet)') param agentSubnetPrefix string = '' @description('Address prefix for the private endpoint subnet (only needed if creating new subnet)') param peSubnetPrefix string = '' -@description('Address prefix for the MCP subnet (only needed if creating new subnet)') -param mcpSubnetPrefix string = '' - // Get the address space (array of CIDR strings) var vnetAddressSpace = existingVNet.properties.addressSpace.addressPrefixes[0] var agentSubnetSpaces = empty(agentSubnetPrefix) ? cidrSubnet(vnetAddressSpace, 24, 0) : agentSubnetPrefix var peSubnetSpaces = empty(peSubnetPrefix) ? cidrSubnet(vnetAddressSpace, 24, 1) : peSubnetPrefix -var mcpSubnetSpaces = empty(mcpSubnetPrefix) ? cidrSubnet(vnetAddressSpace, 24, 2) : mcpSubnetPrefix // Reference the existing virtual network resource existingVNet 'Microsoft.Network/virtualNetworks@2024-05-01' existing = { @@ -84,32 +78,11 @@ module peSubnet 'subnet.bicep' = { } } -// Create the MCP subnet for user-deployed Container Apps -module mcpSubnet 'subnet.bicep' = { - name: 'mcp-subnet-${uniqueString(deployment().name, mcpSubnetName)}' - scope: resourceGroup(vnetResourceGroupName) - params: { - vnetName: vnetName - subnetName: mcpSubnetName - addressPrefix: mcpSubnetSpaces - delegations: [ - { - name: 'Microsoft.App/environments' - properties: { - serviceName: 'Microsoft.App/environments' - } - } - ] - } -} - // Output variables output peSubnetName string = peSubnetName output agentSubnetName string = agentSubnetName -output mcpSubnetName string = mcpSubnetName output agentSubnetId string = '${existingVNet.id}/subnets/${agentSubnetName}' output peSubnetId string = '${existingVNet.id}/subnets/${peSubnetName}' -output mcpSubnetId string = '${existingVNet.id}/subnets/${mcpSubnetName}' output virtualNetworkName string = existingVNet.name output virtualNetworkId string = existingVNet.id output virtualNetworkResourceGroup string = vnetResourceGroupName diff --git a/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/modules-network-secured/format-project-workspace-id.bicep b/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/modules-network-secured/format-project-workspace-id.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/modules-network-secured/format-project-workspace-id.bicep rename to infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/modules-network-secured/format-project-workspace-id.bicep diff --git a/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/modules-network-secured/network-agent-vnet.bicep b/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/modules-network-secured/network-agent-vnet.bicep new file mode 100644 index 000000000..bad8a4f27 --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/modules-network-secured/network-agent-vnet.bicep @@ -0,0 +1,67 @@ +@description('Azure region for the deployment') +param location string + +@description('The name of the virtual network') +param vnetName string + +@description('Indicates if an existing VNet should be used') +param useExistingVnet bool = false + +@description('Subscription ID of the existing VNet (if different from current subscription)') +param existingVnetSubscriptionId string = subscription().subscriptionId + +@description('Resource Group name of the existing VNet (if different from current resource group)') +param existingVnetResourceGroupName string = resourceGroup().name + +@description('The name of Agents Subnet') +param agentSubnetName string = 'agent-subnet' + +@description('The name of Private Endpoint subnet') +param peSubnetName string = 'pe-subnet' + +@description('Address space for the VNet (only used for new VNet)') +param vnetAddressPrefix string = '' + +@description('Address prefix for the agent subnet') +param agentSubnetPrefix string = '' + +@description('Address prefix for the private endpoint subnet') +param peSubnetPrefix string = '' + +// Create new VNet if needed +module newVNet 'vnet.bicep' = if (!useExistingVnet) { + name: 'vnet-deployment' + params: { + location: location + vnetName: vnetName + agentSubnetName: agentSubnetName + peSubnetName: peSubnetName + vnetAddressPrefix: vnetAddressPrefix + agentSubnetPrefix: agentSubnetPrefix + peSubnetPrefix: peSubnetPrefix + } +} + +// Use existing VNet if requested +module existingVNet 'existing-vnet.bicep' = if (useExistingVnet) { + name: 'existing-vnet-deployment' + params: { + vnetName: vnetName + vnetResourceGroupName: existingVnetResourceGroupName + vnetSubscriptionId: existingVnetSubscriptionId + agentSubnetName: agentSubnetName + peSubnetName: peSubnetName + agentSubnetPrefix: agentSubnetPrefix + peSubnetPrefix: peSubnetPrefix + } +} + +// Provide unified outputs regardless of which module was used +output virtualNetworkName string = useExistingVnet ? existingVNet.outputs.virtualNetworkName : newVNet.outputs.virtualNetworkName +output virtualNetworkId string = useExistingVnet ? existingVNet.outputs.virtualNetworkId : newVNet.outputs.virtualNetworkId +output virtualNetworkSubscriptionId string = useExistingVnet ? existingVNet.outputs.virtualNetworkSubscriptionId : newVNet.outputs.virtualNetworkSubscriptionId +output virtualNetworkResourceGroup string = useExistingVnet ? existingVNet.outputs.virtualNetworkResourceGroup : newVNet.outputs.virtualNetworkResourceGroup +output agentSubnetName string = agentSubnetName +output peSubnetName string = peSubnetName +output agentSubnetId string = useExistingVnet ? existingVNet.outputs.agentSubnetId : newVNet.outputs.agentSubnetId +output peSubnetId string = useExistingVnet ? existingVNet.outputs.peSubnetId : newVNet.outputs.peSubnetId diff --git a/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/modules-network-secured/private-endpoint-and-dns.bicep b/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/modules-network-secured/private-endpoint-and-dns.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/modules-network-secured/private-endpoint-and-dns.bicep rename to infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/modules-network-secured/private-endpoint-and-dns.bicep diff --git a/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/modules-network-secured/standard-dependent-resources.bicep b/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/modules-network-secured/standard-dependent-resources.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/modules-network-secured/standard-dependent-resources.bicep rename to infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/modules-network-secured/standard-dependent-resources.bicep diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/modules-network-secured/subnet.bicep b/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/modules-network-secured/subnet.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/modules-network-secured/subnet.bicep rename to infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/modules-network-secured/subnet.bicep diff --git a/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/modules-network-secured/validate-existing-resources.bicep b/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/modules-network-secured/validate-existing-resources.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup-preview/modules-network-secured/validate-existing-resources.bicep rename to infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/modules-network-secured/validate-existing-resources.bicep diff --git a/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/modules-network-secured/vnet.bicep b/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/modules-network-secured/vnet.bicep new file mode 100644 index 000000000..d5b8db277 --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/16-private-network-standard-agent-apim-setup/modules-network-secured/vnet.bicep @@ -0,0 +1,83 @@ +/* +Virtual Network Module +This module deploys the core network infrastructure with security controls: + +1. Address Space: + - VNet CIDR: 172.16.0.0/16 OR 192.168.0.0/16 + - Agents Subnet: 172.16.0.0/24 OR 192.168.0.0/24 + - Private Endpoint Subnet: 172.16.101.0/24 OR 192.168.1.0/24 + +2. Security Features: + - Network isolation + - Subnet delegation + - Private endpoint subnet +*/ + +@description('Azure region for the deployment') +param location string + +@description('The name of the virtual network') +param vnetName string = 'agents-vnet-test' + +@description('The name of Agents Subnet') +param agentSubnetName string = 'agent-subnet' + +@description('The name of Hub subnet') +param peSubnetName string = 'pe-subnet' + + +@description('Address space for the VNet') +param vnetAddressPrefix string = '' + +@description('Address prefix for the agent subnet') +param agentSubnetPrefix string = '' + +@description('Address prefix for the private endpoint subnet') +param peSubnetPrefix string = '' +var defaultVnetAddressPrefix = '192.168.0.0/16' +var vnetAddress = empty(vnetAddressPrefix) ? defaultVnetAddressPrefix : vnetAddressPrefix +var agentSubnet = empty(agentSubnetPrefix) ? cidrSubnet(vnetAddress, 24, 0) : agentSubnetPrefix +var peSubnet = empty(peSubnetPrefix) ? cidrSubnet(vnetAddress, 24, 1) : peSubnetPrefix + +resource virtualNetwork 'Microsoft.Network/virtualNetworks@2024-05-01' = { + name: vnetName + location: location + properties: { + addressSpace: { + addressPrefixes: [ + vnetAddress + ] + } + subnets: [ + { + name: agentSubnetName + properties: { + addressPrefix: agentSubnet + delegations: [ + { + name: 'Microsoft.app/environments' + properties: { + serviceName: 'Microsoft.App/environments' + } + } + ] + } + } + { + name: peSubnetName + properties: { + addressPrefix: peSubnet + } + } + ] + } +} +// Output variables +output peSubnetName string = peSubnetName +output agentSubnetName string = agentSubnetName +output agentSubnetId string = '${virtualNetwork.id}/subnets/${agentSubnetName}' +output peSubnetId string = '${virtualNetwork.id}/subnets/${peSubnetName}' +output virtualNetworkName string = virtualNetwork.name +output virtualNetworkId string = virtualNetwork.id +output virtualNetworkResourceGroup string = resourceGroup().name +output virtualNetworkSubscriptionId string = subscription().subscriptionId diff --git a/infrastructure/infrastructure-setup-bicep/17-private-network-standard-user-assigned-identity-agent-setup/README.md b/infrastructure/infrastructure-setup-bicep/17-private-network-standard-user-assigned-identity-agent-setup/README.md index e098f8941..b8c9bcf93 100644 --- a/infrastructure/infrastructure-setup-bicep/17-private-network-standard-user-assigned-identity-agent-setup/README.md +++ b/infrastructure/infrastructure-setup-bicep/17-private-network-standard-user-assigned-identity-agent-setup/README.md @@ -1,5 +1,5 @@ --- -description: This set of templates demonstrates how to set up Azure AI Agent Service with virtual network isolation using User Managed Identity authetication for the AI Service with private network links to connect the agent to your secure data. +description: This set of templates demonstrates how to set up Foundry Agent Service with virtual network isolation using User Assigned Managed Identity authentication for the AI Service with private network links to connect the agent to your secure data. page_type: sample products: - azure @@ -10,45 +10,57 @@ languages: - json --- -# Azure AI Agent Service: Standard Agent Setup with E2E Network Isolation and UAI - -> **IMPORTANT** -> -> Private Class A subnet support is GA and available in the following regions. **Supported regions: Australia East, Brazil South, Canada East, East US, East US 2, France Central, Germany West Central, Italy North, Japan East, South Africa North, South Central US, South India, Spain Central, Sweden Central, UAE North, UK South, West Europe, West US, West US 3.** -> -> Private Class B and C subnet support is already GA and available in all regions supported by Azure AI Foundry Agent Service. Deployment templates and setup steps are identical for Class A, B, and C subnets. For more on the supported regions of the Azure AI Foundry Agent service, see [Models supported by Azure AI Foundry Agent Service](https://learn.microsoft.com/en-us/azure/ai-foundry/agents/concepts/model-region-support?tabs=global-standard) +# Foundry Agent Service: Standard Agent Setup with E2E Network Isolation and User Assigned Identity +> **NEW** +> For support on deploying the right network isolation template, check out the [GitHub Copilot for Azure skill for private networking](https://github.com/microsoft/GitHub-Copilot-for-Azure/blob/main/plugin/skills/microsoft-foundry/resource/private-network/private-network.md) set-up! --- ## Overview -This infrastructure-as-code (IaC) solution deploys a network-secured Azure AI agent environment with private networking and role-based access control (RBAC). - -Standard setup supports private network isolation through utilizing **Bring Your Own Virtual Network (BYO VNet)** approach, also known as **custom VNet support with subnet delegation.** +This infrastructure-as-code (IaC) solution deploys a network-secured agent environment with private networking and role-based access control (RBAC) and **User-assigned identity**. Standard setup supports private network isolation through utilizing **Bring Your Own Virtual Network (BYO VNet)** approach, also known as **custom VNet support with subnet delegation.** This implementation gives you full control over the inbound and outbound communication paths for your agent. You can restrict access to only the resources explicitly required by your agent, such as storage accounts, databases, or APIs, while blocking all other traffic by default. This approach ensures that your agent operates within a tightly scoped network boundary, reducing the risk of data leakage or unauthorized access. By default, this setup simplifies security configuration while enforcing strong isolation guarantees, ensuring that each agent deployment remains secure, compliant, and aligned with enterprise networking policies. --- -## Key Information +## When to Use This Template + +Use this template when you need: +- **Full end-to-end network isolation** β€” All resources behind private endpoints with no public internet access +- **BYO VNet control** β€” You manage your own virtual network, subnets, and network security groups +- **Standard agent setup with BYO resources** β€” Customer-managed Storage, Cosmos DB, and AI Search for data residency and compliance +- **User Assigned Managed Identity** β€” Customer-managed identity for greater control over credential lifecycle and cross-resource sharing + +### Template Decision Guide + +Use the table below to choose the right infrastructure template for your scenario: -**Limited Region Support for Class A Subnet IPs** -- Class A is supported in a subset of regions supported by the Azure AI Foundry Agent service. +| Template | Agent Type | Networking | Identity | Key Use Case | +|----------|-----------|------------|----------|-------------| +| [**15**](../15-private-network-standard-agent-setup/) | Standard (BYO resources) | BYO VNet + Private Endpoints | System Assigned MI | E2E network isolation with full agent capabilities | +| [**19**](../19-private-network-agent-tools/) | Standard (BYO resources) | BYO VNet + Private Endpoints | System Assigned MI | Same as 15 **plus** tools behind VNet (MCP, OpenAPI, Functions, A2A) | +| [**17** (this template)](../17-private-network-standard-user-assigned-identity-agent-setup/) | Standard (BYO resources) | BYO VNet + Private Endpoints | **User Assigned MI** | Same as 15 but with user-managed identity | +| [**16**](../16-private-network-standard-agent-apim-setup-preview/) | Standard (BYO resources) | BYO VNet + Private Endpoints | System Assigned MI | Same as 15 **plus** private APIM integration (preview) | +| [**18**](../18-managed-virtual-network-preview/) | Standard (BYO resources) | **Managed VNet** (Microsoft-managed) | System Assigned MI | Network isolation without managing your own VNet (preview) | +| [**15a**](../15a-private-network-evaluation-only-setup/) | Evaluation only | BYO VNet + Private Endpoints | System Assigned MI | Minimal setup for evaluation β€” no Cosmos DB, AI Search, or capability host | +| [**11**](../11-private-network-basic-vnet/) | **Basic** (platform-managed) | BYO VNet injection | System Assigned MI | Basic agents with VNet isolation β€” no BYO resources needed | +| [**41**](../41-standard-agent-setup/) | Standard (BYO resources) | **Public** (no VNet) | System Assigned MI | Standard agents without network isolation | +| [**40**](../40-basic-agent-setup/) | **Basic** (platform-managed) | **Public** (no VNet) | System Assigned MI | Simplest setup β€” no BYO resources, no private networking | -**Region and Resource Placement Requirements** -- **All Foundry workspace resources should be in the same region as the VNet**, including CosmosDB, Storage Account, AI Search, Foundry Account, Project, Managed Identity. The only exception is within the Foundry Account, you may choose to deploy your model to a different region, and any cross-region communication will be handled securely within our network infrastructure. - - **Note:** Your Virtual Network can be in a different resource group than your Foundry workspace resources +--- +## Deploy to Azure -[![Deploy To Azure](https://raw.githubusercontent.com/Azure/azure-quickstart-templates/master/1-CONTRIBUTION-GUIDE/images/deploytoazure.svg?sanitize=true)](https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fraw.githubusercontent.com%2Fazure-ai-foundry%2Ffoundry-samples%2Frefs%2Fheads%2Fmain%2Finfrastructure%2Finfrastructure-setup-bicep%2F15-private-network-standard-agent-setup%2Fazuredeploy.json) +[![Deploy To Azure](https://raw.githubusercontent.com/Azure/azure-quickstart-templates/master/1-CONTRIBUTION-GUIDE/images/deploytoazure.svg?sanitize=true)](https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fraw.githubusercontent.com%2Fazure-ai-foundry%2Ffoundry-samples%2Frefs%2Fheads%2Fmain%2Finfrastructure%2Finfrastructure-setup-bicep%2F17-private-network-standard-user-assigned-identity-agent-setup%2Fazuredeploy.json) --- ## Prerequisites 1. **Active Azure subscription with appropriate permissions** - - **Azure AI Account Owner**: Needed to create a cognitive services account and project - - **Owner or Role Based Access Administrator**: Needed to assign RBAC to the required resources (Cosmos DB, Azure AI Search, Storage) - - **Azure AI User**: Needed to create and edit agents + - **Foundry Account Owner**: Needed to create the Microsoft Foundry account and project. + - **Owner or Role Based Access Administrator**: Needed to assign RBAC on the Azure resources used by this template. + - **Foundry User**: Needed to create and use agents, projects, or evaluation workloads after deployment. 1. **Register Resource Providers** @@ -66,8 +78,8 @@ This implementation gives you full control over the inbound and outbound communi 1. Network administrator permissions (if operating in a restricted or enterprise environment) -1. Sufficient quota for all resources in your target Azure region - * If no parameters are passed in, this template creates an Azure AI Foundry resource, Foundry project, Azure Cosmos DB for NoSQL, Azure AI Search, and Azure Storage account +1. Sufficient quota for all resources required by this template in the target Azure region, including model deployment quota. + * If no parameters are passed in, this template creates an Microsoft Foundry resource, Foundry project, Azure Cosmos DB for NoSQL, Azure AI Search, and Azure Storage account 1. Azure CLI installed and configured on your local workstation or deployment pipeline server --- @@ -80,15 +92,25 @@ This implementation gives you full control over the inbound and outbound communi 2. Two subnets are needed as well: - **Agent Subnet** (e.g., 192.168.0.0/24): Hosts Agent client for Agent workloads, delegated to Microsoft.App/environments. The recommended size should be /24 for this delegated subnet. - **Private endpoint Subnet** (e.g. 192.168.1.0/24): Hosts private endpoints - - Ensure that the address spaces for the used VNET does not overlap with any existing networks in your Azure environment or reserved IP ranges like the following: 169.254.0.0/16,172.30.0.0/16,172.31.0.0/16,192.0.2.0/24,0.0.0.0/8,127.0.0.0/8,100.100.0.0/17,100.100.192.0/19,100.100.224.0/19,10.0.0.0/8. + - Ensure that the address spaces for the used VNET does not overlap with any existing networks in your Azure environment or reserved IP ranges like the following: 169.254.0.0/16,172.30.0.0/16,172.31.0.0/16,192.0.2.0/24,0.0.0.0/8,127.0.0.0/8,100.100.0.0/17,100.100.192.0/19,100.100.224.0/19,100.64.0.0/11. This includes all address space(s) you have in your VNET if you have more than one, and peered VNETs. > **Notes:** - If you do not provide an existing virtual network, the template will create a new virtual network with the default address spaces and subnets described above. If you use an existing virtual network, make sure it already contains two subnets (Agent and Private Endpoint) before deploying the template. - You must ensure the Foundry account was successfully created so that underlying caphost has also succeeded. Then proceed to deploying the project caphost bicep. - - You must ensure the subnet is not already in use by another account. It must be an exclusive subnet for the Foundry account. - You must ensure the subnet is exclusively delegated to __Microsoft.App/environments__ and cannot be used by any other Azure resources. - + + + +### Limitations / Known Issues + +1. The delegated agent subnet must be exclusively used by a single Foundry account. It cannot be shared across accounts. +2. The Foundry resource and the virtual network must be in the same Azure region. BYO resources (Storage, Cosmos DB, AI Search) may be in different regions. +3. For the virtual network IP range, you may use any Private Class A, B or C IP range. Private Class A IP address ranges (10.x.x.x) are only supported in the following regions: **Australia East, Brazil South, Canada East, East US, East US 2, France Central, Germany West Central, Italy North, Japan East, South Africa North, South Central US, South India, Spain Central, Sweden Central, UAE North, UK South, West US, West US 3.** Use Class B (172.16.x.x) or C (192.168.x.x) ranges for other regions. You may not use any other IP range that overlaps to the list above or uses public IP ranges. +4. This template does **not** support tools (MCP servers, OpenAPI tools, Azure Functions, A2A) behind the VNet. Use [template 19](../19-private-network-agent-tools/) for that scenario. +5. There is no upgrade path from BYO VNet (this template) to Managed Virtual Network (template 18). A Foundry resource redeployment is required. +6. All projects within the same Foundry account share model deployments. Per-project model isolation is not supported. +7. Cosmos DB is deployed as single-region. Multi-region replication must be configured manually post-deployment. ### Account Deletion Prerequisites and Cleanup Guidance @@ -116,6 +138,31 @@ Note: If not provided, the following resources will be created automatically for #### Parameters +| Parameter | Description | Default | Required | +|-----------|-------------|---------|----------| +| `location` | Azure region for deployment | `eastus` | Yes | +| `aiServices` | Base name for the AI Services resource | `aiservices` | No | +| `firstProjectName` | Name for the Foundry project | `project` | No | +| `modelName` | Model to deploy | `gpt-4.1` | No | +| `modelFormat` | Model provider | `OpenAI` | No | +| `modelVersion` | Model version | `2025-04-14` | No | +| `modelSkuName` | Model deployment SKU | `GlobalStandard` | No | +| `modelCapacity` | Tokens per minute (TPM) capacity | `30` | No | +| `vnetName` | Virtual Network name | `agent-vnet-test` | No | +| `agentSubnetName` | Subnet name for agent workloads | `agent-subnet` | No | +| `agentSubnetPrefix` | Address prefix for agent subnet | `192.168.0.0/24` | No | +| `peSubnetName` | Subnet name for private endpoints | `pe-subnet` | No | +| `peSubnetPrefix` | Address prefix for PE subnet | `192.168.1.0/24` | No | +| `existingVnetResourceId` | Full ARM Resource ID of an existing VNet | `''` (creates new) | No | +| `vnetAddressPrefix` | Address space for new VNet | `192.168.0.0/16` | No | +| `aiSearchResourceId` | ARM Resource ID of existing AI Search | `''` (creates new) | No | +| `azureStorageAccountResourceId` | ARM Resource ID of existing Storage account | `''` (creates new) | No | +| `azureCosmosDBAccountResourceId` | ARM Resource ID of existing Cosmos DB | `''` (creates new) | No | +| `dnsZonesSubscriptionId` | Subscription ID for existing DNS zones | `''` (current sub) | No | +| `existingDnsZones` | Map of DNS zone names to resource groups | All empty (creates new) | No | + +#### BYO Resource Details + 1. **Use Existing Virtual Network and Subnets** To use an existing VNet and subnets, set the existingVnetResourceId parameter to the full Azure Resource ID of the target VNet and its address range, and provide the names of the two required subnets. If the existing VNet is associated with private DNS zones, set the existingDnsZones parameter to the resource group name in which the zones are located. For example: @@ -124,6 +171,7 @@ To use an existing VNet and subnets, set the existingVnetResourceId parameter to - param agentSubnetPrefix string = '192.168.0.0/24' //optional, default is '192.168.0.0/24' - param peSubnetName string = 'pe-subnet' //optional, default is 'pe-subnet' - param peSubnetPrefix string = '192.168.1.0/24' //optional, default is '192.168.1.0/24' +- param dnsZonesSubscriptionId string = '' //optional, leave empty to use current subscription, or set to a subscription ID if DNS zones are in a different subscription - param existingDnsZones = { 'privatelink.services.ai.azure.com': 'privzoneRG' //add resource group name where your private DNS zone is located @@ -132,31 +180,16 @@ To use an existing VNet and subnets, set the existingVnetResourceId parameter to πŸ’‘ If subnets information is provided then make sure it exist within the specified VNet to avoid deployment errors. If subnet information is not provided, the template will create subnets with the default address space. +πŸ’‘ **Cross-Subscription DNS Zones**: All DNS zones specified in `existingDnsZones` will be referenced from the subscription specified in `dnsZonesSubscriptionId`. Leave this parameter empty (default) to use the current deployment subscription, or set it to a subscription ID if your DNS zones are located in a different subscription. + +⚠️ **Important**: When `dnsZonesSubscriptionId` is set to a different subscription, ALL DNS zones in `existingDnsZones` must have resource groups specified (non-empty values). The template does not support creating new DNS zones in a different subscription. Empty resource groups are only allowed when creating zones in the current deployment subscription. + 2. **Use an existing Azure Cosmos DB for NoSQL** To use an existing Cosmos DB for NoSQL resource, set cosmosDBResourceId parameter to the full Azure Resource ID of the target Cosmos DB. - param azureCosmosDBAccountResourceId string = /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.DocumentDB/databaseAccounts/{cosmosDbAccountName} -> **⚠️ Important: Cosmos DB Connection Requirements** -> -> When creating the Cosmos DB connection (e.g., via REST API or ARM), ensure the following: -> - The `authType` **must** be set to `AAD`. This is the only supported authentication type for the Cosmos DB connection used by the Agent Service. -> - The `metadata` section **must** include the `ResourceId` property, set to the full Azure Resource ID of your Cosmos DB account. The Agent Service relies on this property to correctly identify and connect to your Cosmos DB resource. Omitting `ResourceId` from the metadata will cause the connection to fail. -> -> Example connection properties: -> ```json -> { -> "category": "CosmosDB", -> "authType": "AAD", -> "metadata": { -> "ApiType": "Azure", -> "ResourceId": "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.DocumentDB/databaseAccounts/{cosmosDbAccountName}", -> "location": "{region}" -> } -> } -> ``` - 3. **Use an existing Azure AI Search resource** @@ -193,15 +226,70 @@ Click the deploy to Azure button above to open the Azure portal and deploy the t az deployment group create --resource-group --template-file main.bicep --parameters main.bicepparam ``` -> **Note:** To access your Foundry resource securely, use either a VM, VPN, or ExpressRoute. +> **Note:** To access a private Foundry resource securely, use one of the following: +> - A VM or jump box on the virtual network, optionally accessed through Azure Bastion +> - Azure VPN Gateway +> - Azure ExpressRoute + +### Cleanup + +To delete all resources created by this template: + +```bash +az group delete --name --yes --no-wait +``` + +> **Important**: If you need to reuse the same subnet, follow the [Account Deletion Prerequisites and Cleanup Guidance](#account-deletion-prerequisites-and-cleanup-guidance) to properly purge the account and wait for the capability host to fully unlink (~20 minutes). --- ## Network Secured Agent Project Architecture Deep Dive +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Secure Access (VPN Gateway / ExpressRoute / Azure Bastion) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Microsoft Foundry β”‚ + β”‚ (publicNetworkAccess: β”‚ + β”‚ DISABLED) β”‚ + β”‚ β”‚ + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ + β”‚ β”‚ Foundry Project β”‚ β”‚ + β”‚ β”‚ (Agent Workspace) β”‚ β”‚ + β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ Subnet Delegation + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ BYO Virtual Network β”‚ + β”‚ (192.168.0.0/16) β”‚ + β”‚ β”‚ + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ + β”‚ β”‚ Agent Subnet β”‚ β”‚ + β”‚ β”‚ (192.168.0.0/24) β”‚ β”‚ ◄── Delegated to + β”‚ β”‚ Microsoft.App/envs β”‚ β”‚ Microsoft.App/environments + β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β”‚ β”‚ + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ + β”‚ β”‚ PE Subnet β”‚ β”‚ + β”‚ β”‚ (192.168.1.0/24) β”‚ β”‚ + β”‚ β”‚ β”‚ β”‚ + β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ + β”‚ β”‚ β”‚Storage β”‚ β”‚Cosmos β”‚ β”‚ β”‚ ◄── Private endpoints + β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ (no public access) + β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ + β”‚ β”‚ β”‚Search β”‚ β”‚Foundry β”‚ β”‚ β”‚ + β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ + β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +> **Tip:** For detailed layer-by-layer deployment diagrams, see the `diagrams/` folder. + ### Core Components -**Azure AI Foundry** resource +**Microsoft Foundry** resource - Central orchestration point - Manages service connections - Set networking and policy configurations @@ -218,11 +306,11 @@ Click the deploy to Azure button above to open the Azure portal and deploy the t - BYO Search: All vector stores created by the agent leverage the customer’s Azure AI Search resource. - BYO Thread Storage: All customer messages and conversation history will be stored in the customer’s own Azure Cosmos DB account. -By bundling these BYO features (file storage, search, and thread storage), the standard setup guarantees that your deployment is secure by default. All data processed by Azure AI Foundry Agent Service is automatically stored at rest in your own Azure resources, helping you meet internal policies, compliance requirements, and enterprise security standards. +By bundling these BYO features (file storage, search, and thread storage), the standard setup guarantees that your deployment is secure by default. All data processed by Microsoft Foundry Agent Service is automatically stored at rest in your own Azure resources, helping you meet internal policies, compliance requirements, and enterprise security standards. ### Azure Resources Created -Azure AI Foundry (Cognitive Services) +Microsoft Foundry (Cognitive Services) - Type: Microsoft.CognitiveServices/accounts - API version: 2025-04-01-preview - Kind: AIServices @@ -293,7 +381,7 @@ Network Security **Private Endpoints** Private endpoints ensure secure, internal-only connectivity. Private endpoints are created for the following: -- Azure AI Foundry +- Microsoft Foundry - Azure AI Search - Azure Storage - Azure Cosmos DB @@ -301,7 +389,7 @@ Private endpoints ensure secure, internal-only connectivity. Private endpoints a **Private DNS Zones** | Private Link Resource Type | Sub Resource | Private DNS Zone Name | Public DNS Zone Forwarders | |----------------------------|--------------|------------------------|-----------------------------| -| **Azure AI Foundry** | account | `privatelink.cognitiveservices.azure.com`
`privatelink.openai.azure.com`
`privatelink.services.ai.azure.com` | `cognitiveservices.azure.com`
`openai.azure.com`
`services.ai.azure.com` | +| **Microsoft Foundry** | account | `privatelink.cognitiveservices.azure.com`
`privatelink.openai.azure.com`
`privatelink.services.ai.azure.com` | `cognitiveservices.azure.com`
`openai.azure.com`
`services.ai.azure.com` | | **Azure AI Search** | searchService| `privatelink.search.windows.net` | `search.windows.net` | | **Azure Cosmos DB** | Sql | `privatelink.documents.azure.com` | `documents.azure.com` | | **Azure Storage** | blob | `privatelink.blob.core.windows.net` | `blob.core.windows.net` | @@ -313,7 +401,7 @@ Private endpoints ensure secure, internal-only connectivity. Private endpoints a - No credential storage - Platform-managed rotation - This template uses System Managed Identity, but User Assigned Managed Identity is also supported. + This template uses User Assigned Managed Identity for greater control over credential lifecycle and cross-resource sharing. System Assigned Managed Identity is also supported via [template 15](../15-private-network-standard-agent-setup/). - **Role Assignments** - **Azure AI Search** @@ -343,7 +431,7 @@ Private endpoints ensure secure, internal-only connectivity. Private endpoints a ```text modules-network-secured/ β”œβ”€β”€ add-project-capability-host.bicep # Configuring the project's capability host -β”œβ”€β”€ ai-account-identity.bicep # Azure AI Foundry deployment and configuration +β”œβ”€β”€ ai-account-identity.bicep # Microsoft Foundry deployment and configuration β”œβ”€β”€ ai-project-identity.bicep # Foundry project deployment and connection configuration β”œβ”€β”€ ai-search-role-assignments.bicep # AI Search RBAC configuration β”œβ”€β”€ azure-storage-account-role-assignments.bicep # Storage Account RBAC configuration @@ -380,172 +468,11 @@ modules-network-secured/ 4. Review network security groups --- ---- -# (Optional) Adding Multiple Projects to AI Foundry Deployment - -This guide explains how to add additional projects to your existing AI Foundry deployment with network security and capability hosts. - -## Overview - -After deploying your initial AI Foundry setup using `main.bicep`, you can add additional projects using the modular approach provided in this repository. Each new project will: - -- βœ… **Reuse existing shared infrastructure** (AI Services account, Storage, Cosmos DB, AI Search, VNet) -- βœ… **Create independent projects** with unique identities and connections -- βœ… **Set up proper role assignments** and capability hosts for each project -- βœ… **Maintain network security** configurations from your original deployment -- βœ… **Deploy independently** without affecting existing projects - -## Files Added - -### Core Deployment Files - -| File | Purpose | -|------|---------| -| `add-project.bicep` | Main Bicep template for adding new projects | -| `add-project.bicepparam` | Parameters file template for new projects | -| `modules-network-secured/ai-project-identity-unique.bicep` | Modified project module with unique connection names | -| `modules-network-secured/blob-storage-container-role-assignments-unique.bicep` | Modified storage role assignment module | - -### Helper Files - -| File | Purpose | -|------|---------| -| `get-existing-resources.ps1` | PowerShell script to discover existing resource names | - -## Prerequisites - -1. βœ… **Existing AI Foundry deployment** completed using `main.bicep` -2. βœ… **Azure CLI** installed and logged in -3. βœ… **Proper permissions** on the resource group and existing resources -4. βœ… **Resource names** from your existing deployment - -## Step-by-Step Guide - -### Step 1: Discover Existing Resource Names - -Run the PowerShell script to automatically discover your existing resource names: - -```powershell -# Navigate to your repository folder -cd "path\to\your\AgentRepro\folder" - -# Run the discovery script -.\get-existing-resources.ps1 -ResourceGroupName "your-resource-group-name" - -# Optional: Include subscription ID if needed -.\get-existing-resources.ps1 -ResourceGroupName "your-resource-group-name" -SubscriptionId "your-subscription-id" -``` - -**Example output:** -``` -=== Summary for add-project.bicepparam === -param existingAccountName = 'aiservicesytlz' -param existingAiSearchName = 'aiservicesytlzsearch' -param existingStorageName = 'aiservicesytlzstorage' -param existingCosmosDBName = 'aiservicesytlzcosmosdb' -param accountResourceGroupName = 'agenticvnet' -param aiSearchResourceGroupName = 'agenticvnet' -param storageResourceGroupName = 'agenticvnet' -param cosmosDBResourceGroupName = 'agenticvnet' -``` - -### Step 2: Configure Parameters File - -Copy the output from Step 1 and update your `add-project.bicepparam` file: - -### Step 3: Deploy the New Project - -Deploy using Azure CLI: - -```powershell -az deployment group create ` - --resource-group "your-resource-group" ` - --template-file "add-project.bicep" ` - --parameters "add-project.bicepparam" -``` - -## Adding Multiple Projects - -To add additional projects, repeat the process with different parameter values: - -### For a Third Project: - -1. **Update project-specific parameters:** - ```bicep - param projectName = 'thirdproject' // Must be unique - param displayName = 'Third Project' - param projectCapHost = 'caphostthird' // Must be unique - ``` - -3. **Deploy using the new parameters file:** - ```powershell - az deployment group create ` - --resource-group "your-resource-group" ` - --template-file "add-project.bicep" ` - --parameters "add-project.bicepparam" - ``` - -## What Gets Created - -Each new project deployment creates: - -| Resource | Description | -|----------|-------------| -| **AI Foundry Project** | New project under your existing AI Services account | -| **Managed Identity** | Project-specific system-assigned identity | -| **Unique Connections** | Project-specific connections to shared resources | -| **Capability Host** | Configured for Agents with proper connections | -| **RBAC Assignments** | Proper permissions on shared resources | - -### Role Assignments Created: - -- βœ… **Storage Blob Data Contributor** on Storage Account -- βœ… **Storage Blob Data Owner** on project-specific containers -- βœ… **Cosmos DB Operator** on Cosmos DB Account -- βœ… **Cosmos Built-In Data Contributor** on project-specific containers -- βœ… **Search Index Data Contributor** on AI Search Service -- βœ… **Search Service Contributor** on AI Search Service - -## Configuration Reference - -### Required Parameters (Must Customize for Each Project) - -| Parameter | Description | Example | -|-----------|-------------|---------| -| `projectName` | Unique name for the project | `'secondproject'` | -| `displayName` | Display name in Azure portal | `'Second Project'` | -| `projectCapHost` | Unique capability host name | `'caphostsecond'` | -| `projectDescription` | Description of the project | `'My second AI project'` | - -### Existing Resource Parameters (From Script) - -| Parameter | Description | Source | -|-----------|-------------|---------| -| `existingAccountName` | AI Services account name | Output from `get-existing-resources.ps1` | -| `existingAiSearchName` | AI Search service name | Output from `get-existing-resources.ps1` | -| `existingStorageName` | Storage account name | Output from `get-existing-resources.ps1` | -| `existingCosmosDBName` | Cosmos DB account name | Output from `get-existing-resources.ps1` | -| `*ResourceGroupName` | Resource group names | Usually same as deployment RG | -| `*SubscriptionId` | Subscription IDs | Usually same subscription | - - -## Security Considerations - -- βœ… **Least Privilege**: Each project gets only the permissions it needs -- βœ… **Isolated Containers**: Projects get separate storage containers -- βœ… **Network Security**: Inherits network security from original deployment -- βœ… **Unique Identities**: Each project has its own managed identity - -## Limitations - -- πŸ“ All projects share the same model deployments -- πŸ“ Projects must be in the same region as the original deployment -- πŸ“ Network configuration is inherited from original deployment ## References -- [Azure AI Foundry Networking Documentation](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/configure-private-link?tabs=azure-portal&pivots=fdp-project) -- [Azure AI Foundry RBAC Documentation](https://learn.microsoft.com/en-us/azure/ai-foundry/concepts/rbac-azure-ai-foundry?pivots=fdp-project) +- [Microsoft Foundry Networking Documentation](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/configure-private-link?tabs=azure-portal&pivots=fdp-project) +- [Microsoft Foundry RBAC Documentation](https://learn.microsoft.com/en-us/azure/ai-foundry/concepts/rbac-azure-ai-foundry?pivots=fdp-project) - [Private Endpoint Documentation](https://learn.microsoft.com/en-us/azure/private-link/) - [RBAC Documentation](https://learn.microsoft.com/en-us/azure/role-based-access-control/) - [Network Security Best Practices](https://learn.microsoft.com/en-us/azure/security/fundamentals/network-best-practices) diff --git a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/modules-network-secured/managed-network.bicep b/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/modules-network-secured/managed-network.bicep deleted file mode 100644 index 1edeaf49b..000000000 --- a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/modules-network-secured/managed-network.bicep +++ /dev/null @@ -1,112 +0,0 @@ -@description('The name of the AI Services account') -param accountName string - -@description('The isolation mode for the managed network') -@allowed([ - 'AllowOnlyApprovedOutbound' - 'AllowInternetOutbound' -]) -param isolationMode string = 'AllowOnlyApprovedOutbound' - -@description('The name of the storage account to create outbound rules for') -param storageName string - -@description('The resource group name where the storage account is located') -param storageResourceGroupName string - -@description('The subscription ID where the storage account is located') -param storageSubscriptionId string - -// @description('The name of the AI Search service to create outbound rules for') -// param aiSearchName string - -// @description('The resource group name where the AI Search service is located') -// param aiSearchResourceGroupName string - -// @description('The subscription ID where the AI Search service is located') -// param aiSearchSubscriptionId string - -// @description('The name of the Cosmos DB account to create outbound rules for') -// param cosmosDBName string - -// @description('The resource group name where the Cosmos DB account is located') -// param cosmosDBResourceGroupName string - -// @description('The subscription ID where the Cosmos DB account is located') -// param cosmosDBSubscriptionId string - -// Reference the existing AI Services account in the same resource group -resource aiAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { - name: accountName -} - -// Create the managed network settings first -#disable-next-line BCP081 -resource managedNetwork 'Microsoft.CognitiveServices/accounts/managednetworks@2025-10-01-preview' = { - parent: aiAccount - name: 'default' - properties: { - managedNetwork: { - IsolationMode: isolationMode - managedNetworkKind: 'V2' - provisionNetworkNow: true - //firewallSku: 'Standard' // Uncomment to enable firewall only when in AllowOnlyApprovedOutbound mode - } - } -} - -// Create outbound rule for Storage Account -#disable-next-line BCP081 -resource storageOutboundRule 'Microsoft.CognitiveServices/accounts/managednetworks/outboundRules@2025-10-01-preview' = { - parent: managedNetwork - name: 'storage-outbound-rule' - properties: { - type: 'PrivateEndpoint' - destination: { - serviceResourceId: '/subscriptions/${storageSubscriptionId}/resourceGroups/${storageResourceGroupName}/providers/Microsoft.Storage/storageAccounts/${storageName}' - subresourceTarget: 'blob' - sparkEnabled: false - sparkStatus: 'Inactive' - } - category: 'UserDefined' - } -} - -// // Create outbound rule for AI Search -// #disable-next-line BCP081 -// resource aiSearchOutboundRule 'Microsoft.CognitiveServices/accounts/managednetworks/outboundRules@2025-10-01-preview' = { -// parent: managedNetwork -// name: 'aisearch-outbound-rule' -// properties: { -// type: 'PrivateEndpoint' -// destination: { -// serviceResourceId: '/subscriptions/${aiSearchSubscriptionId}/resourceGroups/${aiSearchResourceGroupName}/providers/Microsoft.Search/searchServices/${aiSearchName}' -// subresourceTarget: 'searchService' -// sparkEnabled: false -// sparkStatus: 'Inactive' -// } -// category: 'UserDefined' -// } -// } - -// // Create outbound rule for Cosmos DB -// #disable-next-line BCP081 -// resource cosmosDBOutboundRule 'Microsoft.CognitiveServices/accounts/managednetworks/outboundRules@2025-10-01-preview' = { -// parent: managedNetwork -// name: 'cosmosdb-outbound-rule' -// properties: { -// type: 'PrivateEndpoint' -// destination: { -// serviceResourceId: '/subscriptions/${cosmosDBSubscriptionId}/resourceGroups/${cosmosDBResourceGroupName}/providers/Microsoft.DocumentDB/databaseAccounts/${cosmosDBName}' -// subresourceTarget: 'Sql' -// sparkEnabled: false -// sparkStatus: 'Inactive' -// } -// category: 'UserDefined' -// } -// } - -output managedNetworkSettingsName string = managedNetwork.name -output storageOutboundRuleName string = storageOutboundRule.name -// output aiSearchOutboundRuleName string = aiSearchOutboundRule.name -// output cosmosDBOutboundRuleName string = cosmosDBOutboundRule.name diff --git a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/sample-mvnet/sample_mvnet.json b/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/sample-mvnet/sample_mvnet.json deleted file mode 100644 index cae0f8103..000000000 --- a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/sample-mvnet/sample_mvnet.json +++ /dev/null @@ -1,75 +0,0 @@ -/***** -This is the sample to create a Cognitive Services account with a managed network using Azure Resource Manager (ARM) templates. -There is no e2e secured set-up with this template. Please deploy the main.bicep for the full e2e managed network secured set-up. - -CLI command to run the sample: - -az deployment group create ---resource-group rg-name ---template-file "file_path" ---parameters accounts_foundry_mvnet="my-mvnet" - -********/ - -{ - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "accounts_foundry_mvnet": { - "defaultValue": "my-mvnet", - "type": "String" - } - }, - "variables": {}, - "resources": [ - { - "type": "Microsoft.CognitiveServices/accounts", - "apiVersion": "2025-10-01-preview", - "name": "[parameters('accounts_foundry_mvnet')]", - "location": "uaenorth", - "sku": { - "name": "S0" - }, - "kind": "AIServices", - "identity": { - "type": "SystemAssigned" - }, - "properties": { - "apiProperties": {}, - "customSubDomainName": "[parameters('accounts_foundry_mvnet')]", - "networkAcls": { - "defaultAction": "Allow", - "virtualNetworkRules": [], - "ipRules": [] - }, - "allowProjectManagement": true, - "defaultProject": "firstProject", - "associatedProjects": [ - "firstProject" - ], - "networkInjections": [ - { - "scenario": "agent", - "subnetArmId": "", - "useMicrosoftManagedNetwork": true - } - ], - "publicNetworkAccess": "Enabled" - } - }, - { - "type": "Microsoft.CognitiveServices/accounts/managednetworks", - "apiVersion": "2025-10-01-preview", - "name": "[concat(parameters('accounts_foundry_mvnet'),'/default')]", - "dependsOn": [ - "[resourceId('Microsoft.CognitiveServices/accounts', parameters('accounts_foundry_mvnet'))]" - ], - "properties": { - "managedNetwork": { - "IsolationMode": "AllowOnlyApprovedOutbound", - "managedNetworkKind": "V2" - } - } - } - ] -} diff --git a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/update-outbound-rules-cli/batch-outbound-rules.json b/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/update-outbound-rules-cli/batch-outbound-rules.json deleted file mode 100644 index f3ad55419..000000000 --- a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/update-outbound-rules-cli/batch-outbound-rules.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "id": "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.CognitiveServices/accounts/{accountName}/managedNetworks/default", - "name": "default", - "type": "Microsoft.CognitiveServices/accounts/managedNetworks/outboundRules", - "properties": { - "IsolationMode": "AllowOnlyApprovedOutbound", - "outboundRules": { - "storage-rule": { - "type": "PrivateEndpoint", - "destination": { - "serviceResourceId": "/subscriptions/{storageSubscriptionId}/resourceGroups/{storageResourceGroupName}/providers/Microsoft.Storage/storageAccounts/{storageName}", - "subresourceTarget": "blob", - "sparkEnabled": false, - "sparkStatus": "Inactive" - }, - "category": "UserDefined" - }, - "aisearch-rule": { - "type": "PrivateEndpoint", - "destination": { - "serviceResourceId": "/subscriptions/{aiSearchSubscriptionId}/resourceGroups/{aiSearchResourceGroupName}/providers/Microsoft.Search/searchServices/{aiSearchName}", - "subresourceTarget": "searchService", - "sparkEnabled": false, - "sparkStatus": "Inactive" - }, - "category": "UserDefined" - }, - "cosmosdb-rule": { - "type": "PrivateEndpoint", - "destination": { - "serviceResourceId": "/subscriptions/{cosmosDBSubscriptionId}/resourceGroups/{cosmosDBResourceGroupName}/providers/Microsoft.DocumentDB/databaseAccounts/{cosmosDBName}", - "subresourceTarget": "Sql", - "sparkEnabled": false, - "sparkStatus": "Inactive" - }, - "category": "UserDefined" - } - }, - "managedNetworkKind": "V2" - } -} diff --git a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/update-outbound-rules-cli/outbound-rule-cdb.json b/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/update-outbound-rules-cli/outbound-rule-cdb.json deleted file mode 100644 index af6cd5653..000000000 --- a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/update-outbound-rules-cli/outbound-rule-cdb.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "id": "/subscriptions/{sub-id}/resourceGroups/{rg-name}/providers/Microsoft.CognitiveServices/accounts/{foundry-resource-name}/managednetworks/default/outboundRules/test-rule-cdb", - "name": "test-rule-cdb", - "type": "Microsoft.CognitiveServices/accounts/managednetworks/outboundRules", - "properties": { - "type": "PrivateEndpoint", - "destination": { - "serviceResourceId": "/subscriptions/{sub-id}/resourceGroups/{rg-name}/providers/Microsoft.DocumentDB/databaseAccounts/{cdb-name}", - "subresourceTarget": "Sql" - }, - "category": "UserDefined" - } -} \ No newline at end of file diff --git a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/update-outbound-rules-cli/outbound-rule-search.json b/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/update-outbound-rules-cli/outbound-rule-search.json deleted file mode 100644 index 8ca296087..000000000 --- a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/update-outbound-rules-cli/outbound-rule-search.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "id": "/subscriptions/{sub-id}/resourceGroups/{rg-name}/providers/Microsoft.CognitiveServices/accounts/{foundry-account-name}/managednetworks/default/outboundRules/test-rule-search", - "name": "test-rule-search", - "type": "Microsoft.CognitiveServices/accounts/managednetworks/outboundRules", - "properties": { - "type": "PrivateEndpoint", - "destination": { - "serviceResourceId": "/subscriptions/{sub-id}/resourceGroups/{rg-name}/providers/Microsoft.Search/searchServices/{search-service-name}", - "subresourceTarget": "searchService" - }, - "category": "UserDefined" - } -} \ No newline at end of file diff --git a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/update-outbound-rules-cli/outbound-rule-storage.json b/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/update-outbound-rules-cli/outbound-rule-storage.json deleted file mode 100644 index 264be546c..000000000 --- a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/update-outbound-rules-cli/outbound-rule-storage.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "id": "/subscriptions/{sub-id}/resourceGroups/{rg-name}/providers/Microsoft.CognitiveServices/accounts/{foundry-account-name}/managednetworks/default/outboundRules/test-rule-str", - "name": "test-rule-str", - "type": "Microsoft.CognitiveServices/accounts/managednetworks/outboundRules", - "properties": { - "type": "PrivateEndpoint", - "destination": { - "serviceResourceId": "/subscriptions/{sub-id}/resourceGroups/{rg-name}/providers/Microsoft.Storage/storageAccounts/{storage-account}", - "subresourceTarget": "blob" - }, - "category": "UserDefined" - } -} \ No newline at end of file diff --git a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/README.md b/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/README.md similarity index 51% rename from infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/README.md rename to infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/README.md index 6f216c2ba..0afe3046b 100644 --- a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/README.md +++ b/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/README.md @@ -1,5 +1,5 @@ --- -description: This set of templates demonstrates how to set up Azure AI Agent Service with managed virtual network isolation with private network links to connect the agent to your secure data. +description: This set of templates demonstrates how to set up Microsoft Foundry Agent Service with managed virtual network isolation with private network links to connect the agent to your secure data. page_type: sample products: - azure @@ -10,31 +10,57 @@ languages: - json --- -# Azure AI Agent Service: Standard Agent Setup with Managed Virtual Network +# Microsoft Foundry: Standard Agent Setup with Managed Virtual Network + +> **NEW** +> For support on deploying the right network isolation template, check out the [GitHub Copilot for Azure skill for private networking](https://github.com/microsoft/GitHub-Copilot-for-Azure/blob/main/plugin/skills/microsoft-foundry/resource/private-network/private-network.md) set-up! --- ## Overview -This infrastructure-as-code (IaC) solution deploys a network-secured Azure AI agent environment with private networking and role-based access control (RBAC). +This infrastructure-as-code (IaC) solution deploys a network-secured agent environment with private networking and role-based access control (RBAC). -Standard setup supports private network isolation through utilizing **Managed Virtual Network** approach. Managed Virtual Network support is a public preview feature and requires a feature flag to allowlst your subscription. Preview feature is titled "AI.ManagedVnetPreview" in the Azure Portal +Standard setup supports private network isolation through utilizing **Managed Virtual Network** approach. With Managed Virtual Network, Microsoft manages the virtual network on your behalf, simplifying network configuration while maintaining enterprise-grade isolation. + +--- + +## When to Use This Template + +Use this template when you need: +- **Network isolation without managing your own VNet** β€” Microsoft manages the virtual network infrastructure for you +- **Standard agent setup with BYO resources** β€” Customer-managed Storage, Cosmos DB, and AI Search for data residency and compliance +- **Simplified networking** β€” No subnet planning, delegation, or NSG management required +- **System Assigned Managed Identity** β€” Simplified identity management with platform-managed credentials + +### Template Decision Guide + +Use the table below to choose the right infrastructure template for your scenario: + +| Template | Agent Type | Networking | Identity | Key Use Case | +|----------|-----------|------------|----------|-------------| +| [**15**](../15-private-network-standard-agent-setup/) | Standard (BYO resources) | BYO VNet + Private Endpoints | System Assigned MI | E2E network isolation with full agent capabilities | +| [**19**](../19-private-network-agent-tools/) | Standard (BYO resources) | BYO VNet + Private Endpoints | System Assigned MI | Same as 15 **plus** tools behind VNet (MCP, OpenAPI, Functions, A2A) | +| [**17**](../17-private-network-standard-user-assigned-identity-agent-setup/) | Standard (BYO resources) | BYO VNet + Private Endpoints | **User Assigned MI** | Same as 15 but with user-managed identity | +| [**16**](../16-private-network-standard-agent-apim-setup/) | Standard (BYO resources) | BYO VNet + Private Endpoints | System Assigned MI | Same as 15 **plus** private APIM integration | +| [**18** (this template)](../18-managed-virtual-network/) | Standard (BYO resources) | **Managed VNet** (Microsoft-managed) | System Assigned MI | Network isolation without managing your own VNet | +| [**15a**](../15a-private-network-evaluation-only-setup/) | Evaluation only | BYO VNet + Private Endpoints | System Assigned MI | Minimal setup for evaluation β€” no Cosmos DB, AI Search, or capability host | +| [**11**](../11-private-network-basic-vnet/) | **Basic** (platform-managed) | BYO VNet injection | System Assigned MI | Basic agents with VNet isolation β€” no BYO resources needed | +| [**41**](../41-standard-agent-setup/) | Standard (BYO resources) | **Public** (no VNet) | System Assigned MI | Standard agents without network isolation | +| [**40**](../40-basic-agent-setup/) | **Basic** (platform-managed) | **Public** (no VNet) | System Assigned MI | Simplest setup β€” no BYO resources, no private networking | --- ## Deploy to Azure -[![Deploy To Azure](https://raw.githubusercontent.com/Azure/azure-quickstart-templates/master/1-CONTRIBUTION-GUIDE/images/deploytoazure.svg?sanitize=true)](https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fraw.githubusercontent.com%2Fazure-ai-foundry%2Ffoundry-samples%2Frefs%2Fheads%2Fmain%2Finfrastructure%2Finfrastructure-setup-bicep%2F18-managed-virtual-network-preview%2Fazuredeploy.json) +[![Deploy To Azure](https://raw.githubusercontent.com/Azure/azure-quickstart-templates/master/1-CONTRIBUTION-GUIDE/images/deploytoazure.svg?sanitize=true)](https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fraw.githubusercontent.com%2Fazure-ai-foundry%2Ffoundry-samples%2Frefs%2Fheads%2Fmain%2Finfrastructure%2Finfrastructure-setup-bicep%2F18-managed-virtual-network%2Fazuredeploy.json) --- ## Prerequisites -1. **Register your subscription with the Preview Features in Azure Portal for Managed VNET** - - Register your subscription for the preview feature "AI.ManagedVnetPreview" in the Azure Portal under "Preview Features". . - 1. **Active Azure subscription with appropriate permissions** - - **Azure AI Account Owner**: Needed to create a Foundry account and project - - **Owner or Role Based Access Administrator**: Needed to assign RBAC to the required resources (Cosmos DB, Azure AI Search, Storage) - - **Azure AI User**: Needed to create and edit agents + - **Foundry Account Owner**: Needed to create the Microsoft Foundry account and project. + - **Owner or Role Based Access Administrator**: Needed to assign RBAC on the Azure resources used by this template. + - **Foundry User**: Needed to create and use agents, projects, or evaluation workloads after deployment. 1. Azure CLI installed and configured on your local workstation or deployment pipeline server. Azure CLI support is required to run the 'az rest' commands to update your managed virtual network. @@ -53,23 +79,40 @@ Standard setup supports private network isolation through utilizing **Managed Vi 1. Network administrator permissions (if operating in a restricted or enterprise environment) -1. Sufficient quota for all resources in your target Azure region - * If no parameters are passed in, this template creates an Azure AI Foundry resource, Foundry project, Azure Cosmos DB for NoSQL, Azure AI Search, and Azure Storage account +1. Sufficient quota for all resources required by this template in the target Azure region, including model deployment quota. + * If no parameters are passed in, this template creates a Microsoft Foundry resource, Foundry project, Azure Cosmos DB for NoSQL, Azure AI Search, and Azure Storage account --- ## Pre-Deployment Steps -### Limitations -1. A managed network Foundry resource is only deployable via the `main.bicep` template in the folder `18-managed-virtual-network-preview` in foundry-samples. -1. If you create FQDN outbound rules when the managed virtual network is in Allow Only Approved Outbound mode, a managed Azure Firewall is created which comes with associated Firewall costs. The FQDN outbound rules only support ports 80 and 443. -1. Managed virtual network isolation cannot be disabled after enabling. There is no upgrade path from custom virtual network set-up to managed virtual network. A Foundry resource redeployment is required. Deleting your Foundry resource deletes the managed virtual network. -1. Outbound rules from the managed network must be created through Azure CLI. For the end-to-end secured Agent service set-up with a managed virtual network, the template creates the managed private endpoint to the associated Storage account. Private endpoints are not created to CosmosDB or AI Search. Please use instructions in the `outbound rules CLI ` for information on how to create the managed private endpoints. -1. Support for managed virtual network is only in the following regions: **East US, East US2, Japan East, France Central, UAE North, Brazil South, Spain Central, Germany West Central, Italy North, South Central US, West Central US, Australia East, Sweden Central, Canada East, South Africa North, West Europe, West US, West US 3, South India, and UK South.** -1. If you require private access to on-premises resources for your Foundry resource, please use the to [Application Gatway](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/access-on-premises-resources?view=foundry-classic&viewFallbackFrom=foundry) to configure on-premises access. The same set-up with a private endpoint to Application Gateway and setting up backend pools is supported. Both L4 and L7 traffic are now supported with the Application Gateway in GA. -1. Supports only Standard BYO resources Agents v1 and the Foundry classic experience. Basic Agents do not require network isolation. Support in the new Agents v2 and the new Foundry UI is coming soon. -1. End-to-end network isolation for Agent MCP tools with managed virtual network is currently not supported. Please use public MCP tools with managed network isolation Foundry. -1. For any feedback, please directly email meerakurup@microsoft.com +### Limitations / Known Issues + +1. You can deploy a managed network Foundry resource in three ways. + 1. Bicep template in this folder + 2. Terraform template in this repository: [`infrastructure-setup-terraform/18-managed-virtual-network`](../../../infrastructure-setup-terraform/18-managed-virtual-network/) + 3. `az rest` and Azure CLI commands `az cognitiveservices` documented in the [`azure-cli/`](azure-cli/) folder in this directory +1. There is no Azure Portal UI support to create the managed network yet. Support is coming soon. +1. Once your Foundry resource is created, ensure you have assigned the Foundry resource's managed identity the built-in role of `Azure AI Enterprise Network Connection Approver` (role ID: `b556d68e-0be0-4f35-a333-ad7ee1ce17ea`) to ensure the required private endpoint to the Foundry resource is created and approved. +1. You can't disable managed virtual network isolation after enabling it. There's no upgrade path from custom virtual network set-up to managed virtual network. A Foundry resource redeployment is required. Deleting your Foundry resource deletes the managed virtual network. +1. Support for managed virtual network is only in the following regions: **East US, East US2, Japan East, France Central, UAE North, Brazil South, Spain Central, Germany West Central, Italy North, South Central US, Australia East, Sweden Central, Canada East, South Africa North, West US, West US 3, South India, and UK South.** Additional region support to follow soon. +1. If you require private access to on-premises resources for your Foundry resource, use Application Gateway to configure on-premises access. The same set-up with a private endpoint to Application Gateway and setting up backend pools is supported. Both L4 and L7 traffic are now supported with the Application Gateway in GA. +1. If you create FQDN outbound rules when the managed virtual network is in **Allow Only Approved Outbound** mode, a managed Azure Firewall is created which comes with associated Firewall costs. The FQDN outbound rules only support ports 80 and 443. +1. You can't bring your own Azure Firewall to the managed virtual network. A managed firewall is automatically created for your Foundry account when you use **Allow Only Approved Outbound** mode. +1. You can't reuse the same managed firewall for multiple Foundry accounts. Each Foundry account creates its own managed firewall when you use **Allow Only Approved Outbound** mode. +1. To ensure your second created project inherits the networking settings of your first project and first Foundry resource, follow the steps in the [network secured Agent README](../15-private-network-standard-agent-setup/README.md) under __# (Optional) Adding Multiple Projects to AI Foundry Deployment__. This is required for new projects added to a Foundry resource seucred with managed network as well. + +### Account Deletion Prerequisites and Cleanup Guidance + +Before deleting an **Account** resource, it is essential to first delete the associated **Account Capability Host**. Failure to do so may result in residual dependenciesβ€”such as subnets and other provisioned resourcesβ€”remaining linked to the capability host. + +**Cleanup Options** + +**1. Full Account Removal**: To completely remove an account, you must delete and purge the account. Simply deleting the account is not sufficientβ€”you must purge so that deletion of the associated capability host is triggered. The service will automatically handle the removal of the capability host and any linked resources in the background. To purge the account, use the following [link](https://learn.microsoft.com/en-us/azure/ai-services/recover-purge-resources?tabs=azure-portal#purge-a-deleted-resource). Please allow approximately max of 20 minutes for all resources to be fully unlinked from the account. + +**2. Retain Account, Remove Capability Host**: If you intend to retain the account but remove the capability host, execute the script `deleteCaphost.sh` located in this folder. After deletion, allow approximately max of 20 minutes for all resources to be fully unlinked from the account. To recreate the capability host for the account, use the script `createCaphost.sh` located in the same folder. + +> **Important**: Before deleting the account capability host, ensure that the **project capability host** is deleted. ### Template Customization @@ -83,6 +126,28 @@ Note: If not provided, the following resources will be created automatically for #### Parameters +| Parameter | Description | Default | Required | +|-----------|-------------|---------|----------| +| `location` | Azure region for deployment | `eastus` | Yes | +| `aiServices` | Base name for the AI Services resource | `aiservices` | No | +| `firstProjectName` | Name for the Foundry project | `project` | No | +| `modelName` | Model to deploy | `gpt-4.1` | No | +| `modelFormat` | Model provider | `OpenAI` | No | +| `modelVersion` | Model version | `2025-04-14` | No | +| `modelSkuName` | Model deployment SKU | `GlobalStandard` | No | +| `modelCapacity` | Tokens per minute (TPM) capacity | `30` | No | +| `vnetName` | Virtual Network name | `agent-vnet-test` | No | +| `peSubnetName` | Subnet name for private endpoints | `pe-subnet` | No | +| `peSubnetPrefix` | Address prefix for PE subnet | `192.168.1.0/24` | No | +| `existingVnetResourceId` | Full ARM Resource ID of an existing VNet | `''` (creates new) | No | +| `vnetAddressPrefix` | Address space for new VNet | `192.168.0.0/16` | No | +| `aiSearchResourceId` | ARM Resource ID of existing AI Search | `''` (creates new) | No | +| `azureStorageAccountResourceId` | ARM Resource ID of existing Storage account | `''` (creates new) | No | +| `azureCosmosDBAccountResourceId` | ARM Resource ID of existing Cosmos DB | `''` (creates new) | No | +| `existingDnsZones` | Map of DNS zone names to resource groups | All empty (creates new) | No | + +#### BYO Resource Details + 1. **Use Existing Virtual Network and Subnets** To use an existing VNet and subnet, set the existingVnetResourceId parameter to the full Azure Resource ID of the target VNet and its address range, and provide the names of the required subnet. If the existing VNet is associated with private DNS zones, set the existingDnsZones parameter to the resource group name in which the zones are located. For example: @@ -158,15 +223,28 @@ Click the deploy to Azure button above to open the Azure portal and deploy the t az deployment group create --resource-group --template-file main.bicep --parameters main.bicepparam ``` -> **Note:** To access your Foundry resource securely, use either a VM, VPN, or ExpressRoute. +> **Note:** To access a private Foundry resource securely, use one of the following: +> - A VM or jump box on the virtual network, optionally accessed through Azure Bastion +> - Azure VPN Gateway +> - Azure ExpressRoute ---- +### Cleanup + +To delete all resources created by this template: + +```bash +az group delete --name --yes --no-wait +``` -## Network Secured Agent Project Architecture Deep Dive +> **Important**: Follow the [Account Deletion Prerequisites and Cleanup Guidance](#account-deletion-prerequisites-and-cleanup-guidance) to properly purge the account and wait for the capability host to fully unlink (~20 minutes). + +--- + +## Managed Network Secured Agent Project Architecture Deep Dive ### Core Components -**Azure AI Foundry** resource +**Microsoft Foundry** resource - Central orchestration point - Manages service connections - Set networking and policy configurations @@ -183,11 +261,11 @@ Click the deploy to Azure button above to open the Azure portal and deploy the t - BYO Search: All vector stores created by the agent leverage the customer’s Azure AI Search resource. - BYO Thread Storage: All customer messages and conversation history will be stored in the customer’s own Azure Cosmos DB account. -By bundling these BYO features (file storage, search, and thread storage), the standard setup guarantees that your deployment is secure by default. All data processed by Azure AI Foundry Agent Service is automatically stored at rest in your own Azure resources, helping you meet internal policies, compliance requirements, and enterprise security standards. +By bundling these BYO features (file storage, search, and thread storage), the standard setup guarantees that your deployment is secure by default. All data processed by Microsoft Foundry Agent Service is automatically stored at rest in your own Azure resources, helping you meet internal policies, compliance requirements, and enterprise security standards. ### Azure Resources Created -Azure AI Foundry (Cognitive Services) +Microsoft Foundry (Cognitive Services) - Type: Microsoft.CognitiveServices/accounts - API version: 2025-04-01-preview - Kind: AIServices @@ -256,7 +334,7 @@ Network Security **Private Endpoints** Private endpoints ensure secure, internal-only connectivity. Private endpoints are created for the following: -- Azure AI Foundry +- Microsoft Foundry - Azure AI Search - Azure Storage - Azure Cosmos DB @@ -264,7 +342,7 @@ Private endpoints ensure secure, internal-only connectivity. Private endpoints a **Private DNS Zones** | Private Link Resource Type | Sub Resource | Private DNS Zone Name | Public DNS Zone Forwarders | |----------------------------|--------------|------------------------|-----------------------------| -| **Azure AI Foundry** | account | `privatelink.cognitiveservices.azure.com`
`privatelink.openai.azure.com`
`privatelink.services.ai.azure.com` | `cognitiveservices.azure.com`
`openai.azure.com`
`services.ai.azure.com` | +| **Microsoft Foundry** | account | `privatelink.cognitiveservices.azure.com`
`privatelink.openai.azure.com`
`privatelink.services.ai.azure.com` | `cognitiveservices.azure.com`
`openai.azure.com`
`services.ai.azure.com` | | **Azure AI Search** | searchService| `privatelink.search.windows.net` | `search.windows.net` | | **Azure Cosmos DB** | Sql | `privatelink.documents.azure.com` | `documents.azure.com` | | **Azure Storage** | blob | `privatelink.blob.core.windows.net` | `blob.core.windows.net` | @@ -293,9 +371,13 @@ Private endpoints ensure secure, internal-only connectivity. Private endpoints a - Storage Blob Data Owner - **Cosmos DB for NoSQL** - Cosmos DB Operator (`230815da-be43-4aae-9cb4-875f7bd000aa`) - - Cosmos DB Built-in Data Contributor(`00000000-0000-0000-0000-000000000002`) - - **Azure AI Foundry Resource** - - Contributor (`b24988ac-6180-42a0-ab88-20f7382dd24c`) Required role for the Foundry account to accept all private endpoints created in the managed VNET. This role will be updated to the Azure AI Enterprise Network Connection Approver role which has a smaller scope. + - Cosmos DB Built-in Data Contributor (`00000000-0000-0000-0000-000000000002`) + - Three containers will automatically be provisioned during the create capability host process: + - Cosmos DB for NoSQL container: `<${projectWorkspaceId}>-thread-message-store` + - Cosmos DB for NoSQL container: `<${projectWorkspaceId}>-system-thread-message-store` + - Cosmos DB for NoSQL container: `<${projectWorkspaceId}>-agent-entity-store` + - **Microsoft Foundry Resource** + - Azure AI Enterprise Network Connection Approver (`b556d68e-0be0-4f35-a333-ad7ee1ce17ea`) Required role for the Foundry account to accept all private endpoints created in the managed VNET. --- ## Module Structure @@ -303,7 +385,7 @@ Private endpoints ensure secure, internal-only connectivity. Private endpoints a ```text modules-network-secured/ β”œβ”€β”€ add-project-capability-host.bicep # Configuring the project's capability host -β”œβ”€β”€ ai-account-identity.bicep # Azure AI Foundry deployment and configuration +β”œβ”€β”€ ai-account-identity.bicep # Microsoft Foundry deployment and configuration β”œβ”€β”€ ai-project-identity.bicep # Foundry project deployment and connection configuration β”œβ”€β”€ ai-search-role-assignments.bicep # AI Search RBAC configuration β”œβ”€β”€ azure-storage-account-role-assignment.bicep # Storage Account RBAC configuration @@ -319,8 +401,34 @@ modules-network-secured/ β”œβ”€β”€ subnet.bicep # Setting the subnet for Agent network injection β”œβ”€β”€ validate-existing-resources.bicep # Validate existing CosmosDB, Storage, and Search to template deployment └── vnet.bicep # Deploying a new virtual network + +azure-cli/ +β”œβ”€β”€ azure-cli.md # End-to-end CLI walkthrough for deploying managed VNet via Azure CLI +└── outbound-rules-az-rest.md # az rest commands for creating outbound rules (Private Endpoint, FQDN, Service Tag) ``` +--- + +## Deploy via Azure CLI + +As an alternative to the Bicep template, you can deploy a managed virtual network entirely using Azure CLI commands. The [`azure-cli/`](azure-cli/) folder contains step-by-step documentation for this approach. + +### Files + +| File | Description | +|------|-------------| +| [`azure-cli.md`](azure-cli/azure-cli.md) | Complete end-to-end walkthrough covering account creation with network injections, RBAC role assignment, managed network creation, outbound rule configuration, and deployment verification β€” all using `az cognitiveservices` CLI commands (requires Azure CLI 2.86.0+). | +| [`outbound-rules-az-rest.md`](azure-cli/outbound-rules-az-rest.md) | `az rest` commands for creating individual and batch outbound rules (Private Endpoint rules for Storage, Cosmos DB, and AI Search). Use these when you need direct REST API access or are on an older CLI version. | + +### When to use the CLI approach + +- You want to add a managed network to an **existing** Foundry resource that was created without one +- You prefer imperative CLI commands over declarative Bicep/Terraform templates +- You need to manage outbound rules independently from the initial infrastructure deployment +- You want to quickly test or prototype managed network configurations + +> **Note:** The `az cognitiveservices account managed-network` commands require **Azure CLI 2.86.0 or later**. The commands are currently in preview. For older CLI versions, use the `az rest` equivalents documented in [`outbound-rules-az-rest.md`](azure-cli/outbound-rules-az-rest.md). + ## Maintenance ### Regular Tasks @@ -341,9 +449,9 @@ modules-network-secured/ ## References -- [Azure AI Foundry Networking Documentation](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/configure-private-link?tabs=azure-portal&pivots=fdp-project) -- [Azure AI Foundry RBAC Documentation](https://learn.microsoft.com/en-us/azure/ai-foundry/concepts/rbac-azure-ai-foundry?pivots=fdp-project) +- [Configure managed virtual network for Microsoft Foundry](https://learn.microsoft.com/en-us/azure/foundry/how-to/managed-virtual-network) +- [Microsoft Foundry Networking Documentation](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/configure-private-link?tabs=azure-portal&pivots=fdp-project) +- [Microsoft Foundry RBAC Documentation](https://learn.microsoft.com/en-us/azure/ai-foundry/concepts/rbac-azure-ai-foundry?pivots=fdp-project) - [Private Endpoint Documentation](https://learn.microsoft.com/en-us/azure/private-link/) - [RBAC Documentation](https://learn.microsoft.com/en-us/azure/role-based-access-control/) - [Network Security Best Practices](https://learn.microsoft.com/en-us/azure/security/fundamentals/network-best-practices) -- Managed virtual network (public documentation coming soon) diff --git a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/azure-cli/azure-cli.md b/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/azure-cli/azure-cli.md new file mode 100644 index 000000000..ec026409d --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/azure-cli/azure-cli.md @@ -0,0 +1,317 @@ +# Managed Network and Outbound Rule CLI Commands + +This document summarizes the new Azure CLI commands for configuring managed networks and outbound rules on Azure Cognitive Services accounts used with Microsoft Foundry. + +These commands help you configure network isolation and control outbound traffic from a managed network. + +## Command Groups + +```azurecli +az cognitiveservices account managed-network +az cognitiveservices account managed-network outbound-rule +``` + +## Managed Network Commands + +Use these commands to create, update, show, and provision the managed network for an Azure Cognitive Services account. You can run these commands after the Foundry resource was created. + +**Note:** Ensure you have assigned the Foundry account's managed identity the Azure AI Enterprise Network Connection Approver. This ensures the private endpoint to the Foundry resource from the managed VNET gets created. Command would be like this: + +`az role assignment create --assignee-object-id --assignee-principal-type ServicePrincipal --role "Azure AI Enterprise Network Connection Approver" --scope /subscriptions//resourceGroups/` + +| Command | Description | +| --- | --- | +| `az cognitiveservices account managed-network create` | Creates a managed network. Supports `allow_internet_outbound` or `allow_only_approved_outbound` isolation mode and an optional firewall SKU. | +| `az cognitiveservices account managed-network update` | Updates managed network settings for an existing account. | +| `az cognitiveservices account managed-network show` | Shows the current managed network settings for an account. | +| `az cognitiveservices account managed-network provision-network` | Provisions the managed network after it is configured. | + +### Create a managed network with internet outbound access + +Use `allow_internet_outbound` when the managed network should allow outbound internet access. + +```azurecli +az cognitiveservices account managed-network create \ + --resource-group myResourceGroup \ + --name myAccount \ + --managed-network allow_internet_outbound +``` + +### Create a managed network with approved outbound only + +Use `allow_only_approved_outbound` when outbound traffic should be restricted to approved outbound rules. The optional `--firewall-sku` parameter configures the managed network firewall SKU. + +```azurecli +az cognitiveservices account managed-network create \ + --resource-group myResourceGroup \ + --name myAccount \ + --managed-network allow_only_approved_outbound \ + --firewall-sku Standard +``` + +### Show managed network settings + +Use `show` to verify the current managed network configuration for an account. + +```azurecli +az cognitiveservices account managed-network show \ + --resource-group myResourceGroup \ + --name myAccount +``` + +### Provision the managed network + +Use `provision-network` after configuring the managed network to apply and provision the network settings. + +```azurecli +az cognitiveservices account managed-network provision-network \ + --resource-group myResourceGroup \ + --name myAccount +``` + +## Outbound Rule Commands + +Use outbound rules to define approved destinations when the managed network uses approved outbound access. + +| Command | Description | +| --- | --- | +| `az cognitiveservices account managed-network outbound-rule list` | Lists all outbound rules for the managed network. | +| `az cognitiveservices account managed-network outbound-rule show` | Shows details for a specific outbound rule. | +| `az cognitiveservices account managed-network outbound-rule remove` | Deletes an outbound rule from the managed network. | +| `az cognitiveservices account managed-network outbound-rule set` | Creates or updates one outbound rule. Supports FQDN, private endpoint, and service tag rules. | +| `az cognitiveservices account managed-network outbound-rule bulk-set` | Creates or updates multiple outbound rules from a YAML or JSON file. | + +## Outbound Rule Types + +The `set` command supports these outbound rule types: + +| Type | Description | Example destination | +| --- | --- | --- | +| `fqdn` | Allows outbound traffic to a fully qualified domain name. | `"*.openai.azure.com"` | +| `privateendpoint` | Allows outbound traffic through a private endpoint rule. | Private endpoint configuration JSON | +| `servicetag` | Allows outbound traffic to an Azure service tag, protocol, and port range. | `'{"serviceTag":"Storage","protocol":"TCP","portRanges":"443"}'` | + +### Create or update an FQDN outbound rule + +Use an FQDN rule to allow traffic to a domain name or wildcard domain. + +```azurecli +az cognitiveservices account managed-network outbound-rule set \ + --resource-group myResourceGroup \ + --name myAccount \ + --rule my-fqdn-rule \ + --type fqdn \ + --destination "*.openai.azure.com" +``` + +### Create or update a service tag outbound rule + +Use a service tag rule to allow traffic to an Azure service tag over a specific protocol and port range. + +```azurecli +az cognitiveservices account managed-network outbound-rule set \ + --resource-group myResourceGroup \ + --name myAccount \ + --rule my-servicetag-rule \ + --type servicetag \ + --destination '{"serviceTag":"Storage","protocol":"TCP","portRanges":"443"}' +``` + +### List outbound rules + +Use `list` to view all configured outbound rules for the managed network. + +```azurecli +az cognitiveservices account managed-network outbound-rule list \ + --resource-group myResourceGroup \ + --name myAccount +``` + +### Show an outbound rule + +Use `show` to inspect one outbound rule by name. + +```azurecli +az cognitiveservices account managed-network outbound-rule show \ + --resource-group myResourceGroup \ + --name myAccount \ + --rule my-fqdn-rule +``` + +### Bulk create or update outbound rules + +Use `bulk-set` to create or update multiple outbound rules from a YAML or JSON file. + +```azurecli +az cognitiveservices account managed-network outbound-rule bulk-set \ + --resource-group myResourceGroup \ + --name myAccount \ + --file rules.yaml +``` + +### Remove an outbound rule + +Use `remove` to delete an outbound rule from the managed network. + +```azurecli +az cognitiveservices account managed-network outbound-rule remove \ + --resource-group myResourceGroup \ + --name myAccount \ + --rule my-fqdn-rule +``` + +## End-to-End Deployment: CLI Walkthrough + +This section walks through the full sequence of commands to deploy a Foundry resource with a managed virtual network using the CLI. Each step must be completed in order. + +> **Important:** The `az cognitiveservices account managed-network` CLI commands require a CLI extension. Please update to version 2.86.0. If the commands are not recognized, use the equivalent `az rest` commands shown below. + +### Prerequisites + +- Azure CLI installed and authenticated (`az login`) +- A resource group in a [supported region](#supported-regions) + +### Step 1: Create the AI Services account with network injections + +The account must be created with `customSubDomainName`, `allowProjectManagement`, and `networkInjections` set **at creation time**. These properties cannot be added after the account is created. + +> **Important:** you must use `az rest` commands for account creation with network injections as the azure CLI does not yet support creating a Foundry resource with network injection + +```azurecli +az rest --method PUT \ + --url "https://management.azure.com/subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts/?api-version=2025-10-01-preview" \ + --body '{ + "location": "", + "kind": "AIServices", + "sku": { "name": "S0" }, + "identity": { "type": "SystemAssigned" }, + "properties": { + "allowProjectManagement": true, + "customSubDomainName": "", + "networkInjections": [ + { + "scenario": "agent", + "subnetArmId": "", + "useMicrosoftManagedNetwork": true + } + ], + "disableLocalAuth": false + } + }' \ + --headers "Content-Type=application/json" +``` + +Wait for `provisioningState` to reach `Succeeded` before proceeding: + +```azurecli +az rest --method GET \ + --url "https://management.azure.com/subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts/?api-version=2025-10-01-preview" \ + --query "properties.provisioningState" -o tsv +``` + +### Step 2: Get the managed identity principal ID + +Retrieve the system-assigned managed identity principal ID from the account: + +```azurecli +az cognitiveservices account show \ + --resource-group \ + --name \ + --query identity.principalId -o tsv +``` + +### Step 3: Assign the Network Connection Approver role + +Assign the **Azure AI Enterprise Network Connection Approver** role to the account's managed identity. This allows managed network private endpoints to be auto-approved. + +```azurecli +az role assignment create \ + --assignee-object-id \ + --assignee-principal-type ServicePrincipal \ + --role "Azure AI Enterprise Network Connection Approver" \ + --scope /subscriptions//resourceGroups/ +``` + +> **Note:** If your target resources (Storage, Cosmos DB, AI Search) are in a different resource group, scope the role assignment to that resource group or to the subscription. + +### Step 4: Create the managed network + +Create the managed network child resource on the account. This establishes the network isolation mode and provisions the network infrastructure. + +Using the CLI (when available): + +```azurecli +az cognitiveservices account managed-network create \ + --resource-group \ + --name \ + --managed-network allow_only_approved_outbound \ + --firewall-sku Standard +``` + +### Step 5: Add outbound rules + +After the managed network is active, add outbound rules for approved destinations. See the [Outbound Rule Commands](#outbound-rule-commands) section above for the full set of CLI commands. + +**FQDN rule example:** + +```azurecli +az cognitiveservices account managed-network outbound-rule set \ + --resource-group \ + --name \ + --rule my-fqdn-rule \ + --type fqdn \ + --destination "google.com" +``` + +**Service tag rule example:** + +```azurecli +az cognitiveservices account managed-network outbound-rule set \ + --resource-group \ + --name \ + --rule my-servicetag-rule \ + --type servicetag \ + --destination '{"serviceTag":"Storage","protocol":"TCP","portRanges":"443"}' +``` + +**Private endpoint rule example** (see [outbound-rules-az-rest.md](outbound-rules-az-rest.md) for `az rest` examples): + +```azurecli +az cognitiveservices account managed-network outbound-rule set \ + --resource-group \ + --name \ + --rule my-pe-rule \ + --type privateendpoint \ + --destination '{"serviceResourceId":"/subscriptions//resourceGroups//providers/Microsoft.Storage/storageAccounts/","subresourceTarget":"blob"}' +``` + +### Step 6: Verify the deployment + +Confirm the managed network is active: + +```azurecli +az cognitiveservices account managed-network show \ + --resource-group \ + --name +``` + +List all outbound rules and their status: + +```azurecli +az cognitiveservices account managed-network outbound-rule list \ + --resource-group \ + --name +``` + +Show a specific outbound rule: + +```azurecli +az cognitiveservices account managed-network outbound-rule show \ + --resource-group \ + --name \ + --rule my-fqdn-rule +``` + +### Supported regions + +The managed virtual network feature is available in the following regions: East US, East US 2, Japan East, France Central, UAE North, Brazil South, Spain Central, Germany West Central, Italy North, South Central US, Australia East, Sweden Central, Canada East, South Africa North, West US, West US 3, South India, UK South. \ No newline at end of file diff --git a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/update-outbound-rules-cli/outbound-rule-cli.md b/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/azure-cli/outbound-rules-az-rest.md similarity index 71% rename from infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/update-outbound-rules-cli/outbound-rule-cli.md rename to infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/azure-cli/outbound-rules-az-rest.md index 2080ce654..e39fd44dc 100644 --- a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/update-outbound-rules-cli/outbound-rule-cli.md +++ b/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/azure-cli/outbound-rules-az-rest.md @@ -1,10 +1,13 @@ -## Outbound Rules CLI +## Outbound Rules az rest CLI + +Please follow this documentation to run `az rest` commands to create outbound rules. Please follow the other document in this folder for `az cognitiveservices` commands to create the managed network and create outbound rules. ### Outbound Rule for Storage account Below is the CLI command to create an outbound rule from the managed VNET to your storage account. In the sample template we create the managed VNET PE for storage, but you will need two more for your CosmosDB resource and your Search resource. +```bash az rest --method PUT --url 'https://management.azure.com/subscriptions/{sub-id}/resourceGroups/{rg-name}/providers/Microsoft.CognitiveServices/accounts/{foundry-account}/managedNetworks/default/outboundRules/test-rule?api-version=2025-10-01-preview' \ --body '{ "id": "/subscriptions/{sub-id}/resourceGroups/{rg-name}/providers/Microsoft.CognitiveServices/accounts/{foundry-account}/managedNetworks/default/outboundRules/test-rule-str", @@ -19,11 +22,13 @@ az rest --method PUT --url 'https://management.azure.com/subscriptions/{sub-id}/ "category": "UserDefined" } }' +``` ### Outbound Rule for CDB account Below is the CLI command to create an outbound rule from the managed VNET to your CDB account. +```bash az rest --method PUT --url 'https://management.azure.com/subscriptions/{sub-id}/resourceGroups/{rg-name}/providers/Microsoft.CognitiveServices/accounts/{foundry-account}/managedNetworks/default/outboundRules/test-rule?api-version=2025-10-01-preview' \ --body '{ "id": "/subscriptions/{sub-id}/resourceGroups/{rg-name}/providers/Microsoft.CognitiveServices/accounts/{foundry-account}/managedNetworks/default/outboundRules/test-rule-cdb", @@ -38,11 +43,13 @@ az rest --method PUT --url 'https://management.azure.com/subscriptions/{sub-id}/ "category": "UserDefined" } }' +``` ### Outbound Rule for Search account Below is the CLI command to create an outbound rule from the managed VNET to your Search account. +```bash az rest --method PUT --url 'https://management.azure.com/subscriptions/{sub-id}/resourceGroups/{rg-name}/providers/Microsoft.CognitiveServices/accounts/{foundry-account}/managedNetworks/default/outboundRules/test-rule?api-version=2025-10-01-preview' \ --body '{ "id": "/subscriptions/{sub-id}/resourceGroups/{rg-name}/providers/Microsoft.CognitiveServices/accounts/{foundry-account}/managedNetworks/default/outboundRules/test-rule-search", @@ -57,6 +64,7 @@ az rest --method PUT --url 'https://management.azure.com/subscriptions/{sub-id}/ "category": "UserDefined" } }' +``` # Batch Outbound Rules CLI @@ -101,3 +109,48 @@ az rest --method POST --uri $uri --body $body - Run this after the main Bicep deployment completes - The managed network must already exist before running this command +### batch-outbound-rules.json + +```json +{ + "id": "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.CognitiveServices/accounts/{accountName}/managedNetworks/default", + "name": "default", + "type": "Microsoft.CognitiveServices/accounts/managedNetworks/outboundRules", + "properties": { + "IsolationMode": "AllowOnlyApprovedOutbound", + "outboundRules": { + "storage-rule": { + "type": "PrivateEndpoint", + "destination": { + "serviceResourceId": "/subscriptions/{storageSubscriptionId}/resourceGroups/{storageResourceGroupName}/providers/Microsoft.Storage/storageAccounts/{storageName}", + "subresourceTarget": "blob", + "sparkEnabled": false, + "sparkStatus": "Inactive" + }, + "category": "UserDefined" + }, + "aisearch-rule": { + "type": "PrivateEndpoint", + "destination": { + "serviceResourceId": "/subscriptions/{aiSearchSubscriptionId}/resourceGroups/{aiSearchResourceGroupName}/providers/Microsoft.Search/searchServices/{aiSearchName}", + "subresourceTarget": "searchService", + "sparkEnabled": false, + "sparkStatus": "Inactive" + }, + "category": "UserDefined" + }, + "cosmosdb-rule": { + "type": "PrivateEndpoint", + "destination": { + "serviceResourceId": "/subscriptions/{cosmosDBSubscriptionId}/resourceGroups/{cosmosDBResourceGroupName}/providers/Microsoft.DocumentDB/databaseAccounts/{cosmosDBName}", + "subresourceTarget": "Sql", + "sparkEnabled": false, + "sparkStatus": "Inactive" + }, + "category": "UserDefined" + } + }, + "managedNetworkKind": "V2" + } +} +``` \ No newline at end of file diff --git a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/azuredeploy.json b/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/azuredeploy.json similarity index 96% rename from infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/azuredeploy.json rename to infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/azuredeploy.json index 3904cced9..83f80f7ff 100644 --- a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/azuredeploy.json +++ b/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/azuredeploy.json @@ -4,8 +4,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "15609984867232984221" + "version": "0.42.1.51946", + "templateHash": "10726822887583020348" } }, "parameters": { @@ -250,8 +250,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "17445593936032020020" + "version": "0.42.1.51946", + "templateHash": "5439878820994143115" } }, "parameters": { @@ -344,8 +344,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "81356089139070276" + "version": "0.42.1.51946", + "templateHash": "16620010525527418603" } }, "parameters": { @@ -474,8 +474,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "1788095536881131913" + "version": "0.42.1.51946", + "templateHash": "11496589636440152813" } }, "parameters": { @@ -547,8 +547,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "8025358656626191442" + "version": "0.42.1.51946", + "templateHash": "8762424559716292746" } }, "parameters": { @@ -685,8 +685,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "8798681219838722072" + "version": "0.42.1.51946", + "templateHash": "9631735419288057649" } }, "parameters": { @@ -732,9 +732,9 @@ { "type": "Microsoft.Authorization/roleAssignments", "apiVersion": "2022-04-01", - "name": "[guid(resourceId('Microsoft.CognitiveServices/accounts', parameters('accountName')), 'b24988ac-6180-42a0-ab88-20f7382dd24c', resourceGroup().id)]", + "name": "[guid(resourceId('Microsoft.CognitiveServices/accounts', parameters('accountName')), 'b556d68e-0be0-4f35-a333-ad7ee1ce17ea', resourceGroup().id)]", "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b24988ac-6180-42a0-ab88-20f7382dd24c')]", + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b556d68e-0be0-4f35-a333-ad7ee1ce17ea')]", "principalId": "[reference(resourceId('Microsoft.CognitiveServices/accounts', parameters('accountName')), '2025-10-01-preview', 'full').identity.principalId]", "principalType": "ServicePrincipal" }, @@ -799,8 +799,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "15994578372975581196" + "version": "0.42.1.51946", + "templateHash": "15549652612714258938" } }, "parameters": { @@ -978,8 +978,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "2754228344238136934" + "version": "0.42.1.51946", + "templateHash": "16516106179017566598" } }, "parameters": { @@ -1208,15 +1208,6 @@ }, "isolationMode": { "value": "[parameters('isolationMode')]" - }, - "storageName": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('dependencies-{0}-deployment', variables('uniqueSuffix'))), '2025-04-01').outputs.azureStorageName.value]" - }, - "storageResourceGroupName": { - "value": "[variables('azureStorageResourceGroupName')]" - }, - "storageSubscriptionId": { - "value": "[variables('azureStorageSubscriptionId')]" } }, "template": { @@ -1225,8 +1216,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "2247331089577562037" + "version": "0.42.1.51946", + "templateHash": "9263659451530831183" } }, "parameters": { @@ -1246,24 +1237,6 @@ "metadata": { "description": "The isolation mode for the managed network" } - }, - "storageName": { - "type": "string", - "metadata": { - "description": "The name of the storage account to create outbound rules for" - } - }, - "storageResourceGroupName": { - "type": "string", - "metadata": { - "description": "The resource group name where the storage account is located" - } - }, - "storageSubscriptionId": { - "type": "string", - "metadata": { - "description": "The subscription ID where the storage account is located" - } } }, "resources": [ @@ -1278,34 +1251,12 @@ "provisionNetworkNow": true } } - }, - { - "type": "Microsoft.CognitiveServices/accounts/managedNetworks/outboundRules", - "apiVersion": "2025-10-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('accountName'), 'default', 'storage-outbound-rule')]", - "properties": { - "type": "PrivateEndpoint", - "destination": { - "serviceResourceId": "[format('/subscriptions/{0}/resourceGroups/{1}/providers/Microsoft.Storage/storageAccounts/{2}', parameters('storageSubscriptionId'), parameters('storageResourceGroupName'), parameters('storageName'))]", - "subresourceTarget": "blob", - "sparkEnabled": false, - "sparkStatus": "Inactive" - }, - "category": "UserDefined" - }, - "dependsOn": [ - "[resourceId('Microsoft.CognitiveServices/accounts/managedNetworks', parameters('accountName'), 'default')]" - ] } ], "outputs": { "managedNetworkSettingsName": { "type": "string", "value": "default" - }, - "storageOutboundRuleName": { - "type": "string", - "value": "storage-outbound-rule" } } } @@ -1389,8 +1340,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "16093594075579008850" + "version": "0.42.1.51946", + "templateHash": "7099263758639578851" } }, "parameters": { @@ -2012,8 +1963,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "5095087340309076800" + "version": "0.42.1.51946", + "templateHash": "18174839353195527759" } }, "parameters": { @@ -2094,6 +2045,7 @@ } }, "dependsOn": [ + "[resourceId('Microsoft.CognitiveServices/accounts/projects', parameters('accountName'), parameters('projectName'))]", "[resourceId('Microsoft.CognitiveServices/accounts/projects/connections', parameters('accountName'), parameters('projectName'), parameters('cosmosDBName'))]" ] }, @@ -2112,6 +2064,7 @@ } }, "dependsOn": [ + "[resourceId('Microsoft.CognitiveServices/accounts/projects', parameters('accountName'), parameters('projectName'))]", "[resourceId('Microsoft.CognitiveServices/accounts/projects/connections', parameters('accountName'), parameters('projectName'), parameters('azureStorageName'))]" ] }, @@ -2187,8 +2140,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "6910483561575524105" + "version": "0.42.1.51946", + "templateHash": "5729541472937730744" } }, "parameters": { @@ -2242,8 +2195,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "14683840003859985069" + "version": "0.42.1.51946", + "templateHash": "6805612394357697185" } }, "parameters": { @@ -2258,7 +2211,7 @@ { "type": "Microsoft.Authorization/roleAssignments", "apiVersion": "2022-04-01", - "scope": "[format('Microsoft.Storage/storageAccounts/{0}', parameters('azureStorageName'))]", + "scope": "[resourceId('Microsoft.Storage/storageAccounts', parameters('azureStorageName'))]", "name": "[guid(parameters('projectPrincipalId'), resourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe'), resourceId('Microsoft.Storage/storageAccounts', parameters('azureStorageName')))]", "properties": { "principalId": "[parameters('projectPrincipalId')]", @@ -2300,8 +2253,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "2161753938341361575" + "version": "0.42.1.51946", + "templateHash": "3731695714569887273" } }, "parameters": { @@ -2322,7 +2275,7 @@ { "type": "Microsoft.Authorization/roleAssignments", "apiVersion": "2022-04-01", - "scope": "[format('Microsoft.DocumentDB/databaseAccounts/{0}', parameters('cosmosDBName'))]", + "scope": "[resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('cosmosDBName'))]", "name": "[guid(parameters('projectPrincipalId'), resourceId('Microsoft.Authorization/roleDefinitions', '230815da-be43-4aae-9cb4-875f7bd000aa'), resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('cosmosDBName')))]", "properties": { "principalId": "[parameters('projectPrincipalId')]", @@ -2364,8 +2317,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "7968115481508840" + "version": "0.42.1.51946", + "templateHash": "167668741936896543" } }, "parameters": { @@ -2386,7 +2339,7 @@ { "type": "Microsoft.Authorization/roleAssignments", "apiVersion": "2022-04-01", - "scope": "[format('Microsoft.Search/searchServices/{0}', parameters('aiSearchName'))]", + "scope": "[resourceId('Microsoft.Search/searchServices', parameters('aiSearchName'))]", "name": "[guid(parameters('projectPrincipalId'), resourceId('Microsoft.Authorization/roleDefinitions', '8ebe5a00-799e-43f5-93ac-243d3dce84a7'), resourceId('Microsoft.Search/searchServices', parameters('aiSearchName')))]", "properties": { "principalId": "[parameters('projectPrincipalId')]", @@ -2397,7 +2350,7 @@ { "type": "Microsoft.Authorization/roleAssignments", "apiVersion": "2022-04-01", - "scope": "[format('Microsoft.Search/searchServices/{0}', parameters('aiSearchName'))]", + "scope": "[resourceId('Microsoft.Search/searchServices', parameters('aiSearchName'))]", "name": "[guid(parameters('projectPrincipalId'), resourceId('Microsoft.Authorization/roleDefinitions', '7ca78c08-252a-4471-8644-bb5ff32d4ba0'), resourceId('Microsoft.Search/searchServices', parameters('aiSearchName')))]", "properties": { "principalId": "[parameters('projectPrincipalId')]", @@ -2449,8 +2402,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "17458377866351620215" + "version": "0.42.1.51946", + "templateHash": "10627022964818938294" } }, "parameters": { @@ -2543,8 +2496,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "13874725855824693255" + "version": "0.42.1.51946", + "templateHash": "14861833638743424490" } }, "parameters": { @@ -2574,7 +2527,7 @@ { "type": "Microsoft.Authorization/roleAssignments", "apiVersion": "2022-04-01", - "scope": "[format('Microsoft.Storage/storageAccounts/{0}', parameters('storageName'))]", + "scope": "[resourceId('Microsoft.Storage/storageAccounts', parameters('storageName'))]", "name": "[guid(resourceId('Microsoft.Authorization/roleDefinitions', 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b'), resourceId('Microsoft.Storage/storageAccounts', parameters('storageName')))]", "properties": { "principalId": "[parameters('aiProjectPrincipalId')]", @@ -2622,8 +2575,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "16291470712974205281" + "version": "0.42.1.51946", + "templateHash": "9661397591572983652" } }, "parameters": { diff --git a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/azuredeploy.parameters.json b/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/azuredeploy.parameters.json similarity index 100% rename from infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/azuredeploy.parameters.json rename to infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/azuredeploy.parameters.json diff --git a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/main.bicep b/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/main.bicep similarity index 97% rename from infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/main.bicep rename to infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/main.bicep index 80b941e7c..5386c9ba9 100644 --- a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/main.bicep +++ b/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/main.bicep @@ -245,15 +245,6 @@ module managedNetwork 'modules-network-secured/managed-network.bicep' = { params: { accountName: aiAccount.outputs.accountName isolationMode: isolationMode - storageName: aiDependencies.outputs.azureStorageName - storageResourceGroupName: azureStorageResourceGroupName - storageSubscriptionId: azureStorageSubscriptionId - // aiSearchName: aiDependencies.outputs.aiSearchName - // aiSearchResourceGroupName: aiSearchServiceResourceGroupName - // aiSearchSubscriptionId: aiSearchServiceSubscriptionId - // cosmosDBName: aiDependencies.outputs.cosmosDBName - // cosmosDBResourceGroupName: cosmosDBResourceGroupName - // cosmosDBSubscriptionId: cosmosDBSubscriptionId } dependsOn: [ aiSearch // Ensure AI Search exists diff --git a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/main.bicepparam b/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/main.bicepparam similarity index 100% rename from infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/main.bicepparam rename to infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/main.bicepparam diff --git a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/main.parameters.json b/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/main.parameters.json new file mode 100644 index 000000000..54369ac21 --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/main.parameters.json @@ -0,0 +1,76 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "location": { + "value": "eastus2" + }, + "isolationMode": { + "value": "AllowOnlyApprovedOutbound" + }, + "aiServices": { + "value": "aiservices" + }, + "deploymentTimestamp": { + "value": "20260501222500" + }, + "firstProjectName": { + "value": "project" + }, + "projectDescription": { + "value": "A project for the AI Foundry account with managed network secured deployed Agent" + }, + "displayName": { + "value": "project" + }, + "peSubnetName": { + "value": "pe-subnet" + }, + "existingVnetResourceId": { + "value": "/subscriptions/4c9044a9-7faa-4233-9745-94436400ee92/resourceGroups/mvnet-5-1-eus2/providers/Microsoft.Network/virtualNetworks/my-mvnet-5-1" + }, + "vnetName": { + "value": "" + }, + "aiSearchResourceId": { + "value": "/subscriptions/4c9044a9-7faa-4233-9745-94436400ee92/resourceGroups/rg-mvnet-eus2-4-1/providers/Microsoft.Search/searchServices/foundryzc3jsearch" + }, + "azureStorageAccountResourceId": { + "value": "/subscriptions/4c9044a9-7faa-4233-9745-94436400ee92/resourceGroups/mvnet-5-1-eus2/providers/Microsoft.Storage/storageAccounts/aiservicesrw5rstorage" + }, + "azureCosmosDBAccountResourceId": { + "value": "/subscriptions/4c9044a9-7faa-4233-9745-94436400ee92/resourceGroups/mvnet-5-1-eus2/providers/Microsoft.DocumentDB/databaseAccounts/aiservicesrw5rcosmosdb" + }, + "apiManagementResourceId": { + "value": "" + }, + "existingDnsZones": { + "value": { + "privatelink.services.ai.azure.com": "", + "privatelink.openai.azure.com": "", + "privatelink.cognitiveservices.azure.com": "", + "privatelink.search.windows.net": "", + "privatelink.blob.core.windows.net": "", + "privatelink.documents.azure.com": "", + "privatelink.azure-api.net": "" + } + }, + "dnsZoneNames": { + "value": [ + "privatelink.services.ai.azure.com", + "privatelink.openai.azure.com", + "privatelink.cognitiveservices.azure.com", + "privatelink.search.windows.net", + "privatelink.blob.core.windows.net", + "privatelink.documents.azure.com", + "privatelink.azure-api.net" + ] + }, + "vnetAddressPrefix": { + "value": "" + }, + "peSubnetPrefix": { + "value": "" + } + } +} \ No newline at end of file diff --git a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/metadata.json b/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/metadata.json similarity index 62% rename from infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/metadata.json rename to infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/metadata.json index e51f264f4..8e04d75fe 100644 --- a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/metadata.json +++ b/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/metadata.json @@ -2,8 +2,8 @@ "$schema": "https://aka.ms/azure-quickstart-templates-metadata-schema#", "type": "QuickStart", "itemDisplayName": "Managed Network Secured Standard Agent Setup", - "description": "This set of templates demonstrates how to set up Azure AI Agent Service with the managed network secured standard setup environment.", - "summary": "This set of templates demonstrates how to use Azure AI Agent Service with a managed virtual network.", + "description": "This set of templates demonstrates how to set up Foundry with a managed network secured standard setup environment.", + "summary": "This set of templates demonstrates how to use Foundry with a managed virtual network.", "githubUsername": "meerakurup", "dateUpdated": "2025-12-17", "environments": [ diff --git a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/modules-network-secured/add-project-capability-host.bicep b/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/modules-network-secured/add-project-capability-host.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/modules-network-secured/add-project-capability-host.bicep rename to infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/modules-network-secured/add-project-capability-host.bicep diff --git a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/modules-network-secured/ai-account-identity.bicep b/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/modules-network-secured/ai-account-identity.bicep similarity index 89% rename from infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/modules-network-secured/ai-account-identity.bicep rename to infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/modules-network-secured/ai-account-identity.bicep index 43330a81e..b940a50b2 100644 --- a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/modules-network-secured/ai-account-identity.bicep +++ b/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/modules-network-secured/ai-account-identity.bicep @@ -39,12 +39,12 @@ resource account 'Microsoft.CognitiveServices/accounts@2025-10-01-preview' = { } } -// Role assignment for the AI Services account managed identity, Azure AI Network connection approver role / Contributor (Change back when available) +// Role assignment for the AI Services account managed identity, Azure AI Network connection approver role resource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(account.id, 'b24988ac-6180-42a0-ab88-20f7382dd24c', resourceGroup().id) + name: guid(account.id, 'b556d68e-0be0-4f35-a333-ad7ee1ce17ea', resourceGroup().id) scope: resourceGroup() properties: { - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b24988ac-6180-42a0-ab88-20f7382dd24c') + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b556d68e-0be0-4f35-a333-ad7ee1ce17ea') principalId: account.identity.principalId principalType: 'ServicePrincipal' } diff --git a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/modules-network-secured/ai-project-identity.bicep b/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/modules-network-secured/ai-project-identity.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/modules-network-secured/ai-project-identity.bicep rename to infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/modules-network-secured/ai-project-identity.bicep diff --git a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/modules-network-secured/ai-search-role-assignments.bicep b/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/modules-network-secured/ai-search-role-assignments.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/modules-network-secured/ai-search-role-assignments.bicep rename to infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/modules-network-secured/ai-search-role-assignments.bicep diff --git a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/modules-network-secured/azure-storage-account-role-assignment.bicep b/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/modules-network-secured/azure-storage-account-role-assignment.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/modules-network-secured/azure-storage-account-role-assignment.bicep rename to infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/modules-network-secured/azure-storage-account-role-assignment.bicep diff --git a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/modules-network-secured/blob-storage-container-role-assignments.bicep b/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/modules-network-secured/blob-storage-container-role-assignments.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/modules-network-secured/blob-storage-container-role-assignments.bicep rename to infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/modules-network-secured/blob-storage-container-role-assignments.bicep diff --git a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/modules-network-secured/cosmos-container-role-assignments.bicep b/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/modules-network-secured/cosmos-container-role-assignments.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/modules-network-secured/cosmos-container-role-assignments.bicep rename to infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/modules-network-secured/cosmos-container-role-assignments.bicep diff --git a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/modules-network-secured/cosmosdb-account-role-assignment.bicep b/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/modules-network-secured/cosmosdb-account-role-assignment.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/modules-network-secured/cosmosdb-account-role-assignment.bicep rename to infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/modules-network-secured/cosmosdb-account-role-assignment.bicep diff --git a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/modules-network-secured/existing-vnet.bicep b/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/modules-network-secured/existing-vnet.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/modules-network-secured/existing-vnet.bicep rename to infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/modules-network-secured/existing-vnet.bicep diff --git a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/modules-network-secured/format-project-workspace-id.bicep b/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/modules-network-secured/format-project-workspace-id.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/modules-network-secured/format-project-workspace-id.bicep rename to infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/modules-network-secured/format-project-workspace-id.bicep diff --git a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/modules-network-secured/managed-network.bicep b/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/modules-network-secured/managed-network.bicep new file mode 100644 index 000000000..285948878 --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/modules-network-secured/managed-network.bicep @@ -0,0 +1,31 @@ +@description('The name of the AI Services account') +param accountName string + +@description('The isolation mode for the managed network') +@allowed([ + 'AllowOnlyApprovedOutbound' + 'AllowInternetOutbound' +]) +param isolationMode string = 'AllowOnlyApprovedOutbound' + +// Reference the existing AI Services account in the same resource group +resource aiAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { + name: accountName +} + +// Create the managed network settings first +#disable-next-line BCP081 +resource managedNetwork 'Microsoft.CognitiveServices/accounts/managednetworks@2025-10-01-preview' = { + parent: aiAccount + name: 'default' + properties: { + managedNetwork: { + IsolationMode: isolationMode + managedNetworkKind: 'V2' + provisionNetworkNow: true + //firewallSku: 'Standard' // Uncomment to enable firewall only when in AllowOnlyApprovedOutbound mode + } + } +} + +output managedNetworkSettingsName string = managedNetwork.name diff --git a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/modules-network-secured/network-agent-vnet.bicep b/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/modules-network-secured/network-agent-vnet.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/modules-network-secured/network-agent-vnet.bicep rename to infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/modules-network-secured/network-agent-vnet.bicep diff --git a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/modules-network-secured/private-endpoint-and-dns.bicep b/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/modules-network-secured/private-endpoint-and-dns.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/modules-network-secured/private-endpoint-and-dns.bicep rename to infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/modules-network-secured/private-endpoint-and-dns.bicep diff --git a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/modules-network-secured/standard-dependent-resources.bicep b/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/modules-network-secured/standard-dependent-resources.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/modules-network-secured/standard-dependent-resources.bicep rename to infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/modules-network-secured/standard-dependent-resources.bicep diff --git a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/modules-network-secured/subnet.bicep b/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/modules-network-secured/subnet.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/modules-network-secured/subnet.bicep rename to infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/modules-network-secured/subnet.bicep diff --git a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/modules-network-secured/validate-existing-resources.bicep b/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/modules-network-secured/validate-existing-resources.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/modules-network-secured/validate-existing-resources.bicep rename to infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/modules-network-secured/validate-existing-resources.bicep diff --git a/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/modules-network-secured/vnet.bicep b/infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/modules-network-secured/vnet.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/18-managed-virtual-network-preview/modules-network-secured/vnet.bicep rename to infrastructure/infrastructure-setup-bicep/18-managed-virtual-network/modules-network-secured/vnet.bicep diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/README.md b/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/README.md deleted file mode 100644 index f67457cc9..000000000 --- a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/README.md +++ /dev/null @@ -1,222 +0,0 @@ -# Hybrid Private Resources Agent Setup - -This template deploys an Azure AI Foundry account with backend resources (AI Search, Cosmos DB, Storage) on **private endpoints**. By default, the Foundry resource itself also has **public network access disabled**, but this can be switched to public access if needed (see [Switching Between Private and Public Access](#switching-between-private-and-public-access)). - -## Architecture (Default β€” Private Foundry) - -``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ Secure Access (VPN Gateway / ExpressRoute / Azure Bastion) β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ AI Services Account β”‚ - β”‚ (publicNetworkAccess: β”‚ - β”‚ DISABLED) β”‚ ◄── Private by default - β”‚ β”‚ - β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ - β”‚ β”‚ Data Proxy / Agent β”‚ β”‚ - β”‚ β”‚ ToolServer β”‚ β”‚ - β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ networkInjections - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ Private VNet β”‚ - β”‚ β”‚ - β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ - β”‚ β”‚AI Searchβ”‚ β”‚Cosmos DBβ”‚ β”‚ ◄── Private endpoints - β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ (no public access) - β”‚ β”‚ - β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ - β”‚ β”‚ Storage β”‚ β”‚ MCP β”‚ β”‚ - β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ Servers β”‚ β”‚ - β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ -``` - -## Key Features - -| Feature | This Template (19) β€” Private (default) | This Template (19) β€” Public | Fully Private (15) | -|---------|----------------------------------------|-----------------------------|-----------------------| -| AI Services public access | ❌ Disabled | βœ… Enabled | ❌ Disabled | -| Portal access | Via VPN/ExpressRoute/Bastion | βœ… Works directly | Via VPN/ExpressRoute/Bastion | -| Backend resources | πŸ”’ Private | πŸ”’ Private | πŸ”’ Private | -| Data Proxy | βœ… Configured | βœ… Configured | βœ… Configured | -| Secure connection required | βœ… Yes | ❌ No | βœ… Yes | - -## Switching Between Private and Public Access - -The Foundry resource has **public network access disabled by default**. You can switch between the two modes by modifying the Bicep template. - -### To enable public access - -In [modules-network-secured/ai-account-identity.bicep](modules-network-secured/ai-account-identity.bicep), change: - -```bicep -// Change from: -publicNetworkAccess: 'Disabled' -// To: -publicNetworkAccess: 'Enabled' - -// Also change: -defaultAction: 'Deny' -// To: -defaultAction: 'Allow' -``` - -This makes the Foundry resource accessible from the internet (e.g., for portal-based development without VPN). - -### To disable public access (default) - -Revert the changes above, setting `publicNetworkAccess: 'Disabled'` and `defaultAction: 'Deny'`. - -## Connecting to a Private Foundry Resource - -When public network access is disabled (the default), you need a secure connection to reach the Foundry resource. Azure provides three methods: - -1. **Azure VPN Gateway** β€” Connect from your local network to the Azure VNet over an encrypted tunnel. -2. **Azure ExpressRoute** β€” Use a private, dedicated connection from your on-premises infrastructure to Azure. -3. **Azure Bastion** β€” Use a jump box VM on the VNet, accessed securely through the Azure portal. - -For detailed setup instructions, see: [Securely connect to Azure AI Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/configure-private-link?view=foundry#securely-connect-to-foundry). - -## When to Use This Template - -Use this template when you want: -- **Private backend resources** β€” Keep AI Search, Cosmos DB, and Storage behind private endpoints -- **MCP server integration** β€” Deploy MCP servers on the VNet that agents can access via Data Proxy -- **OpenAPI tool integration** β€” Deploy OpenAPI-spec HTTP services on the VNet for agent tool access -- **A2A (Agent-to-Agent)** β€” Connect agents to remote agents behind the VNet via the A2A protocol -- **Azure Functions** β€” Deploy an Azure Function behind a VNET for agent tool access. -- **Private Foundry (default)** β€” Full network isolation with secure access via VPN/ExpressRoute/Bastion -- **Optional public Foundry access** β€” Switch to public for portal-based development if allowed by your security policy - -## When NOT to Use This Template - -Use [template 15](../15-private-network-standard-agent-setup/) instead when you need: -- **Fully managed private networking** β€” Including managed VNet with Microsoft-managed private endpoints -- **Compliance requirements** β€” Regulations that require a different private networking topology - -## Deployment - -### Prerequisites - -1. Azure CLI installed and authenticated -2. Owner or Contributor role on the subscription -3. Sufficient quota for model deployment (gpt-4o-mini) - -### Deploy - -```bash -# Create resource group -az group create --name "rg-hybrid-agent-test" --location "westus2" - -# Deploy the template -az deployment group create \ - --resource-group "rg-hybrid-agent-test" \ - --template-file main.bicep \ - --parameters location="westus2" -``` - -### Verify Deployment - -```bash -# Check deployment status -az deployment group show \ - --resource-group "rg-hybrid-agent-test" \ - --name "main" \ - --query "properties.provisioningState" - -# List private endpoints (should see AI Search, Storage, Cosmos DB) -az network private-endpoint list \ - --resource-group "rg-hybrid-agent-test" \ - --output table -``` - -## Testing Agents with Private Resources - -### Option 1: Portal Testing - -If the Foundry resource has **public network access enabled**, you can test directly in the portal: - -1. Navigate to [Azure AI Foundry portal](https://ai.azure.com) -2. Select your project -3. Create an agent with AI Search tool -4. Test that the agent can query the private AI Search index - -If the Foundry resource has **public network access disabled** (default), you need to connect via VPN Gateway, ExpressRoute, or Azure Bastion before accessing the portal. See [Connecting to a Private Foundry Resource](#connecting-to-a-private-foundry-resource). - -### Option 2: SDK Testing - -See [tests/TESTING-GUIDE.md](tests/TESTING-GUIDE.md) for detailed SDK testing instructions. - -## MCP Server Deployment - -To deploy MCP servers on the private VNet: - -```bash -# Create Container Apps environment on mcp-subnet -az containerapp env create \ - --resource-group "rg-hybrid-agent-test" \ - --name "mcp-env" \ - --location "westus2" \ - --infrastructure-subnet-resource-id "" \ - --internal-only true - -# Deploy MCP server -az containerapp create \ - --resource-group "rg-hybrid-agent-test" \ - --name "my-mcp-server" \ - --environment "mcp-env" \ - --image "" \ - --target-port 8080 \ - --ingress external \ - --min-replicas 1 -``` - -Then configure private DNS zone for Container Apps (see TESTING-GUIDE.md Step 6.3). - -## Parameters - -> **⚠️ Important: Cosmos DB Connection Requirements** -> -> If you are creating the Cosmos DB connection manually (e.g., via REST API or ARM), ensure the following: -> - The `authType` **must** be set to `AAD`. This is the only supported authentication type for the Cosmos DB connection used by the Agent Service. -> - The `metadata` section **must** include the `ResourceId` property, set to the full Azure Resource ID of your Cosmos DB account. The Agent Service relies on this property to correctly identify and connect to your Cosmos DB resource. Omitting `ResourceId` from the metadata will cause the connection to fail. -> -> Example connection properties: -> ```json -> { -> "category": "CosmosDB", -> "authType": "AAD", -> "metadata": { -> "ApiType": "Azure", -> "ResourceId": "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.DocumentDB/databaseAccounts/{cosmosDbAccountName}", -> "location": "{region}" -> } -> } -> ``` - -| Parameter | Description | Default | -|-----------|-------------|---------| -| `location` | Azure region | `eastus2` | -| `aiServices` | Base name for AI Services | `aiservices` | -| `modelName` | Model to deploy | `gpt-4o-mini` | -| `modelCapacity` | TPM capacity | `30` | -| `vnetName` | VNet name | `agent-vnet-test` | -| `agentSubnetName` | Subnet for AI Foundry (reserved) | `agent-subnet` | -| `peSubnetName` | Subnet for private endpoints | `pe-subnet` | -| `mcpSubnetName` | Subnet for MCP servers | `mcp-subnet` | - -## Cleanup - -```bash -# Delete all resources -az group delete --name "rg-hybrid-agent-test" --yes --no-wait -``` - -## Related Templates - -- [15-private-network-standard-agent-setup](../15-private-network-standard-agent-setup/) - Fully private setup (no public access) -- [40-basic-agent-setup](../40-basic-agent-setup/) - Basic agent setup without private networking -- [41-standard-agent-setup](../41-standard-agent-setup/) - Standard agent setup without private networking diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/main.bicepparam b/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/main.bicepparam deleted file mode 100644 index a5125398b..000000000 --- a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/main.bicepparam +++ /dev/null @@ -1,66 +0,0 @@ -using './main.bicep' - -param location = 'norwayeast' -param aiServices = 'djetchev' -param modelName = 'gpt-4o-mini' -param modelFormat = 'OpenAI' -param modelVersion = '2024-07-18' -param modelSkuName = 'GlobalStandard' -param modelCapacity = 1 -param firstProjectName = 'project' -param projectDescription = 'A project for the AI Foundry account with network secured deployed Agent' -param displayName = 'project' -param peSubnetName = 'pe-subnet' - -// Resource IDs for existing resources -// If you provide these, the deployment will use the existing resources instead of creating new ones -param existingVnetResourceId = '' -param vnetName = 'agent-vnet-test' -param agentSubnetName = 'agent-subnet' -param aiSearchResourceId = '' -param azureStorageAccountResourceId = '' -param azureCosmosDBAccountResourceId = '' -// Pass the DNS zone map here -// Leave empty to create new DNS zone, add the resource group of existing DNS zone to use it -param existingDnsZones = { - 'privatelink.services.ai.azure.com': '' - 'privatelink.openai.azure.com': '' - 'privatelink.cognitiveservices.azure.com': '' - 'privatelink.search.windows.net': '' - 'privatelink.blob.core.windows.net': '' - 'privatelink.documents.azure.com': '' -} - -//DNSZones names for validating if they exist -param dnsZoneNames = [ - 'privatelink.services.ai.azure.com' - 'privatelink.openai.azure.com' - 'privatelink.cognitiveservices.azure.com' - 'privatelink.search.windows.net' - 'privatelink.blob.core.windows.net' - 'privatelink.documents.azure.com' -] - -// Network configuration (behavior depends on `existingVnetResourceId`) -// -// - NEW VNet (existingVnetResourceId is empty): -// The values below are used to CREATE the VNet and the two subnets. -// Provide explicit, non-overlapping CIDR ranges when creating a new VNet. -// -// - EXISTING VNet (existingVnetResourceId is provided): -// The module will reference the existing VNet. Subnet handling depends on the -// values you provide: -// * If `agentSubnetPrefix` or `peSubnetPrefix` are empty, the module may -// auto-derive subnet CIDRs from the existing VNet's address space -// (using cidrSubnet). This can produce /24 (or configured) subnets -// starting at index 0, 1, etc. -// * If you provide explicit subnet prefixes, the module will attempt to -// create or update subnets with those prefixes in the existing VNet. -// -// Important operational notes and risks (when existingVnetResourceId is provided): -// - Avoid CIDR overlaps with any existing subnets in the target VNet. Overlap -// leads to `NetcfgSubnetRangesOverlap` and failed deployments. -// - For highest safety when using an existing VNet, supply the existing `agentSubnetPrefix` and `peSubnetPrefix`. -param vnetAddressPrefix = '' -param agentSubnetPrefix = '' -param peSubnetPrefix = '' diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/main.json b/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/main.json deleted file mode 100644 index d7dd22475..000000000 --- a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/main.json +++ /dev/null @@ -1,2772 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "789904159633670276" - } - }, - "parameters": { - "location": { - "type": "string", - "defaultValue": "eastus2", - "allowedValues": [ - "westus", - "eastus", - "eastus2", - "japaneast", - "francecentral", - "spaincentral", - "uaenorth", - "southcentralus", - "italynorth", - "germanywestcentral", - "brazilsouth", - "southafricanorth", - "australiaeast", - "swedencentral", - "canadaeast", - "westeurope", - "westus3", - "uksouth", - "southindia", - "koreacentral", - "polandcentral", - "switzerlandnorth", - "norwayeast" - ], - "metadata": { - "description": "Location for all resources." - } - }, - "aiServices": { - "type": "string", - "defaultValue": "aiservices", - "metadata": { - "description": "Name for your AI Services resource." - } - }, - "modelName": { - "type": "string", - "defaultValue": "gpt-4o", - "metadata": { - "description": "The name of the model you want to deploy" - } - }, - "modelFormat": { - "type": "string", - "defaultValue": "OpenAI", - "metadata": { - "description": "The provider of your model" - } - }, - "modelVersion": { - "type": "string", - "defaultValue": "2024-11-20", - "metadata": { - "description": "The version of your model" - } - }, - "modelSkuName": { - "type": "string", - "defaultValue": "GlobalStandard", - "metadata": { - "description": "The sku of your model deployment" - } - }, - "modelCapacity": { - "type": "int", - "defaultValue": 30, - "metadata": { - "description": "The tokens per minute (TPM) of your model deployment" - } - }, - "deploymentTimestamp": { - "type": "string", - "defaultValue": "[utcNow('yyyyMMddHHmmss')]" - }, - "firstProjectName": { - "type": "string", - "defaultValue": "project", - "metadata": { - "description": "Name for your project resource." - } - }, - "projectDescription": { - "type": "string", - "defaultValue": "A project for the AI Foundry account with network secured deployed Agent", - "metadata": { - "description": "This project will be a sub-resource of your account" - } - }, - "displayName": { - "type": "string", - "defaultValue": "network secured agent project", - "metadata": { - "description": "The display name of the project" - } - }, - "vnetName": { - "type": "string", - "defaultValue": "agent-vnet-test", - "metadata": { - "description": "Virtual Network name for the Agent to create new or existing virtual network" - } - }, - "agentSubnetName": { - "type": "string", - "defaultValue": "agent-subnet", - "metadata": { - "description": "The name of Agents Subnet to create new or existing subnet for agents" - } - }, - "peSubnetName": { - "type": "string", - "defaultValue": "pe-subnet", - "metadata": { - "description": "The name of Private Endpoint subnet to create new or existing subnet for private endpoints" - } - }, - "existingVnetResourceId": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Existing Virtual Network name Resource ID" - } - }, - "vnetAddressPrefix": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Address space for the VNet (only used for new VNet)" - } - }, - "agentSubnetPrefix": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Address prefix for the agent subnet. The default value is 192.168.0.0/24 but you can choose any size /26 or any class like 10.0.0.0 or 172.168.0.0" - } - }, - "peSubnetPrefix": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Address prefix for the private endpoint subnet" - } - }, - "aiSearchResourceId": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "The AI Search Service full ARM Resource ID. This is an optional field, and if not provided, the resource will be created." - } - }, - "azureStorageAccountResourceId": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "The AI Storage Account full ARM Resource ID. This is an optional field, and if not provided, the resource will be created." - } - }, - "azureCosmosDBAccountResourceId": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "The Cosmos DB Account full ARM Resource ID. This is an optional field, and if not provided, the resource will be created." - } - }, - "fabricWorkspaceResourceId": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "The Microsoft Fabric Workspace full ARM Resource ID. This is an optional field for Fabric private link connectivity." - } - }, - "existingDnsZones": { - "type": "object", - "defaultValue": { - "privatelink.services.ai.azure.com": "", - "privatelink.openai.azure.com": "", - "privatelink.cognitiveservices.azure.com": "", - "privatelink.search.windows.net": "", - "privatelink.blob.core.windows.net": "", - "privatelink.documents.azure.com": "", - "privatelink.analysis.windows.net": "" - }, - "metadata": { - "description": "Object mapping DNS zone names to their resource group, or empty string to indicate creation" - } - }, - "dnsZoneNames": { - "type": "array", - "defaultValue": [ - "privatelink.services.ai.azure.com", - "privatelink.openai.azure.com", - "privatelink.cognitiveservices.azure.com", - "privatelink.search.windows.net", - "privatelink.blob.core.windows.net", - "privatelink.documents.azure.com", - "privatelink.analysis.windows.net" - ], - "metadata": { - "description": "Zone Names for Validation of existing Private Dns Zones" - } - }, - "projectCapHost": { - "type": "string", - "defaultValue": "caphostproj", - "metadata": { - "description": "The name of the project capability host to be created" - } - } - }, - "variables": { - "uniqueSuffix": "[substring(uniqueString(format('{0}-{1}', resourceGroup().id, parameters('deploymentTimestamp'))), 0, 4)]", - "accountName": "[toLower(format('{0}{1}', parameters('aiServices'), variables('uniqueSuffix')))]", - "projectName": "[toLower(format('{0}{1}', parameters('firstProjectName'), variables('uniqueSuffix')))]", - "cosmosDBName": "[toLower(format('{0}{1}cosmosdb', parameters('aiServices'), variables('uniqueSuffix')))]", - "aiSearchName": "[toLower(format('{0}{1}search', parameters('aiServices'), variables('uniqueSuffix')))]", - "azureStorageName": "[toLower(format('{0}{1}storage', parameters('aiServices'), variables('uniqueSuffix')))]", - "storagePassedIn": "[not(equals(parameters('azureStorageAccountResourceId'), ''))]", - "searchPassedIn": "[not(equals(parameters('aiSearchResourceId'), ''))]", - "cosmosPassedIn": "[not(equals(parameters('azureCosmosDBAccountResourceId'), ''))]", - "existingVnetPassedIn": "[not(equals(parameters('existingVnetResourceId'), ''))]", - "acsParts": "[split(parameters('aiSearchResourceId'), '/')]", - "aiSearchServiceSubscriptionId": "[if(variables('searchPassedIn'), variables('acsParts')[2], subscription().subscriptionId)]", - "aiSearchServiceResourceGroupName": "[if(variables('searchPassedIn'), variables('acsParts')[4], resourceGroup().name)]", - "cosmosParts": "[split(parameters('azureCosmosDBAccountResourceId'), '/')]", - "cosmosDBSubscriptionId": "[if(variables('cosmosPassedIn'), variables('cosmosParts')[2], subscription().subscriptionId)]", - "cosmosDBResourceGroupName": "[if(variables('cosmosPassedIn'), variables('cosmosParts')[4], resourceGroup().name)]", - "storageParts": "[split(parameters('azureStorageAccountResourceId'), '/')]", - "azureStorageSubscriptionId": "[if(variables('storagePassedIn'), variables('storageParts')[2], subscription().subscriptionId)]", - "azureStorageResourceGroupName": "[if(variables('storagePassedIn'), variables('storageParts')[4], resourceGroup().name)]", - "vnetParts": "[split(parameters('existingVnetResourceId'), '/')]", - "vnetSubscriptionId": "[if(variables('existingVnetPassedIn'), variables('vnetParts')[2], subscription().subscriptionId)]", - "vnetResourceGroupName": "[if(variables('existingVnetPassedIn'), variables('vnetParts')[4], resourceGroup().name)]", - "existingVnetName": "[if(variables('existingVnetPassedIn'), last(variables('vnetParts')), parameters('vnetName'))]", - "trimVnetName": "[trim(variables('existingVnetName'))]" - }, - "resources": [ - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[format('vnet-{0}-{1}-deployment', variables('trimVnetName'), variables('uniqueSuffix'))]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "vnetName": { - "value": "[variables('trimVnetName')]" - }, - "useExistingVnet": { - "value": "[variables('existingVnetPassedIn')]" - }, - "existingVnetResourceGroupName": { - "value": "[variables('vnetResourceGroupName')]" - }, - "agentSubnetName": { - "value": "[parameters('agentSubnetName')]" - }, - "peSubnetName": { - "value": "[parameters('peSubnetName')]" - }, - "vnetAddressPrefix": { - "value": "[parameters('vnetAddressPrefix')]" - }, - "agentSubnetPrefix": { - "value": "[parameters('agentSubnetPrefix')]" - }, - "peSubnetPrefix": { - "value": "[parameters('peSubnetPrefix')]" - }, - "existingVnetSubscriptionId": { - "value": "[variables('vnetSubscriptionId')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "8505298823279202405" - } - }, - "parameters": { - "location": { - "type": "string", - "metadata": { - "description": "Azure region for the deployment" - } - }, - "vnetName": { - "type": "string", - "metadata": { - "description": "The name of the virtual network" - } - }, - "useExistingVnet": { - "type": "bool", - "defaultValue": false, - "metadata": { - "description": "Indicates if an existing VNet should be used" - } - }, - "existingVnetSubscriptionId": { - "type": "string", - "defaultValue": "[subscription().subscriptionId]", - "metadata": { - "description": "Subscription ID of the existing VNet (if different from current subscription)" - } - }, - "existingVnetResourceGroupName": { - "type": "string", - "defaultValue": "[resourceGroup().name]", - "metadata": { - "description": "Resource Group name of the existing VNet (if different from current resource group)" - } - }, - "agentSubnetName": { - "type": "string", - "defaultValue": "agent-subnet", - "metadata": { - "description": "The name of Agents Subnet" - } - }, - "peSubnetName": { - "type": "string", - "defaultValue": "pe-subnet", - "metadata": { - "description": "The name of Private Endpoint subnet" - } - }, - "vnetAddressPrefix": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Address space for the VNet (only used for new VNet)" - } - }, - "agentSubnetPrefix": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Address prefix for the agent subnet" - } - }, - "peSubnetPrefix": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Address prefix for the private endpoint subnet" - } - } - }, - "resources": [ - { - "condition": "[not(parameters('useExistingVnet'))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "vnet-deployment", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "vnetName": { - "value": "[parameters('vnetName')]" - }, - "agentSubnetName": { - "value": "[parameters('agentSubnetName')]" - }, - "peSubnetName": { - "value": "[parameters('peSubnetName')]" - }, - "vnetAddressPrefix": { - "value": "[parameters('vnetAddressPrefix')]" - }, - "agentSubnetPrefix": { - "value": "[parameters('agentSubnetPrefix')]" - }, - "peSubnetPrefix": { - "value": "[parameters('peSubnetPrefix')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "4954184648131521061" - } - }, - "parameters": { - "location": { - "type": "string", - "metadata": { - "description": "Azure region for the deployment" - } - }, - "vnetName": { - "type": "string", - "defaultValue": "agents-vnet-test", - "metadata": { - "description": "The name of the virtual network" - } - }, - "agentSubnetName": { - "type": "string", - "defaultValue": "agent-subnet", - "metadata": { - "description": "The name of Agents Subnet" - } - }, - "peSubnetName": { - "type": "string", - "defaultValue": "pe-subnet", - "metadata": { - "description": "The name of Hub subnet" - } - }, - "vnetAddressPrefix": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Address space for the VNet" - } - }, - "agentSubnetPrefix": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Address prefix for the agent subnet" - } - }, - "peSubnetPrefix": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Address prefix for the private endpoint subnet" - } - } - }, - "variables": { - "defaultVnetAddressPrefix": "192.168.0.0/16", - "vnetAddress": "[if(empty(parameters('vnetAddressPrefix')), variables('defaultVnetAddressPrefix'), parameters('vnetAddressPrefix'))]", - "agentSubnet": "[if(empty(parameters('agentSubnetPrefix')), cidrSubnet(variables('vnetAddress'), 24, 0), parameters('agentSubnetPrefix'))]", - "peSubnet": "[if(empty(parameters('peSubnetPrefix')), cidrSubnet(variables('vnetAddress'), 24, 1), parameters('peSubnetPrefix'))]" - }, - "resources": [ - { - "type": "Microsoft.Network/virtualNetworks", - "apiVersion": "2024-05-01", - "name": "[parameters('vnetName')]", - "location": "[parameters('location')]", - "properties": { - "addressSpace": { - "addressPrefixes": [ - "[variables('vnetAddress')]" - ] - }, - "subnets": [ - { - "name": "[parameters('agentSubnetName')]", - "properties": { - "addressPrefix": "[variables('agentSubnet')]", - "delegations": [ - { - "name": "Microsoft.app/environments", - "properties": { - "serviceName": "Microsoft.App/environments" - } - } - ] - } - }, - { - "name": "[parameters('peSubnetName')]", - "properties": { - "addressPrefix": "[variables('peSubnet')]" - } - } - ] - } - } - ], - "outputs": { - "peSubnetName": { - "type": "string", - "value": "[parameters('peSubnetName')]" - }, - "agentSubnetName": { - "type": "string", - "value": "[parameters('agentSubnetName')]" - }, - "agentSubnetId": { - "type": "string", - "value": "[format('{0}/subnets/{1}', resourceId('Microsoft.Network/virtualNetworks', parameters('vnetName')), parameters('agentSubnetName'))]" - }, - "peSubnetId": { - "type": "string", - "value": "[format('{0}/subnets/{1}', resourceId('Microsoft.Network/virtualNetworks', parameters('vnetName')), parameters('peSubnetName'))]" - }, - "virtualNetworkName": { - "type": "string", - "value": "[parameters('vnetName')]" - }, - "virtualNetworkId": { - "type": "string", - "value": "[resourceId('Microsoft.Network/virtualNetworks', parameters('vnetName'))]" - }, - "virtualNetworkResourceGroup": { - "type": "string", - "value": "[resourceGroup().name]" - }, - "virtualNetworkSubscriptionId": { - "type": "string", - "value": "[subscription().subscriptionId]" - } - } - } - } - }, - { - "condition": "[parameters('useExistingVnet')]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "existing-vnet-deployment", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "vnetName": { - "value": "[parameters('vnetName')]" - }, - "vnetResourceGroupName": { - "value": "[parameters('existingVnetResourceGroupName')]" - }, - "vnetSubscriptionId": { - "value": "[parameters('existingVnetSubscriptionId')]" - }, - "agentSubnetName": { - "value": "[parameters('agentSubnetName')]" - }, - "peSubnetName": { - "value": "[parameters('peSubnetName')]" - }, - "agentSubnetPrefix": { - "value": "[parameters('agentSubnetPrefix')]" - }, - "peSubnetPrefix": { - "value": "[parameters('peSubnetPrefix')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "3152324712046183852" - } - }, - "parameters": { - "vnetName": { - "type": "string", - "metadata": { - "description": "The name of the existing virtual network" - } - }, - "vnetSubscriptionId": { - "type": "string", - "defaultValue": "[subscription().subscriptionId]", - "metadata": { - "description": "Subscription ID of virtual network (if different from current subscription)" - } - }, - "vnetResourceGroupName": { - "type": "string", - "defaultValue": "[resourceGroup().name]", - "metadata": { - "description": "Resource Group name of the existing VNet (if different from current resource group)" - } - }, - "agentSubnetName": { - "type": "string", - "defaultValue": "agent-subnet", - "metadata": { - "description": "The name of Agents Subnet" - } - }, - "peSubnetName": { - "type": "string", - "defaultValue": "pe-subnet", - "metadata": { - "description": "The name of Private Endpoint subnet" - } - }, - "agentSubnetPrefix": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Address prefix for the agent subnet (only needed if creating new subnet)" - } - }, - "peSubnetPrefix": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Address prefix for the private endpoint subnet (only needed if creating new subnet)" - } - } - }, - "resources": [ - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[format('agent-subnet-{0}', uniqueString(deployment().name, parameters('agentSubnetName')))]", - "resourceGroup": "[parameters('vnetResourceGroupName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "vnetName": { - "value": "[parameters('vnetName')]" - }, - "subnetName": { - "value": "[parameters('agentSubnetName')]" - }, - "addressPrefix": "[if(empty(parameters('agentSubnetPrefix')), createObject('value', cidrSubnet(reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName')), '2024-05-01').addressSpace.addressPrefixes[0], 24, 0)), createObject('value', parameters('agentSubnetPrefix')))]", - "delegations": { - "value": [ - { - "name": "Microsoft.App/environments", - "properties": { - "serviceName": "Microsoft.App/environments" - } - } - ] - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "17043822047386586435" - } - }, - "parameters": { - "vnetName": { - "type": "string", - "metadata": { - "description": "Name of the virtual network" - } - }, - "subnetName": { - "type": "string", - "metadata": { - "description": "Name of the subnet" - } - }, - "addressPrefix": { - "type": "string", - "metadata": { - "description": "Address prefix for the subnet" - } - }, - "delegations": { - "type": "array", - "defaultValue": [], - "metadata": { - "description": "Array of subnet delegations" - } - } - }, - "resources": [ - { - "type": "Microsoft.Network/virtualNetworks/subnets", - "apiVersion": "2024-05-01", - "name": "[format('{0}/{1}', parameters('vnetName'), parameters('subnetName'))]", - "properties": { - "addressPrefix": "[parameters('addressPrefix')]", - "delegations": "[parameters('delegations')]" - } - } - ], - "outputs": { - "subnetId": { - "type": "string", - "value": "[resourceId('Microsoft.Network/virtualNetworks/subnets', split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[0], split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[1])]" - }, - "subnetName": { - "type": "string", - "value": "[parameters('subnetName')]" - } - } - } - } - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[format('pe-subnet-{0}', uniqueString(deployment().name, parameters('peSubnetName')))]", - "resourceGroup": "[parameters('vnetResourceGroupName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "vnetName": { - "value": "[parameters('vnetName')]" - }, - "subnetName": { - "value": "[parameters('peSubnetName')]" - }, - "addressPrefix": "[if(empty(parameters('peSubnetPrefix')), createObject('value', cidrSubnet(reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName')), '2024-05-01').addressSpace.addressPrefixes[0], 24, 1)), createObject('value', parameters('peSubnetPrefix')))]", - "delegations": { - "value": [] - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "17043822047386586435" - } - }, - "parameters": { - "vnetName": { - "type": "string", - "metadata": { - "description": "Name of the virtual network" - } - }, - "subnetName": { - "type": "string", - "metadata": { - "description": "Name of the subnet" - } - }, - "addressPrefix": { - "type": "string", - "metadata": { - "description": "Address prefix for the subnet" - } - }, - "delegations": { - "type": "array", - "defaultValue": [], - "metadata": { - "description": "Array of subnet delegations" - } - } - }, - "resources": [ - { - "type": "Microsoft.Network/virtualNetworks/subnets", - "apiVersion": "2024-05-01", - "name": "[format('{0}/{1}', parameters('vnetName'), parameters('subnetName'))]", - "properties": { - "addressPrefix": "[parameters('addressPrefix')]", - "delegations": "[parameters('delegations')]" - } - } - ], - "outputs": { - "subnetId": { - "type": "string", - "value": "[resourceId('Microsoft.Network/virtualNetworks/subnets', split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[0], split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[1])]" - }, - "subnetName": { - "type": "string", - "value": "[parameters('subnetName')]" - } - } - } - } - } - ], - "outputs": { - "peSubnetName": { - "type": "string", - "value": "[parameters('peSubnetName')]" - }, - "agentSubnetName": { - "type": "string", - "value": "[parameters('agentSubnetName')]" - }, - "agentSubnetId": { - "type": "string", - "value": "[format('{0}/subnets/{1}', extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName')), parameters('agentSubnetName'))]" - }, - "peSubnetId": { - "type": "string", - "value": "[format('{0}/subnets/{1}', extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName')), parameters('peSubnetName'))]" - }, - "virtualNetworkName": { - "type": "string", - "value": "[parameters('vnetName')]" - }, - "virtualNetworkId": { - "type": "string", - "value": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName'))]" - }, - "virtualNetworkResourceGroup": { - "type": "string", - "value": "[parameters('vnetResourceGroupName')]" - }, - "virtualNetworkSubscriptionId": { - "type": "string", - "value": "[parameters('vnetSubscriptionId')]" - } - } - } - } - } - ], - "outputs": { - "virtualNetworkName": { - "type": "string", - "value": "[if(parameters('useExistingVnet'), reference(resourceId('Microsoft.Resources/deployments', 'existing-vnet-deployment'), '2025-04-01').outputs.virtualNetworkName.value, reference(resourceId('Microsoft.Resources/deployments', 'vnet-deployment'), '2025-04-01').outputs.virtualNetworkName.value)]" - }, - "virtualNetworkId": { - "type": "string", - "value": "[if(parameters('useExistingVnet'), reference(resourceId('Microsoft.Resources/deployments', 'existing-vnet-deployment'), '2025-04-01').outputs.virtualNetworkId.value, reference(resourceId('Microsoft.Resources/deployments', 'vnet-deployment'), '2025-04-01').outputs.virtualNetworkId.value)]" - }, - "virtualNetworkSubscriptionId": { - "type": "string", - "value": "[if(parameters('useExistingVnet'), reference(resourceId('Microsoft.Resources/deployments', 'existing-vnet-deployment'), '2025-04-01').outputs.virtualNetworkSubscriptionId.value, reference(resourceId('Microsoft.Resources/deployments', 'vnet-deployment'), '2025-04-01').outputs.virtualNetworkSubscriptionId.value)]" - }, - "virtualNetworkResourceGroup": { - "type": "string", - "value": "[if(parameters('useExistingVnet'), reference(resourceId('Microsoft.Resources/deployments', 'existing-vnet-deployment'), '2025-04-01').outputs.virtualNetworkResourceGroup.value, reference(resourceId('Microsoft.Resources/deployments', 'vnet-deployment'), '2025-04-01').outputs.virtualNetworkResourceGroup.value)]" - }, - "agentSubnetName": { - "type": "string", - "value": "[parameters('agentSubnetName')]" - }, - "peSubnetName": { - "type": "string", - "value": "[parameters('peSubnetName')]" - }, - "agentSubnetId": { - "type": "string", - "value": "[if(parameters('useExistingVnet'), reference(resourceId('Microsoft.Resources/deployments', 'existing-vnet-deployment'), '2025-04-01').outputs.agentSubnetId.value, reference(resourceId('Microsoft.Resources/deployments', 'vnet-deployment'), '2025-04-01').outputs.agentSubnetId.value)]" - }, - "peSubnetId": { - "type": "string", - "value": "[if(parameters('useExistingVnet'), reference(resourceId('Microsoft.Resources/deployments', 'existing-vnet-deployment'), '2025-04-01').outputs.peSubnetId.value, reference(resourceId('Microsoft.Resources/deployments', 'vnet-deployment'), '2025-04-01').outputs.peSubnetId.value)]" - } - } - } - } - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[format('{0}-{1}-deployment', variables('accountName'), variables('uniqueSuffix'))]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "accountName": { - "value": "[variables('accountName')]" - }, - "location": { - "value": "[parameters('location')]" - }, - "modelName": { - "value": "[parameters('modelName')]" - }, - "modelFormat": { - "value": "[parameters('modelFormat')]" - }, - "modelVersion": { - "value": "[parameters('modelVersion')]" - }, - "modelSkuName": { - "value": "[parameters('modelSkuName')]" - }, - "modelCapacity": { - "value": "[parameters('modelCapacity')]" - }, - "agentSubnetId": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('vnet-{0}-{1}-deployment', variables('trimVnetName'), variables('uniqueSuffix'))), '2025-04-01').outputs.agentSubnetId.value]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "854097619778148359" - } - }, - "parameters": { - "accountName": { - "type": "string" - }, - "location": { - "type": "string" - }, - "modelName": { - "type": "string" - }, - "modelFormat": { - "type": "string" - }, - "modelVersion": { - "type": "string" - }, - "modelSkuName": { - "type": "string" - }, - "modelCapacity": { - "type": "int" - }, - "agentSubnetId": { - "type": "string" - }, - "networkInjection": { - "type": "string", - "defaultValue": "true" - } - }, - "resources": [ - { - "type": "Microsoft.CognitiveServices/accounts", - "apiVersion": "2025-04-01-preview", - "name": "[parameters('accountName')]", - "location": "[parameters('location')]", - "sku": { - "name": "S0" - }, - "kind": "AIServices", - "identity": { - "type": "SystemAssigned" - }, - "properties": { - "allowProjectManagement": true, - "customSubDomainName": "[parameters('accountName')]", - "networkAcls": { - "defaultAction": "Deny", - "virtualNetworkRules": [], - "ipRules": [], - "bypass": "AzureServices" - }, - "publicNetworkAccess": "Disabled", - "networkInjections": "[if(equals(parameters('networkInjection'), 'true'), createArray(createObject('scenario', 'agent', 'subnetArmId', parameters('agentSubnetId'), 'useMicrosoftManagedNetwork', false())), null())]", - "disableLocalAuth": false - } - }, - { - "type": "Microsoft.CognitiveServices/accounts/deployments", - "apiVersion": "2025-04-01-preview", - "name": "[format('{0}/{1}', parameters('accountName'), parameters('modelName'))]", - "sku": { - "capacity": "[parameters('modelCapacity')]", - "name": "[parameters('modelSkuName')]" - }, - "properties": { - "model": { - "name": "[parameters('modelName')]", - "format": "[parameters('modelFormat')]", - "version": "[parameters('modelVersion')]" - } - }, - "dependsOn": [ - "[resourceId('Microsoft.CognitiveServices/accounts', parameters('accountName'))]" - ] - } - ], - "outputs": { - "accountName": { - "type": "string", - "value": "[parameters('accountName')]" - }, - "accountID": { - "type": "string", - "value": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('accountName'))]" - }, - "accountTarget": { - "type": "string", - "value": "[reference(resourceId('Microsoft.CognitiveServices/accounts', parameters('accountName')), '2025-04-01-preview').endpoint]" - }, - "accountPrincipalId": { - "type": "string", - "value": "[reference(resourceId('Microsoft.CognitiveServices/accounts', parameters('accountName')), '2025-04-01-preview', 'full').identity.principalId]" - } - } - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', format('vnet-{0}-{1}-deployment', variables('trimVnetName'), variables('uniqueSuffix')))]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[format('validate-existing-resources-{0}-deployment', variables('uniqueSuffix'))]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "aiSearchResourceId": { - "value": "[parameters('aiSearchResourceId')]" - }, - "azureStorageAccountResourceId": { - "value": "[parameters('azureStorageAccountResourceId')]" - }, - "azureCosmosDBAccountResourceId": { - "value": "[parameters('azureCosmosDBAccountResourceId')]" - }, - "existingDnsZones": { - "value": "[parameters('existingDnsZones')]" - }, - "dnsZoneNames": { - "value": "[parameters('dnsZoneNames')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "7641310640078958122" - } - }, - "parameters": { - "aiSearchResourceId": { - "type": "string", - "metadata": { - "description": "Resource ID of the AI Search Service." - } - }, - "azureStorageAccountResourceId": { - "type": "string", - "metadata": { - "description": "Resource ID of the Azure Storage Account." - } - }, - "azureCosmosDBAccountResourceId": { - "type": "string", - "metadata": { - "description": "ResourceId of Cosmos DB Account" - } - }, - "existingDnsZones": { - "type": "object", - "metadata": { - "description": "Object mapping DNS zone names to their resource group, or empty string to indicate creation" - } - }, - "dnsZoneNames": { - "type": "array", - "metadata": { - "description": "List of private DNS zone names to validate" - } - } - }, - "variables": { - "storagePassedIn": "[not(equals(parameters('azureStorageAccountResourceId'), ''))]", - "searchPassedIn": "[not(equals(parameters('aiSearchResourceId'), ''))]", - "cosmosPassedIn": "[not(equals(parameters('azureCosmosDBAccountResourceId'), ''))]", - "storageParts": "[split(parameters('azureStorageAccountResourceId'), '/')]", - "azureStorageSubscriptionId": "[if(and(variables('storagePassedIn'), greater(length(variables('storageParts')), 2)), variables('storageParts')[2], subscription().subscriptionId)]", - "azureStorageResourceGroupName": "[if(and(variables('storagePassedIn'), greater(length(variables('storageParts')), 4)), variables('storageParts')[4], resourceGroup().name)]", - "acsParts": "[split(parameters('aiSearchResourceId'), '/')]", - "aiSearchServiceSubscriptionId": "[if(and(variables('searchPassedIn'), greater(length(variables('acsParts')), 2)), variables('acsParts')[2], subscription().subscriptionId)]", - "aiSearchServiceResourceGroupName": "[if(and(variables('searchPassedIn'), greater(length(variables('acsParts')), 4)), variables('acsParts')[4], resourceGroup().name)]", - "cosmosParts": "[split(parameters('azureCosmosDBAccountResourceId'), '/')]", - "cosmosDBSubscriptionId": "[if(and(variables('cosmosPassedIn'), greater(length(variables('cosmosParts')), 2)), variables('cosmosParts')[2], subscription().subscriptionId)]", - "cosmosDBResourceGroupName": "[if(and(variables('cosmosPassedIn'), greater(length(variables('cosmosParts')), 4)), variables('cosmosParts')[4], resourceGroup().name)]", - "dnsZoneTypes": [ - "Microsoft.Network/privateDnsZones" - ] - }, - "resources": [], - "outputs": { - "aiSearchExists": { - "type": "bool", - "value": "[and(variables('searchPassedIn'), equals(last(split(parameters('aiSearchResourceId'), '/')), variables('acsParts')[8]))]" - }, - "cosmosDBExists": { - "type": "bool", - "value": "[and(variables('cosmosPassedIn'), equals(last(split(parameters('azureCosmosDBAccountResourceId'), '/')), variables('cosmosParts')[8]))]" - }, - "azureStorageExists": { - "type": "bool", - "value": "[and(variables('storagePassedIn'), equals(last(split(parameters('azureStorageAccountResourceId'), '/')), variables('storageParts')[8]))]" - }, - "aiSearchServiceSubscriptionId": { - "type": "string", - "value": "[variables('aiSearchServiceSubscriptionId')]" - }, - "aiSearchServiceResourceGroupName": { - "type": "string", - "value": "[variables('aiSearchServiceResourceGroupName')]" - }, - "cosmosDBSubscriptionId": { - "type": "string", - "value": "[variables('cosmosDBSubscriptionId')]" - }, - "cosmosDBResourceGroupName": { - "type": "string", - "value": "[variables('cosmosDBResourceGroupName')]" - }, - "azureStorageSubscriptionId": { - "type": "string", - "value": "[variables('azureStorageSubscriptionId')]" - }, - "azureStorageResourceGroupName": { - "type": "string", - "value": "[variables('azureStorageResourceGroupName')]" - }, - "dnsZoneExists": { - "type": "array", - "copy": { - "count": "[length(parameters('dnsZoneNames'))]", - "input": { - "name": "[parameters('dnsZoneNames')[copyIndex()]]", - "exists": "[not(empty(parameters('existingDnsZones')[parameters('dnsZoneNames')[copyIndex()]]))]" - } - } - } - } - } - } - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[format('dependencies-{0}-deployment', variables('uniqueSuffix'))]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "location": { - "value": "[parameters('location')]" - }, - "azureStorageName": { - "value": "[variables('azureStorageName')]" - }, - "aiSearchName": { - "value": "[variables('aiSearchName')]" - }, - "cosmosDBName": { - "value": "[variables('cosmosDBName')]" - }, - "aiSearchResourceId": { - "value": "[parameters('aiSearchResourceId')]" - }, - "aiSearchExists": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('validate-existing-resources-{0}-deployment', variables('uniqueSuffix'))), '2025-04-01').outputs.aiSearchExists.value]" - }, - "azureStorageAccountResourceId": { - "value": "[parameters('azureStorageAccountResourceId')]" - }, - "azureStorageExists": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('validate-existing-resources-{0}-deployment', variables('uniqueSuffix'))), '2025-04-01').outputs.azureStorageExists.value]" - }, - "cosmosDBResourceId": { - "value": "[parameters('azureCosmosDBAccountResourceId')]" - }, - "cosmosDBExists": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('validate-existing-resources-{0}-deployment', variables('uniqueSuffix'))), '2025-04-01').outputs.cosmosDBExists.value]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "2754228344238136934" - } - }, - "parameters": { - "location": { - "type": "string", - "metadata": { - "description": "Azure region of the deployment" - } - }, - "aiSearchName": { - "type": "string", - "metadata": { - "description": "The name of the AI Search resource" - } - }, - "azureStorageName": { - "type": "string", - "metadata": { - "description": "Name of the storage account" - } - }, - "cosmosDBName": { - "type": "string", - "metadata": { - "description": "Name of the new Cosmos DB account" - } - }, - "aiSearchResourceId": { - "type": "string", - "metadata": { - "description": "The AI Search Service full ARM Resource ID. This is an optional field, and if not provided, the resource will be created." - } - }, - "azureStorageAccountResourceId": { - "type": "string", - "metadata": { - "description": "The AI Storage Account full ARM Resource ID. This is an optional field, and if not provided, the resource will be created." - } - }, - "cosmosDBResourceId": { - "type": "string", - "metadata": { - "description": "The Cosmos DB Account full ARM Resource ID. This is an optional field, and if not provided, the resource will be created." - } - }, - "aiSearchExists": { - "type": "bool" - }, - "azureStorageExists": { - "type": "bool" - }, - "cosmosDBExists": { - "type": "bool" - }, - "noZRSRegions": { - "type": "array", - "defaultValue": [ - "southindia", - "westus" - ] - }, - "sku": { - "type": "object", - "defaultValue": "[if(contains(parameters('noZRSRegions'), parameters('location')), createObject('name', 'Standard_GRS'), createObject('name', 'Standard_ZRS'))]" - } - }, - "variables": { - "cosmosParts": "[split(parameters('cosmosDBResourceId'), '/')]", - "canaryRegions": [ - "eastus2euap", - "centraluseuap" - ], - "cosmosDbRegion": "[if(contains(variables('canaryRegions'), parameters('location')), 'westus', parameters('location'))]", - "acsParts": "[split(parameters('aiSearchResourceId'), '/')]", - "azureStorageParts": "[split(parameters('azureStorageAccountResourceId'), '/')]" - }, - "resources": [ - { - "condition": "[not(parameters('cosmosDBExists'))]", - "type": "Microsoft.DocumentDB/databaseAccounts", - "apiVersion": "2024-11-15", - "name": "[parameters('cosmosDBName')]", - "location": "[variables('cosmosDbRegion')]", - "kind": "GlobalDocumentDB", - "properties": { - "consistencyPolicy": { - "defaultConsistencyLevel": "Session" - }, - "disableLocalAuth": true, - "enableAutomaticFailover": false, - "enableMultipleWriteLocations": false, - "publicNetworkAccess": "Disabled", - "enableFreeTier": false, - "locations": [ - { - "locationName": "[parameters('location')]", - "failoverPriority": 0, - "isZoneRedundant": false - } - ], - "databaseAccountOfferType": "Standard" - } - }, - { - "condition": "[not(parameters('aiSearchExists'))]", - "type": "Microsoft.Search/searchServices", - "apiVersion": "2024-06-01-preview", - "name": "[parameters('aiSearchName')]", - "location": "[parameters('location')]", - "identity": { - "type": "SystemAssigned" - }, - "properties": { - "disableLocalAuth": false, - "authOptions": { - "aadOrApiKey": { - "aadAuthFailureMode": "http401WithBearerChallenge" - } - }, - "encryptionWithCmk": { - "enforcement": "Unspecified" - }, - "hostingMode": "default", - "partitionCount": 1, - "publicNetworkAccess": "disabled", - "replicaCount": 1, - "semanticSearch": "disabled", - "networkRuleSet": { - "bypass": "None", - "ipRules": [] - } - }, - "sku": { - "name": "standard" - } - }, - { - "condition": "[not(parameters('azureStorageExists'))]", - "type": "Microsoft.Storage/storageAccounts", - "apiVersion": "2023-05-01", - "name": "[parameters('azureStorageName')]", - "location": "[parameters('location')]", - "kind": "StorageV2", - "sku": "[parameters('sku')]", - "properties": { - "minimumTlsVersion": "TLS1_2", - "allowBlobPublicAccess": false, - "publicNetworkAccess": "Disabled", - "networkAcls": { - "bypass": "AzureServices", - "defaultAction": "Deny", - "virtualNetworkRules": [] - }, - "allowSharedKeyAccess": false - } - } - ], - "outputs": { - "aiSearchName": { - "type": "string", - "value": "[if(parameters('aiSearchExists'), variables('acsParts')[8], parameters('aiSearchName'))]" - }, - "aiSearchID": { - "type": "string", - "value": "[if(parameters('aiSearchExists'), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('acsParts')[2], variables('acsParts')[4]), 'Microsoft.Search/searchServices', variables('acsParts')[8]), resourceId('Microsoft.Search/searchServices', parameters('aiSearchName')))]" - }, - "aiSearchServiceResourceGroupName": { - "type": "string", - "value": "[if(parameters('aiSearchExists'), variables('acsParts')[4], resourceGroup().name)]" - }, - "aiSearchServiceSubscriptionId": { - "type": "string", - "value": "[if(parameters('aiSearchExists'), variables('acsParts')[2], subscription().subscriptionId)]" - }, - "azureStorageName": { - "type": "string", - "value": "[if(parameters('azureStorageExists'), variables('azureStorageParts')[8], parameters('azureStorageName'))]" - }, - "azureStorageId": { - "type": "string", - "value": "[if(parameters('azureStorageExists'), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('azureStorageParts')[2], variables('azureStorageParts')[4]), 'Microsoft.Storage/storageAccounts', variables('azureStorageParts')[8]), resourceId('Microsoft.Storage/storageAccounts', parameters('azureStorageName')))]" - }, - "azureStorageResourceGroupName": { - "type": "string", - "value": "[if(parameters('azureStorageExists'), variables('azureStorageParts')[4], resourceGroup().name)]" - }, - "azureStorageSubscriptionId": { - "type": "string", - "value": "[if(parameters('azureStorageExists'), variables('azureStorageParts')[2], subscription().subscriptionId)]" - }, - "cosmosDBName": { - "type": "string", - "value": "[if(parameters('cosmosDBExists'), variables('cosmosParts')[8], parameters('cosmosDBName'))]" - }, - "cosmosDBId": { - "type": "string", - "value": "[if(parameters('cosmosDBExists'), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('cosmosParts')[2], variables('cosmosParts')[4]), 'Microsoft.DocumentDB/databaseAccounts', variables('cosmosParts')[8]), resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('cosmosDBName')))]" - }, - "cosmosDBResourceGroupName": { - "type": "string", - "value": "[if(parameters('cosmosDBExists'), variables('cosmosParts')[4], resourceGroup().name)]" - }, - "cosmosDBSubscriptionId": { - "type": "string", - "value": "[if(parameters('cosmosDBExists'), variables('cosmosParts')[2], subscription().subscriptionId)]" - } - } - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', format('validate-existing-resources-{0}-deployment', variables('uniqueSuffix')))]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[format('{0}-private-endpoint', variables('uniqueSuffix'))]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "aiAccountName": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('accountName'), variables('uniqueSuffix'))), '2025-04-01').outputs.accountName.value]" - }, - "aiSearchName": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('dependencies-{0}-deployment', variables('uniqueSuffix'))), '2025-04-01').outputs.aiSearchName.value]" - }, - "storageName": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('dependencies-{0}-deployment', variables('uniqueSuffix'))), '2025-04-01').outputs.azureStorageName.value]" - }, - "cosmosDBName": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('dependencies-{0}-deployment', variables('uniqueSuffix'))), '2025-04-01').outputs.cosmosDBName.value]" - }, - "fabricWorkspaceResourceId": { - "value": "[parameters('fabricWorkspaceResourceId')]" - }, - "vnetName": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('vnet-{0}-{1}-deployment', variables('trimVnetName'), variables('uniqueSuffix'))), '2025-04-01').outputs.virtualNetworkName.value]" - }, - "peSubnetName": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('vnet-{0}-{1}-deployment', variables('trimVnetName'), variables('uniqueSuffix'))), '2025-04-01').outputs.peSubnetName.value]" - }, - "suffix": { - "value": "[variables('uniqueSuffix')]" - }, - "vnetResourceGroupName": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('vnet-{0}-{1}-deployment', variables('trimVnetName'), variables('uniqueSuffix'))), '2025-04-01').outputs.virtualNetworkResourceGroup.value]" - }, - "vnetSubscriptionId": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('vnet-{0}-{1}-deployment', variables('trimVnetName'), variables('uniqueSuffix'))), '2025-04-01').outputs.virtualNetworkSubscriptionId.value]" - }, - "cosmosDBSubscriptionId": { - "value": "[variables('cosmosDBSubscriptionId')]" - }, - "cosmosDBResourceGroupName": { - "value": "[variables('cosmosDBResourceGroupName')]" - }, - "aiSearchSubscriptionId": { - "value": "[variables('aiSearchServiceSubscriptionId')]" - }, - "aiSearchResourceGroupName": { - "value": "[variables('aiSearchServiceResourceGroupName')]" - }, - "storageAccountResourceGroupName": { - "value": "[variables('azureStorageResourceGroupName')]" - }, - "storageAccountSubscriptionId": { - "value": "[variables('azureStorageSubscriptionId')]" - }, - "existingDnsZones": { - "value": "[parameters('existingDnsZones')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "10536644141407027053" - } - }, - "parameters": { - "aiAccountName": { - "type": "string", - "metadata": { - "description": "Name of the AI Foundry account" - } - }, - "aiSearchName": { - "type": "string", - "metadata": { - "description": "Name of the AI Search service" - } - }, - "storageName": { - "type": "string", - "metadata": { - "description": "Name of the storage account" - } - }, - "cosmosDBName": { - "type": "string", - "metadata": { - "description": "Name of the Cosmos DB account" - } - }, - "fabricWorkspaceResourceId": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "The Microsoft Fabric Workspace full ARM Resource ID. Optional - leave empty to skip Fabric private endpoint." - } - }, - "vnetName": { - "type": "string", - "metadata": { - "description": "Name of the Vnet" - } - }, - "peSubnetName": { - "type": "string", - "metadata": { - "description": "Name of the Customer subnet" - } - }, - "suffix": { - "type": "string", - "metadata": { - "description": "Suffix for unique resource names" - } - }, - "vnetResourceGroupName": { - "type": "string", - "defaultValue": "[resourceGroup().name]", - "metadata": { - "description": "Resource Group name for existing Virtual Network (if different from current resource group)" - } - }, - "vnetSubscriptionId": { - "type": "string", - "defaultValue": "[subscription().subscriptionId]", - "metadata": { - "description": "Subscription ID for Virtual Network" - } - }, - "storageAccountResourceGroupName": { - "type": "string", - "defaultValue": "[resourceGroup().name]", - "metadata": { - "description": "Resource Group name for Storage Account" - } - }, - "storageAccountSubscriptionId": { - "type": "string", - "defaultValue": "[subscription().subscriptionId]", - "metadata": { - "description": "Subscription ID for Storage account" - } - }, - "aiSearchSubscriptionId": { - "type": "string", - "defaultValue": "[subscription().subscriptionId]", - "metadata": { - "description": "Subscription ID for AI Search service" - } - }, - "aiSearchResourceGroupName": { - "type": "string", - "defaultValue": "[resourceGroup().name]", - "metadata": { - "description": "Resource Group name for AI Search service" - } - }, - "cosmosDBSubscriptionId": { - "type": "string", - "defaultValue": "[subscription().subscriptionId]", - "metadata": { - "description": "Subscription ID for Cosmos DB account" - } - }, - "cosmosDBResourceGroupName": { - "type": "string", - "defaultValue": "[resourceGroup().name]", - "metadata": { - "description": "Resource group name for Cosmos DB account" - } - }, - "existingDnsZones": { - "type": "object", - "defaultValue": { - "privatelink.services.ai.azure.com": "", - "privatelink.openai.azure.com": "", - "privatelink.cognitiveservices.azure.com": "", - "privatelink.search.windows.net": "", - "[format('privatelink.blob.{0}', environment().suffixes.storage)]": "", - "privatelink.documents.azure.com": "", - "privatelink.fabric.microsoft.com": "" - }, - "metadata": { - "description": "Map of DNS zone FQDNs to resource group names. If provided, reference existing DNS zones in this resource group instead of creating them." - } - } - }, - "variables": { - "fabricPassedIn": "[not(equals(parameters('fabricWorkspaceResourceId'), ''))]", - "fabricParts": "[split(parameters('fabricWorkspaceResourceId'), '/')]", - "fabricWorkspaceName": "[if(variables('fabricPassedIn'), last(variables('fabricParts')), '')]", - "aiServicesDnsZoneName": "privatelink.services.ai.azure.com", - "openAiDnsZoneName": "privatelink.openai.azure.com", - "cognitiveServicesDnsZoneName": "privatelink.cognitiveservices.azure.com", - "aiSearchDnsZoneName": "privatelink.search.windows.net", - "storageDnsZoneName": "[format('privatelink.blob.{0}', environment().suffixes.storage)]", - "cosmosDBDnsZoneName": "privatelink.documents.azure.com", - "fabricDnsZoneName": "privatelink.fabric.microsoft.com", - "aiServicesDnsZoneRG": "[parameters('existingDnsZones')[variables('aiServicesDnsZoneName')]]", - "openAiDnsZoneRG": "[parameters('existingDnsZones')[variables('openAiDnsZoneName')]]", - "cognitiveServicesDnsZoneRG": "[parameters('existingDnsZones')[variables('cognitiveServicesDnsZoneName')]]", - "aiSearchDnsZoneRG": "[parameters('existingDnsZones')[variables('aiSearchDnsZoneName')]]", - "storageDnsZoneRG": "[parameters('existingDnsZones')[variables('storageDnsZoneName')]]", - "cosmosDBDnsZoneRG": "[parameters('existingDnsZones')[variables('cosmosDBDnsZoneName')]]", - "fabricDnsZoneRG": "[coalesce(tryGet(parameters('existingDnsZones'), 'fabricDnsZoneName'), '')]", - "aiServicesDnsZoneId": "[if(empty(variables('aiServicesDnsZoneRG')), resourceId('Microsoft.Network/privateDnsZones', variables('aiServicesDnsZoneName')), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('aiServicesDnsZoneRG')), 'Microsoft.Network/privateDnsZones', variables('aiServicesDnsZoneName')))]", - "openAiDnsZoneId": "[if(empty(variables('openAiDnsZoneRG')), resourceId('Microsoft.Network/privateDnsZones', variables('openAiDnsZoneName')), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('openAiDnsZoneRG')), 'Microsoft.Network/privateDnsZones', variables('openAiDnsZoneName')))]", - "cognitiveServicesDnsZoneId": "[if(empty(variables('cognitiveServicesDnsZoneRG')), resourceId('Microsoft.Network/privateDnsZones', variables('cognitiveServicesDnsZoneName')), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('cognitiveServicesDnsZoneRG')), 'Microsoft.Network/privateDnsZones', variables('cognitiveServicesDnsZoneName')))]", - "aiSearchDnsZoneId": "[if(empty(variables('aiSearchDnsZoneRG')), resourceId('Microsoft.Network/privateDnsZones', variables('aiSearchDnsZoneName')), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('aiSearchDnsZoneRG')), 'Microsoft.Network/privateDnsZones', variables('aiSearchDnsZoneName')))]", - "storageDnsZoneId": "[if(empty(variables('storageDnsZoneRG')), resourceId('Microsoft.Network/privateDnsZones', variables('storageDnsZoneName')), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('storageDnsZoneRG')), 'Microsoft.Network/privateDnsZones', variables('storageDnsZoneName')))]", - "cosmosDBDnsZoneId": "[if(empty(variables('cosmosDBDnsZoneRG')), resourceId('Microsoft.Network/privateDnsZones', variables('cosmosDBDnsZoneName')), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('cosmosDBDnsZoneRG')), 'Microsoft.Network/privateDnsZones', variables('cosmosDBDnsZoneName')))]", - "fabricDnsZoneId": "[if(variables('fabricPassedIn'), if(empty(variables('fabricDnsZoneRG')), resourceId('Microsoft.Network/privateDnsZones', variables('fabricDnsZoneName')), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('fabricDnsZoneRG')), 'Microsoft.Network/privateDnsZones', variables('fabricDnsZoneName'))), '')]" - }, - "resources": [ - { - "type": "Microsoft.Network/privateEndpoints", - "apiVersion": "2024-05-01", - "name": "[format('{0}-private-endpoint', parameters('aiAccountName'))]", - "location": "[resourceGroup().location]", - "properties": { - "subnet": { - "id": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('vnetSubscriptionId'), parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks/subnets', parameters('vnetName'), parameters('peSubnetName'))]" - }, - "privateLinkServiceConnections": [ - { - "name": "[format('{0}-private-link-service-connection', parameters('aiAccountName'))]", - "properties": { - "privateLinkServiceId": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('aiAccountName'))]", - "groupIds": [ - "account" - ] - } - } - ] - } - }, - { - "type": "Microsoft.Network/privateEndpoints", - "apiVersion": "2024-05-01", - "name": "[format('{0}-private-endpoint', parameters('aiSearchName'))]", - "location": "[resourceGroup().location]", - "properties": { - "subnet": { - "id": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('vnetSubscriptionId'), parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks/subnets', parameters('vnetName'), parameters('peSubnetName'))]" - }, - "privateLinkServiceConnections": [ - { - "name": "[format('{0}-private-link-service-connection', parameters('aiSearchName'))]", - "properties": { - "privateLinkServiceId": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('aiSearchSubscriptionId'), parameters('aiSearchResourceGroupName')), 'Microsoft.Search/searchServices', parameters('aiSearchName'))]", - "groupIds": [ - "searchService" - ] - } - } - ] - } - }, - { - "type": "Microsoft.Network/privateEndpoints", - "apiVersion": "2024-05-01", - "name": "[format('{0}-private-endpoint', parameters('storageName'))]", - "location": "[resourceGroup().location]", - "properties": { - "subnet": { - "id": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('vnetSubscriptionId'), parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks/subnets', parameters('vnetName'), parameters('peSubnetName'))]" - }, - "privateLinkServiceConnections": [ - { - "name": "[format('{0}-private-link-service-connection', parameters('storageName'))]", - "properties": { - "privateLinkServiceId": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('storageAccountSubscriptionId'), parameters('storageAccountResourceGroupName')), 'Microsoft.Storage/storageAccounts', parameters('storageName'))]", - "groupIds": [ - "blob" - ] - } - } - ] - } - }, - { - "type": "Microsoft.Network/privateEndpoints", - "apiVersion": "2024-05-01", - "name": "[format('{0}-private-endpoint', parameters('cosmosDBName'))]", - "location": "[resourceGroup().location]", - "properties": { - "subnet": { - "id": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('vnetSubscriptionId'), parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks/subnets', parameters('vnetName'), parameters('peSubnetName'))]" - }, - "privateLinkServiceConnections": [ - { - "name": "[format('{0}-private-link-service-connection', parameters('cosmosDBName'))]", - "properties": { - "privateLinkServiceId": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('cosmosDBSubscriptionId'), parameters('cosmosDBResourceGroupName')), 'Microsoft.DocumentDB/databaseAccounts', parameters('cosmosDBName'))]", - "groupIds": [ - "Sql" - ] - } - } - ] - } - }, - { - "condition": "[variables('fabricPassedIn')]", - "type": "Microsoft.Network/privateEndpoints", - "apiVersion": "2024-05-01", - "name": "[format('{0}-fabric-private-endpoint', variables('fabricWorkspaceName'))]", - "location": "[resourceGroup().location]", - "properties": { - "subnet": { - "id": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('vnetSubscriptionId'), parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks/subnets', parameters('vnetName'), parameters('peSubnetName'))]" - }, - "privateLinkServiceConnections": [ - { - "name": "[format('{0}-private-link-service-connection', variables('fabricWorkspaceName'))]", - "properties": { - "privateLinkServiceId": "[parameters('fabricWorkspaceResourceId')]", - "groupIds": [ - "Fabric" - ] - } - } - ] - } - }, - { - "condition": "[empty(variables('aiServicesDnsZoneRG'))]", - "type": "Microsoft.Network/privateDnsZones", - "apiVersion": "2020-06-01", - "name": "[variables('aiServicesDnsZoneName')]", - "location": "global" - }, - { - "condition": "[empty(variables('openAiDnsZoneRG'))]", - "type": "Microsoft.Network/privateDnsZones", - "apiVersion": "2020-06-01", - "name": "[variables('openAiDnsZoneName')]", - "location": "global" - }, - { - "condition": "[empty(variables('cognitiveServicesDnsZoneRG'))]", - "type": "Microsoft.Network/privateDnsZones", - "apiVersion": "2020-06-01", - "name": "[variables('cognitiveServicesDnsZoneName')]", - "location": "global" - }, - { - "condition": "[empty(variables('aiSearchDnsZoneRG'))]", - "type": "Microsoft.Network/privateDnsZones", - "apiVersion": "2020-06-01", - "name": "[variables('aiSearchDnsZoneName')]", - "location": "global" - }, - { - "condition": "[empty(variables('storageDnsZoneRG'))]", - "type": "Microsoft.Network/privateDnsZones", - "apiVersion": "2020-06-01", - "name": "[variables('storageDnsZoneName')]", - "location": "global" - }, - { - "condition": "[empty(variables('cosmosDBDnsZoneRG'))]", - "type": "Microsoft.Network/privateDnsZones", - "apiVersion": "2020-06-01", - "name": "[variables('cosmosDBDnsZoneName')]", - "location": "global" - }, - { - "condition": "[and(variables('fabricPassedIn'), empty(variables('fabricDnsZoneRG')))]", - "type": "Microsoft.Network/privateDnsZones", - "apiVersion": "2020-06-01", - "name": "[variables('fabricDnsZoneName')]", - "location": "global" - }, - { - "condition": "[empty(variables('aiServicesDnsZoneRG'))]", - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2024-06-01", - "name": "[format('{0}/{1}', variables('aiServicesDnsZoneName'), format('aiServices-{0}-link', parameters('suffix')))]", - "location": "global", - "properties": { - "virtualNetwork": { - "id": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('vnetSubscriptionId'), parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName'))]" - }, - "registrationEnabled": false - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones', variables('aiServicesDnsZoneName'))]" - ] - }, - { - "condition": "[empty(variables('openAiDnsZoneRG'))]", - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2024-06-01", - "name": "[format('{0}/{1}', variables('openAiDnsZoneName'), format('aiServicesOpenAI-{0}-link', parameters('suffix')))]", - "location": "global", - "properties": { - "virtualNetwork": { - "id": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('vnetSubscriptionId'), parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName'))]" - }, - "registrationEnabled": false - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones', variables('openAiDnsZoneName'))]" - ] - }, - { - "condition": "[empty(variables('cognitiveServicesDnsZoneRG'))]", - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2024-06-01", - "name": "[format('{0}/{1}', variables('cognitiveServicesDnsZoneName'), format('aiServicesCognitiveServices-{0}-link', parameters('suffix')))]", - "location": "global", - "properties": { - "virtualNetwork": { - "id": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('vnetSubscriptionId'), parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName'))]" - }, - "registrationEnabled": false - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones', variables('cognitiveServicesDnsZoneName'))]" - ] - }, - { - "condition": "[empty(variables('aiSearchDnsZoneRG'))]", - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2024-06-01", - "name": "[format('{0}/{1}', variables('aiSearchDnsZoneName'), format('aiSearch-{0}-link', parameters('suffix')))]", - "location": "global", - "properties": { - "virtualNetwork": { - "id": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('vnetSubscriptionId'), parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName'))]" - }, - "registrationEnabled": false - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones', variables('aiSearchDnsZoneName'))]" - ] - }, - { - "condition": "[empty(variables('storageDnsZoneRG'))]", - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2024-06-01", - "name": "[format('{0}/{1}', variables('storageDnsZoneName'), format('storage-{0}-link', parameters('suffix')))]", - "location": "global", - "properties": { - "virtualNetwork": { - "id": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('vnetSubscriptionId'), parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName'))]" - }, - "registrationEnabled": false - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones', variables('storageDnsZoneName'))]" - ] - }, - { - "condition": "[empty(variables('cosmosDBDnsZoneRG'))]", - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2024-06-01", - "name": "[format('{0}/{1}', variables('cosmosDBDnsZoneName'), format('cosmosDB-{0}-link', parameters('suffix')))]", - "location": "global", - "properties": { - "virtualNetwork": { - "id": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('vnetSubscriptionId'), parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName'))]" - }, - "registrationEnabled": false - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones', variables('cosmosDBDnsZoneName'))]" - ] - }, - { - "condition": "[and(variables('fabricPassedIn'), empty(variables('fabricDnsZoneRG')))]", - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2024-06-01", - "name": "[format('{0}/{1}', variables('fabricDnsZoneName'), format('fabric-{0}-link', parameters('suffix')))]", - "location": "global", - "properties": { - "virtualNetwork": { - "id": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('vnetSubscriptionId'), parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName'))]" - }, - "registrationEnabled": false - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones', variables('fabricDnsZoneName'))]" - ] - }, - { - "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", - "apiVersion": "2024-05-01", - "name": "[format('{0}/{1}', format('{0}-private-endpoint', parameters('aiAccountName')), format('{0}-dns-group', parameters('aiAccountName')))]", - "properties": { - "privateDnsZoneConfigs": [ - { - "name": "[format('{0}-dns-aiserv-config', parameters('aiAccountName'))]", - "properties": { - "privateDnsZoneId": "[variables('aiServicesDnsZoneId')]" - } - }, - { - "name": "[format('{0}-dns-openai-config', parameters('aiAccountName'))]", - "properties": { - "privateDnsZoneId": "[variables('openAiDnsZoneId')]" - } - }, - { - "name": "[format('{0}-dns-cogserv-config', parameters('aiAccountName'))]", - "properties": { - "privateDnsZoneId": "[variables('cognitiveServicesDnsZoneId')]" - } - } - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateEndpoints', format('{0}-private-endpoint', parameters('aiAccountName')))]", - "[resourceId('Microsoft.Network/privateDnsZones/virtualNetworkLinks', variables('aiServicesDnsZoneName'), format('aiServices-{0}-link', parameters('suffix')))]", - "[resourceId('Microsoft.Network/privateDnsZones', variables('aiServicesDnsZoneName'))]", - "[resourceId('Microsoft.Network/privateDnsZones/virtualNetworkLinks', variables('cognitiveServicesDnsZoneName'), format('aiServicesCognitiveServices-{0}-link', parameters('suffix')))]", - "[resourceId('Microsoft.Network/privateDnsZones', variables('cognitiveServicesDnsZoneName'))]", - "[resourceId('Microsoft.Network/privateDnsZones/virtualNetworkLinks', variables('openAiDnsZoneName'), format('aiServicesOpenAI-{0}-link', parameters('suffix')))]", - "[resourceId('Microsoft.Network/privateDnsZones', variables('openAiDnsZoneName'))]" - ] - }, - { - "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", - "apiVersion": "2024-05-01", - "name": "[format('{0}/{1}', format('{0}-private-endpoint', parameters('aiSearchName')), format('{0}-dns-group', parameters('aiSearchName')))]", - "properties": { - "privateDnsZoneConfigs": [ - { - "name": "[format('{0}-dns-config', parameters('aiSearchName'))]", - "properties": { - "privateDnsZoneId": "[variables('aiSearchDnsZoneId')]" - } - } - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones/virtualNetworkLinks', variables('aiSearchDnsZoneName'), format('aiSearch-{0}-link', parameters('suffix')))]", - "[resourceId('Microsoft.Network/privateDnsZones', variables('aiSearchDnsZoneName'))]", - "[resourceId('Microsoft.Network/privateEndpoints', format('{0}-private-endpoint', parameters('aiSearchName')))]" - ] - }, - { - "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", - "apiVersion": "2024-05-01", - "name": "[format('{0}/{1}', format('{0}-private-endpoint', parameters('storageName')), format('{0}-dns-group', parameters('storageName')))]", - "properties": { - "privateDnsZoneConfigs": [ - { - "name": "[format('{0}-dns-config', parameters('storageName'))]", - "properties": { - "privateDnsZoneId": "[variables('storageDnsZoneId')]" - } - } - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones/virtualNetworkLinks', variables('storageDnsZoneName'), format('storage-{0}-link', parameters('suffix')))]", - "[resourceId('Microsoft.Network/privateDnsZones', variables('storageDnsZoneName'))]", - "[resourceId('Microsoft.Network/privateEndpoints', format('{0}-private-endpoint', parameters('storageName')))]" - ] - }, - { - "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", - "apiVersion": "2024-05-01", - "name": "[format('{0}/{1}', format('{0}-private-endpoint', parameters('cosmosDBName')), format('{0}-dns-group', parameters('cosmosDBName')))]", - "properties": { - "privateDnsZoneConfigs": [ - { - "name": "[format('{0}-dns-config', parameters('cosmosDBName'))]", - "properties": { - "privateDnsZoneId": "[variables('cosmosDBDnsZoneId')]" - } - } - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones/virtualNetworkLinks', variables('cosmosDBDnsZoneName'), format('cosmosDB-{0}-link', parameters('suffix')))]", - "[resourceId('Microsoft.Network/privateDnsZones', variables('cosmosDBDnsZoneName'))]", - "[resourceId('Microsoft.Network/privateEndpoints', format('{0}-private-endpoint', parameters('cosmosDBName')))]" - ] - }, - { - "condition": "[variables('fabricPassedIn')]", - "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", - "apiVersion": "2024-05-01", - "name": "[format('{0}/{1}', format('{0}-fabric-private-endpoint', variables('fabricWorkspaceName')), format('{0}-dns-group', variables('fabricWorkspaceName')))]", - "properties": { - "privateDnsZoneConfigs": [ - { - "name": "[format('{0}-dns-config', variables('fabricWorkspaceName'))]", - "properties": { - "privateDnsZoneId": "[variables('fabricDnsZoneId')]" - } - } - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones/virtualNetworkLinks', variables('fabricDnsZoneName'), format('fabric-{0}-link', parameters('suffix')))]", - "[resourceId('Microsoft.Network/privateDnsZones', variables('fabricDnsZoneName'))]", - "[resourceId('Microsoft.Network/privateEndpoints', format('{0}-fabric-private-endpoint', variables('fabricWorkspaceName')))]" - ] - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('accountName'), variables('uniqueSuffix')))]", - "[resourceId('Microsoft.Resources/deployments', format('dependencies-{0}-deployment', variables('uniqueSuffix')))]", - "[resourceId('Microsoft.Resources/deployments', format('vnet-{0}-{1}-deployment', variables('trimVnetName'), variables('uniqueSuffix')))]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[format('{0}-{1}-deployment', variables('projectName'), variables('uniqueSuffix'))]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "projectName": { - "value": "[variables('projectName')]" - }, - "projectDescription": { - "value": "[parameters('projectDescription')]" - }, - "displayName": { - "value": "[parameters('displayName')]" - }, - "location": { - "value": "[parameters('location')]" - }, - "aiSearchName": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('dependencies-{0}-deployment', variables('uniqueSuffix'))), '2025-04-01').outputs.aiSearchName.value]" - }, - "aiSearchServiceResourceGroupName": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('dependencies-{0}-deployment', variables('uniqueSuffix'))), '2025-04-01').outputs.aiSearchServiceResourceGroupName.value]" - }, - "aiSearchServiceSubscriptionId": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('dependencies-{0}-deployment', variables('uniqueSuffix'))), '2025-04-01').outputs.aiSearchServiceSubscriptionId.value]" - }, - "cosmosDBName": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('dependencies-{0}-deployment', variables('uniqueSuffix'))), '2025-04-01').outputs.cosmosDBName.value]" - }, - "cosmosDBSubscriptionId": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('dependencies-{0}-deployment', variables('uniqueSuffix'))), '2025-04-01').outputs.cosmosDBSubscriptionId.value]" - }, - "cosmosDBResourceGroupName": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('dependencies-{0}-deployment', variables('uniqueSuffix'))), '2025-04-01').outputs.cosmosDBResourceGroupName.value]" - }, - "azureStorageName": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('dependencies-{0}-deployment', variables('uniqueSuffix'))), '2025-04-01').outputs.azureStorageName.value]" - }, - "azureStorageSubscriptionId": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('dependencies-{0}-deployment', variables('uniqueSuffix'))), '2025-04-01').outputs.azureStorageSubscriptionId.value]" - }, - "azureStorageResourceGroupName": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('dependencies-{0}-deployment', variables('uniqueSuffix'))), '2025-04-01').outputs.azureStorageResourceGroupName.value]" - }, - "accountName": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('accountName'), variables('uniqueSuffix'))), '2025-04-01').outputs.accountName.value]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "5095087340309076800" - } - }, - "parameters": { - "accountName": { - "type": "string" - }, - "location": { - "type": "string" - }, - "projectName": { - "type": "string" - }, - "projectDescription": { - "type": "string" - }, - "displayName": { - "type": "string" - }, - "aiSearchName": { - "type": "string" - }, - "aiSearchServiceResourceGroupName": { - "type": "string" - }, - "aiSearchServiceSubscriptionId": { - "type": "string" - }, - "cosmosDBName": { - "type": "string" - }, - "cosmosDBSubscriptionId": { - "type": "string" - }, - "cosmosDBResourceGroupName": { - "type": "string" - }, - "azureStorageName": { - "type": "string" - }, - "azureStorageSubscriptionId": { - "type": "string" - }, - "azureStorageResourceGroupName": { - "type": "string" - } - }, - "resources": [ - { - "type": "Microsoft.CognitiveServices/accounts/projects/connections", - "apiVersion": "2025-04-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('accountName'), parameters('projectName'), parameters('cosmosDBName'))]", - "properties": { - "category": "CosmosDB", - "target": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('cosmosDBSubscriptionId'), parameters('cosmosDBResourceGroupName')), 'Microsoft.DocumentDB/databaseAccounts', parameters('cosmosDBName')), '2024-12-01-preview').documentEndpoint]", - "authType": "AAD", - "metadata": { - "ApiType": "Azure", - "ResourceId": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('cosmosDBSubscriptionId'), parameters('cosmosDBResourceGroupName')), 'Microsoft.DocumentDB/databaseAccounts', parameters('cosmosDBName'))]", - "location": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('cosmosDBSubscriptionId'), parameters('cosmosDBResourceGroupName')), 'Microsoft.DocumentDB/databaseAccounts', parameters('cosmosDBName')), '2024-12-01-preview', 'full').location]" - } - }, - "dependsOn": [ - "[resourceId('Microsoft.CognitiveServices/accounts/projects', parameters('accountName'), parameters('projectName'))]" - ] - }, - { - "type": "Microsoft.CognitiveServices/accounts/projects/connections", - "apiVersion": "2025-04-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('accountName'), parameters('projectName'), parameters('azureStorageName'))]", - "properties": { - "category": "AzureStorageAccount", - "target": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('azureStorageSubscriptionId'), parameters('azureStorageResourceGroupName')), 'Microsoft.Storage/storageAccounts', parameters('azureStorageName')), '2023-05-01').primaryEndpoints.blob]", - "authType": "AAD", - "metadata": { - "ApiType": "Azure", - "ResourceId": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('azureStorageSubscriptionId'), parameters('azureStorageResourceGroupName')), 'Microsoft.Storage/storageAccounts', parameters('azureStorageName'))]", - "location": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('azureStorageSubscriptionId'), parameters('azureStorageResourceGroupName')), 'Microsoft.Storage/storageAccounts', parameters('azureStorageName')), '2023-05-01', 'full').location]" - } - }, - "dependsOn": [ - "[resourceId('Microsoft.CognitiveServices/accounts/projects', parameters('accountName'), parameters('projectName'))]" - ] - }, - { - "type": "Microsoft.CognitiveServices/accounts/projects/connections", - "apiVersion": "2025-04-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('accountName'), parameters('projectName'), parameters('aiSearchName'))]", - "properties": { - "category": "CognitiveSearch", - "target": "[format('https://{0}.search.windows.net', parameters('aiSearchName'))]", - "authType": "AAD", - "metadata": { - "ApiType": "Azure", - "ResourceId": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('aiSearchServiceSubscriptionId'), parameters('aiSearchServiceResourceGroupName')), 'Microsoft.Search/searchServices', parameters('aiSearchName'))]", - "location": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('aiSearchServiceSubscriptionId'), parameters('aiSearchServiceResourceGroupName')), 'Microsoft.Search/searchServices', parameters('aiSearchName')), '2024-06-01-preview', 'full').location]" - } - }, - "dependsOn": [ - "[resourceId('Microsoft.CognitiveServices/accounts/projects', parameters('accountName'), parameters('projectName'))]" - ] - }, - { - "type": "Microsoft.CognitiveServices/accounts/projects", - "apiVersion": "2025-04-01-preview", - "name": "[format('{0}/{1}', parameters('accountName'), parameters('projectName'))]", - "location": "[parameters('location')]", - "identity": { - "type": "SystemAssigned" - }, - "properties": { - "description": "[parameters('projectDescription')]", - "displayName": "[parameters('displayName')]" - } - } - ], - "outputs": { - "projectName": { - "type": "string", - "value": "[parameters('projectName')]" - }, - "projectId": { - "type": "string", - "value": "[resourceId('Microsoft.CognitiveServices/accounts/projects', parameters('accountName'), parameters('projectName'))]" - }, - "projectPrincipalId": { - "type": "string", - "value": "[reference(resourceId('Microsoft.CognitiveServices/accounts/projects', parameters('accountName'), parameters('projectName')), '2025-04-01-preview', 'full').identity.principalId]" - }, - "projectWorkspaceId": { - "type": "string", - "value": "[reference(resourceId('Microsoft.CognitiveServices/accounts/projects', parameters('accountName'), parameters('projectName')), '2025-04-01-preview').internalId]" - }, - "cosmosDBConnection": { - "type": "string", - "value": "[parameters('cosmosDBName')]" - }, - "azureStorageConnection": { - "type": "string", - "value": "[parameters('azureStorageName')]" - }, - "aiSearchConnection": { - "type": "string", - "value": "[parameters('aiSearchName')]" - } - } - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('accountName'), variables('uniqueSuffix')))]", - "[resourceId('Microsoft.Resources/deployments', format('dependencies-{0}-deployment', variables('uniqueSuffix')))]", - "[resourceId('Microsoft.Resources/deployments', format('{0}-private-endpoint', variables('uniqueSuffix')))]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[format('format-project-workspace-id-{0}-deployment', variables('uniqueSuffix'))]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "projectWorkspaceId": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('projectName'), variables('uniqueSuffix'))), '2025-04-01').outputs.projectWorkspaceId.value]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "6910483561575524105" - } - }, - "parameters": { - "projectWorkspaceId": { - "type": "string" - } - }, - "variables": { - "part1": "[substring(parameters('projectWorkspaceId'), 0, 8)]", - "part2": "[substring(parameters('projectWorkspaceId'), 8, 4)]", - "part3": "[substring(parameters('projectWorkspaceId'), 12, 4)]", - "part4": "[substring(parameters('projectWorkspaceId'), 16, 4)]", - "part5": "[substring(parameters('projectWorkspaceId'), 20, 12)]", - "formattedGuid": "[format('{0}-{1}-{2}-{3}-{4}', variables('part1'), variables('part2'), variables('part3'), variables('part4'), variables('part5'))]" - }, - "resources": [], - "outputs": { - "projectWorkspaceIdGuid": { - "type": "string", - "value": "[variables('formattedGuid')]" - } - } - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('projectName'), variables('uniqueSuffix')))]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[format('storage-{0}-{1}-deployment', variables('azureStorageName'), variables('uniqueSuffix'))]", - "subscriptionId": "[variables('azureStorageSubscriptionId')]", - "resourceGroup": "[variables('azureStorageResourceGroupName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "azureStorageName": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('dependencies-{0}-deployment', variables('uniqueSuffix'))), '2025-04-01').outputs.azureStorageName.value]" - }, - "projectPrincipalId": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('projectName'), variables('uniqueSuffix'))), '2025-04-01').outputs.projectPrincipalId.value]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "14683840003859985069" - } - }, - "parameters": { - "azureStorageName": { - "type": "string" - }, - "projectPrincipalId": { - "type": "string" - } - }, - "resources": [ - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[format('Microsoft.Storage/storageAccounts/{0}', parameters('azureStorageName'))]", - "name": "[guid(parameters('projectPrincipalId'), resourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe'), resourceId('Microsoft.Storage/storageAccounts', parameters('azureStorageName')))]", - "properties": { - "principalId": "[parameters('projectPrincipalId')]", - "roleDefinitionId": "[resourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe')]", - "principalType": "ServicePrincipal" - } - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', format('dependencies-{0}-deployment', variables('uniqueSuffix')))]", - "[resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('projectName'), variables('uniqueSuffix')))]", - "[resourceId('Microsoft.Resources/deployments', format('{0}-private-endpoint', variables('uniqueSuffix')))]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[format('cosmos-account-ra-{0}-deployment', variables('uniqueSuffix'))]", - "subscriptionId": "[variables('cosmosDBSubscriptionId')]", - "resourceGroup": "[variables('cosmosDBResourceGroupName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "cosmosDBName": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('dependencies-{0}-deployment', variables('uniqueSuffix'))), '2025-04-01').outputs.cosmosDBName.value]" - }, - "projectPrincipalId": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('projectName'), variables('uniqueSuffix'))), '2025-04-01').outputs.projectPrincipalId.value]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "25128059954858801" - } - }, - "parameters": { - "cosmosDBName": { - "type": "string", - "metadata": { - "description": "Name of the Cosmos DB resource" - } - }, - "projectPrincipalId": { - "type": "string", - "metadata": { - "description": "Principal ID of the AI project" - } - } - }, - "resources": [ - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[format('Microsoft.DocumentDB/databaseAccounts/{0}', parameters('cosmosDBName'))]", - "name": "[guid(parameters('projectPrincipalId'), resourceId('Microsoft.Authorization/roleDefinitions', '230815da-be43-4aae-9cb4-875f7bd000aa'), resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('cosmosDBName')))]", - "properties": { - "principalId": "[parameters('projectPrincipalId')]", - "roleDefinitionId": "[resourceId('Microsoft.Authorization/roleDefinitions', '230815da-be43-4aae-9cb4-875f7bd000aa')]", - "principalType": "ServicePrincipal" - } - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', format('dependencies-{0}-deployment', variables('uniqueSuffix')))]", - "[resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('projectName'), variables('uniqueSuffix')))]", - "[resourceId('Microsoft.Resources/deployments', format('{0}-private-endpoint', variables('uniqueSuffix')))]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[format('ai-search-ra-{0}-deployment', variables('uniqueSuffix'))]", - "subscriptionId": "[variables('aiSearchServiceSubscriptionId')]", - "resourceGroup": "[variables('aiSearchServiceResourceGroupName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "aiSearchName": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('dependencies-{0}-deployment', variables('uniqueSuffix'))), '2025-04-01').outputs.aiSearchName.value]" - }, - "projectPrincipalId": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('projectName'), variables('uniqueSuffix'))), '2025-04-01').outputs.projectPrincipalId.value]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "7968115481508840" - } - }, - "parameters": { - "aiSearchName": { - "type": "string", - "metadata": { - "description": "Name of the AI Search resource" - } - }, - "projectPrincipalId": { - "type": "string", - "metadata": { - "description": "Principal ID of the AI project" - } - } - }, - "resources": [ - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[format('Microsoft.Search/searchServices/{0}', parameters('aiSearchName'))]", - "name": "[guid(parameters('projectPrincipalId'), resourceId('Microsoft.Authorization/roleDefinitions', '8ebe5a00-799e-43f5-93ac-243d3dce84a7'), resourceId('Microsoft.Search/searchServices', parameters('aiSearchName')))]", - "properties": { - "principalId": "[parameters('projectPrincipalId')]", - "roleDefinitionId": "[resourceId('Microsoft.Authorization/roleDefinitions', '8ebe5a00-799e-43f5-93ac-243d3dce84a7')]", - "principalType": "ServicePrincipal" - } - }, - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[format('Microsoft.Search/searchServices/{0}', parameters('aiSearchName'))]", - "name": "[guid(parameters('projectPrincipalId'), resourceId('Microsoft.Authorization/roleDefinitions', '7ca78c08-252a-4471-8644-bb5ff32d4ba0'), resourceId('Microsoft.Search/searchServices', parameters('aiSearchName')))]", - "properties": { - "principalId": "[parameters('projectPrincipalId')]", - "roleDefinitionId": "[resourceId('Microsoft.Authorization/roleDefinitions', '7ca78c08-252a-4471-8644-bb5ff32d4ba0')]", - "principalType": "ServicePrincipal" - } - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', format('dependencies-{0}-deployment', variables('uniqueSuffix')))]", - "[resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('projectName'), variables('uniqueSuffix')))]", - "[resourceId('Microsoft.Resources/deployments', format('{0}-private-endpoint', variables('uniqueSuffix')))]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[format('capabilityHost-configuration-{0}-deployment', variables('uniqueSuffix'))]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "accountName": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('accountName'), variables('uniqueSuffix'))), '2025-04-01').outputs.accountName.value]" - }, - "projectName": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('projectName'), variables('uniqueSuffix'))), '2025-04-01').outputs.projectName.value]" - }, - "cosmosDBConnection": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('projectName'), variables('uniqueSuffix'))), '2025-04-01').outputs.cosmosDBConnection.value]" - }, - "azureStorageConnection": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('projectName'), variables('uniqueSuffix'))), '2025-04-01').outputs.azureStorageConnection.value]" - }, - "aiSearchConnection": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('projectName'), variables('uniqueSuffix'))), '2025-04-01').outputs.aiSearchConnection.value]" - }, - "projectCapHost": { - "value": "[parameters('projectCapHost')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "17458377866351620215" - } - }, - "parameters": { - "cosmosDBConnection": { - "type": "string" - }, - "azureStorageConnection": { - "type": "string" - }, - "aiSearchConnection": { - "type": "string" - }, - "projectName": { - "type": "string" - }, - "accountName": { - "type": "string" - }, - "projectCapHost": { - "type": "string" - } - }, - "variables": { - "threadConnections": [ - "[format('{0}', parameters('cosmosDBConnection'))]" - ], - "storageConnections": [ - "[format('{0}', parameters('azureStorageConnection'))]" - ], - "vectorStoreConnections": [ - "[format('{0}', parameters('aiSearchConnection'))]" - ] - }, - "resources": [ - { - "type": "Microsoft.CognitiveServices/accounts/projects/capabilityHosts", - "apiVersion": "2025-04-01-preview", - "name": "[format('{0}/{1}/{2}', parameters('accountName'), parameters('projectName'), parameters('projectCapHost'))]", - "properties": { - "capabilityHostKind": "Agents", - "vectorStoreConnections": "[variables('vectorStoreConnections')]", - "storageConnections": "[variables('storageConnections')]", - "threadStorageConnections": "[variables('threadConnections')]" - } - } - ], - "outputs": { - "projectCapHost": { - "type": "string", - "value": "[parameters('projectCapHost')]" - } - } - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('accountName'), variables('uniqueSuffix')))]", - "[resourceId('Microsoft.Resources/deployments', format('dependencies-{0}-deployment', variables('uniqueSuffix')))]", - "[resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('projectName'), variables('uniqueSuffix')))]", - "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('aiSearchServiceSubscriptionId'), variables('aiSearchServiceResourceGroupName')), 'Microsoft.Resources/deployments', format('ai-search-ra-{0}-deployment', variables('uniqueSuffix')))]", - "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('cosmosDBSubscriptionId'), variables('cosmosDBResourceGroupName')), 'Microsoft.Resources/deployments', format('cosmos-account-ra-{0}-deployment', variables('uniqueSuffix')))]", - "[resourceId('Microsoft.Resources/deployments', format('{0}-private-endpoint', variables('uniqueSuffix')))]", - "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('azureStorageSubscriptionId'), variables('azureStorageResourceGroupName')), 'Microsoft.Resources/deployments', format('storage-{0}-{1}-deployment', variables('azureStorageName'), variables('uniqueSuffix')))]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[format('storage-containers-ra-{0}-deployment', variables('uniqueSuffix'))]", - "subscriptionId": "[variables('azureStorageSubscriptionId')]", - "resourceGroup": "[variables('azureStorageResourceGroupName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "aiProjectPrincipalId": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('projectName'), variables('uniqueSuffix'))), '2025-04-01').outputs.projectPrincipalId.value]" - }, - "storageName": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('dependencies-{0}-deployment', variables('uniqueSuffix'))), '2025-04-01').outputs.azureStorageName.value]" - }, - "workspaceId": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('format-project-workspace-id-{0}-deployment', variables('uniqueSuffix'))), '2025-04-01').outputs.projectWorkspaceIdGuid.value]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "13874725855824693255" - } - }, - "parameters": { - "storageName": { - "type": "string", - "metadata": { - "description": "Name of the storage account" - } - }, - "aiProjectPrincipalId": { - "type": "string", - "metadata": { - "description": "Principal ID of the AI Project" - } - }, - "workspaceId": { - "type": "string", - "metadata": { - "description": "Workspace Id of the AI Project" - } - } - }, - "variables": { - "conditionStr": "[format('((!(ActionMatches{{''Microsoft.Storage/storageAccounts/blobServices/containers/blobs/tags/read''}}) AND !(ActionMatches{{''Microsoft.Storage/storageAccounts/blobServices/containers/blobs/filter/action''}}) AND !(ActionMatches{{''Microsoft.Storage/storageAccounts/blobServices/containers/blobs/tags/write''}}) ) OR (@Resource[Microsoft.Storage/storageAccounts/blobServices/containers:name] StringStartsWithIgnoreCase ''{0}'' AND @Resource[Microsoft.Storage/storageAccounts/blobServices/containers:name] StringLikeIgnoreCase ''*-azureml-agent''))', parameters('workspaceId'))]" - }, - "resources": [ - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[format('Microsoft.Storage/storageAccounts/{0}', parameters('storageName'))]", - "name": "[guid(resourceId('Microsoft.Authorization/roleDefinitions', 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b'), resourceId('Microsoft.Storage/storageAccounts', parameters('storageName')))]", - "properties": { - "principalId": "[parameters('aiProjectPrincipalId')]", - "roleDefinitionId": "[resourceId('Microsoft.Authorization/roleDefinitions', 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b')]", - "principalType": "ServicePrincipal", - "conditionVersion": "2.0", - "condition": "[variables('conditionStr')]" - } - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', format('capabilityHost-configuration-{0}-deployment', variables('uniqueSuffix')))]", - "[resourceId('Microsoft.Resources/deployments', format('dependencies-{0}-deployment', variables('uniqueSuffix')))]", - "[resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('projectName'), variables('uniqueSuffix')))]", - "[resourceId('Microsoft.Resources/deployments', format('format-project-workspace-id-{0}-deployment', variables('uniqueSuffix')))]" - ] - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[format('cosmos-containers-ra-{0}-deployment', variables('uniqueSuffix'))]", - "subscriptionId": "[variables('cosmosDBSubscriptionId')]", - "resourceGroup": "[variables('cosmosDBResourceGroupName')]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "cosmosAccountName": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('dependencies-{0}-deployment', variables('uniqueSuffix'))), '2025-04-01').outputs.cosmosDBName.value]" - }, - "projectWorkspaceId": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('format-project-workspace-id-{0}-deployment', variables('uniqueSuffix'))), '2025-04-01').outputs.projectWorkspaceIdGuid.value]" - }, - "projectPrincipalId": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('projectName'), variables('uniqueSuffix'))), '2025-04-01').outputs.projectPrincipalId.value]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.39.26.7824", - "templateHash": "16291470712974205281" - } - }, - "parameters": { - "cosmosAccountName": { - "type": "string", - "metadata": { - "description": "Name of the AI Search resource" - } - }, - "projectPrincipalId": { - "type": "string", - "metadata": { - "description": "Project name" - } - }, - "projectWorkspaceId": { - "type": "string" - } - }, - "variables": { - "roleDefinitionId": "[resourceId('Microsoft.DocumentDB/databaseAccounts/sqlRoleDefinitions', parameters('cosmosAccountName'), '00000000-0000-0000-0000-000000000002')]", - "accountScope": "[format('/subscriptions/{0}/resourceGroups/{1}/providers/Microsoft.DocumentDB/databaseAccounts/{2}', subscription().subscriptionId, resourceGroup().name, parameters('cosmosAccountName'))]" - }, - "resources": [ - { - "type": "Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments", - "apiVersion": "2022-05-15", - "name": "[format('{0}/{1}', parameters('cosmosAccountName'), guid(parameters('projectWorkspaceId'), parameters('cosmosAccountName'), variables('roleDefinitionId'), parameters('projectPrincipalId')))]", - "properties": { - "principalId": "[parameters('projectPrincipalId')]", - "roleDefinitionId": "[variables('roleDefinitionId')]", - "scope": "[variables('accountScope')]" - } - } - ] - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', format('capabilityHost-configuration-{0}-deployment', variables('uniqueSuffix')))]", - "[resourceId('Microsoft.Resources/deployments', format('dependencies-{0}-deployment', variables('uniqueSuffix')))]", - "[resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('projectName'), variables('uniqueSuffix')))]", - "[resourceId('Microsoft.Resources/deployments', format('format-project-workspace-id-{0}-deployment', variables('uniqueSuffix')))]", - "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('azureStorageSubscriptionId'), variables('azureStorageResourceGroupName')), 'Microsoft.Resources/deployments', format('storage-containers-ra-{0}-deployment', variables('uniqueSuffix')))]" - ] - } - ] -} \ No newline at end of file diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/modules-network-secured/validate-existing-resources.bicep b/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/modules-network-secured/validate-existing-resources.bicep deleted file mode 100644 index f798e5e27..000000000 --- a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/modules-network-secured/validate-existing-resources.bicep +++ /dev/null @@ -1,91 +0,0 @@ -// @description('Resource ID of the AI Service Account. ') -// param aiServiceAccountResourceId string - -@description('Resource ID of the AI Search Service.') -param aiSearchResourceId string - -@description('Resource ID of the Azure Storage Account.') -param azureStorageAccountResourceId string - -@description('ResourceId of Cosmos DB Account') -param azureCosmosDBAccountResourceId string - -// Check if existing resources have been passed in -var storagePassedIn = azureStorageAccountResourceId != '' -var searchPassedIn = aiSearchResourceId != '' -var cosmosPassedIn = azureCosmosDBAccountResourceId != '' - -var storageParts = split(azureStorageAccountResourceId, '/') -var azureStorageSubscriptionId = storagePassedIn && length(storageParts) > 2 ? storageParts[2] : subscription().subscriptionId -var azureStorageResourceGroupName = storagePassedIn && length(storageParts) > 4 ? storageParts[4] : resourceGroup().name - -var acsParts = split(aiSearchResourceId, '/') -var aiSearchServiceSubscriptionId = searchPassedIn && length(acsParts) > 2 ? acsParts[2] : subscription().subscriptionId -var aiSearchServiceResourceGroupName = searchPassedIn && length(acsParts) > 4 ? acsParts[4] : resourceGroup().name - -var cosmosParts = split(azureCosmosDBAccountResourceId, '/') -var cosmosDBSubscriptionId = cosmosPassedIn && length(cosmosParts) > 2 ? cosmosParts[2] : subscription().subscriptionId -var cosmosDBResourceGroupName = cosmosPassedIn && length(cosmosParts) > 4 ? cosmosParts[4] : resourceGroup().name - -// Validate AI Search -resource aiSearch 'Microsoft.Search/searchServices@2024-06-01-preview' existing = if (searchPassedIn) { - name: last(split(aiSearchResourceId, '/')) - scope: resourceGroup(aiSearchServiceSubscriptionId, aiSearchServiceResourceGroupName) -} - -// Validate Cosmos DB Account -resource cosmosDBAccount 'Microsoft.DocumentDB/databaseAccounts@2024-12-01-preview' existing = if (cosmosPassedIn) { - name: last(split(azureCosmosDBAccountResourceId, '/')) - scope: resourceGroup(cosmosDBSubscriptionId,cosmosDBResourceGroupName) -} - -// Validate Storage Account -resource azureStorageAccount 'Microsoft.Storage/storageAccounts@2024-01-01' existing = if (storagePassedIn) { - name: last(split(azureStorageAccountResourceId, '/')) - scope: resourceGroup(azureStorageSubscriptionId,azureStorageResourceGroupName) -} - -// output aiServiceExists bool = aiServicesPassedIn && (aiServiceAccount.name == aiServiceParts[8]) -output aiSearchExists bool = searchPassedIn && (aiSearch.name == acsParts[8]) -output cosmosDBExists bool = cosmosPassedIn && (cosmosDBAccount.name == cosmosParts[8]) -output azureStorageExists bool = storagePassedIn && (azureStorageAccount.name == storageParts[8]) - -output aiSearchServiceSubscriptionId string = aiSearchServiceSubscriptionId -output aiSearchServiceResourceGroupName string = aiSearchServiceResourceGroupName - -output cosmosDBSubscriptionId string = cosmosDBSubscriptionId -output cosmosDBResourceGroupName string = cosmosDBResourceGroupName - -output azureStorageSubscriptionId string = azureStorageSubscriptionId -output azureStorageResourceGroupName string = azureStorageResourceGroupName - -// Adding DNS Zone Check - -@description('Object mapping DNS zone names to their resource group, or empty string to indicate creation') -param existingDnsZones object - -@description('List of private DNS zone names to validate') -param dnsZoneNames array - -var dnsZoneTypes = [ - 'Microsoft.Network/privateDnsZones' -] - -// Output whether each DNS zone exists -output dnsZoneExists array = [ - for zoneName in dnsZoneNames: { - name: zoneName - exists: !empty(existingDnsZones[zoneName]) - } -] - -/* -// Helper function to check existence -function resourceExists(resourceType: string, name: string, rg: string): bool { - // Use the existing resource reference to check - var res = existing resource dnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' = { - name: name - scope: resourceGroup(rg) - } - return !empty(res.id) -}*/ diff --git a/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/README.md b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/README.md new file mode 100644 index 000000000..d8ea34be4 --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/README.md @@ -0,0 +1,675 @@ +--- +description: This set of templates demonstrates how to set up Foundry Agent Service with virtual network isolation, private network links, and tools behind VNet. +page_type: sample +products: +- azure +- azure-resource-manager +urlFragment: network-secured-agent-tools +languages: +- bicep +- json +--- + +# Microsoft Foundry: Standard Agent Setup with E2E Network Isolation with Tools behind VNET + +> **NEW** +> For support on deploying the right network isolation template, check out the [GitHub Copilot for Azure skill for private networking](https://github.com/microsoft/GitHub-Copilot-for-Azure/blob/main/plugin/skills/microsoft-foundry/resource/private-network/private-network.md) set-up! + +> **IMPORTANT** +> When testing all the Agent tools behind a VNET, please use the [TESTING-GUIDE.md](tests/TESTING-GUIDE.md) file in this repository to ensure your tools are set-up correctly. Currently supported Agent tools behind a VNET include: private MCP, OpenAPI, A2A, Azure Functions, AI Search, Fabric Data Agent. More tools behind a VNET support is coming soon! + +--- +## Overview +This infrastructure-as-code (IaC) solution deploys a network-secured agent environment with private networking, role-based access control (RBAC), and support for tools behind the VNet (MCP servers, OpenAPI tools, Azure Functions, A2A). + +Standard setup supports private network isolation through utilizing **Bring Your Own Virtual Network (BYO VNet)** approach, also known as **custom VNet support with subnet delegation.** + +This implementation gives you full control over the inbound and outbound communication paths for your agent. You can restrict access to only the resources explicitly required by your agent, such as storage accounts, databases, or APIs, while blocking all other traffic by default. This approach ensures that your agent operates within a tightly scoped network boundary, reducing the risk of data leakage or unauthorized access. By default, this setup simplifies security configuration while enforcing strong isolation guarantees, ensuring that each agent deployment remains secure, compliant, and aligned with enterprise networking policies. + +By default, the Foundry resource itself also has **public network access disabled**, but this can be switched to public access if needed (see [Switching Between Private and Public Access](#switching-between-private-and-public-access)). + +--- + +## When to Use This Template + +Use this template when you need: +- **Full end-to-end network isolation** β€” All resources behind private endpoints with no public internet access +- **BYO VNet control** β€” You manage your own virtual network, subnets, and network security groups +- **Standard agent setup with BYO resources** β€” Customer-managed Storage, Cosmos DB, and AI Search for data residency and compliance +- **Tools behind VNet** β€” MCP servers, OpenAPI tools, Azure Functions, or A2A agents deployed on the private VNet +- **System Assigned Managed Identity** β€” Simplified identity management with platform-managed credentials + +### Template Decision Guide + +Use the table below to choose the right infrastructure template for your scenario: + +| Template | Agent Type | Networking | Identity | Key Use Case | +|----------|-----------|------------|----------|-------------| +| [**15**](../15-private-network-standard-agent-setup/) | Standard (BYO resources) | BYO VNet + Private Endpoints | System Assigned MI | E2E network isolation with full agent capabilities | +| [**19** (this template)](../19-private-network-agent-tools/) | Standard (BYO resources) | BYO VNet + Private Endpoints | System Assigned MI | Same as 15 **plus** tools behind VNet (MCP, OpenAPI, Functions, A2A) | +| [**17**](../17-private-network-standard-user-assigned-identity-agent-setup/) | Standard (BYO resources) | BYO VNet + Private Endpoints | **User Assigned MI** | Same as 15 but with user-managed identity | +| [**16**](../16-private-network-standard-agent-apim-setup-preview/) | Standard (BYO resources) | BYO VNet + Private Endpoints | System Assigned MI | Same as 15 **plus** private APIM integration (preview) | +| [**18**](../18-managed-virtual-network-preview/) | Standard (BYO resources) | **Managed VNet** (Microsoft-managed) | System Assigned MI | Network isolation without managing your own VNet (preview) | +| [**15a**](../15a-private-network-evaluation-only-setup/) | Evaluation only | BYO VNet + Private Endpoints | System Assigned MI | Minimal setup for evaluation β€” no Cosmos DB, AI Search, or capability host | +| [**11**](../11-private-network-basic-vnet/) | **Basic** (platform-managed) | BYO VNet injection | System Assigned MI | Basic agents with VNet isolation β€” no BYO resources needed | +| [**41**](../41-standard-agent-setup/) | Standard (BYO resources) | **Public** (no VNet) | System Assigned MI | Standard agents without network isolation | +| [**40**](../40-basic-agent-setup/) | **Basic** (platform-managed) | **Public** (no VNet) | System Assigned MI | Simplest setup β€” no BYO resources, no private networking | + +### Key Features + +| Feature | This Template (19) β€” Private (default) | This Template (19) β€” Public | Fully Private (15) | +|---------|----------------------------------------|-----------------------------|-----------------------| +| AI Services public access | ❌ Disabled | βœ… Enabled | ❌ Disabled | +| Portal access | Via VPN/ExpressRoute/Bastion | βœ… Works directly | Via VPN/ExpressRoute/Bastion | +| Backend resources | πŸ”’ Private | πŸ”’ Private | πŸ”’ Private | +| Data Proxy | βœ… Configured | βœ… Configured | βœ… Configured | +| Tools behind VNet (MCP, OpenAPI, Functions, A2A) | βœ… Supported | βœ… Supported | ❌ Not supported | +| Secure connection required | βœ… Yes | ❌ No | βœ… Yes | + +--- + +## Deploy to Azure + +[![Deploy To Azure](https://raw.githubusercontent.com/Azure/azure-quickstart-templates/master/1-CONTRIBUTION-GUIDE/images/deploytoazure.svg?sanitize=true)](https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fraw.githubusercontent.com%2Fazure-ai-foundry%2Ffoundry-samples%2Frefs%2Fheads%2Fmain%2Finfrastructure%2Finfrastructure-setup-bicep%2F19-private-network-agent-tools%2Fazuredeploy.json/createUIDefinitionUri/https%3A%2F%2Fraw.githubusercontent.com%2Fazure-ai-foundry%2Ffoundry-samples%2Frefs%2Fheads%2Fmain%2Finfrastructure%2Finfrastructure-setup-bicep%2F19-private-network-agent-tools%2FcreateUiDefinition.json) + +> The "Deploy to Azure" button uses [`createUiDefinition.json`](./createUiDefinition.json) to render a guided wizard in the Azure Portal: real VNet/subnet pickers, resource pickers for AI Search / Cosmos / Storage, and per-field validation \u2014 instead of the default flat list of 27 text boxes. + + +--- + +## Prerequisites + +1. **Active Azure subscription with appropriate permissions** + - **Foundry Account Owner**: Needed to create the Microsoft Foundry account and project. + - **Owner or Role Based Access Administrator**: Needed to assign RBAC on the Azure resources used by this template. + - **Foundry User**: Needed to create and use agents, projects, or evaluation workloads after deployment. + +1. **Register Resource Providers** + + Make sure you have an active Azure subscription that allows registering resource providers. For example, subnet delegation requires the Microsoft.App provider to be registered in your subscription. If it's not already registered, run the commands below: + + ```bash + az provider register --namespace 'Microsoft.KeyVault' + az provider register --namespace 'Microsoft.CognitiveServices' + az provider register --namespace 'Microsoft.Storage' + az provider register --namespace 'Microsoft.Search' + az provider register --namespace 'Microsoft.Network' + az provider register --namespace 'Microsoft.App' + az provider register --namespace 'Microsoft.ContainerService' + ``` + +1. Network administrator permissions (if operating in a restricted or enterprise environment) + +1. Sufficient quota for all resources required by this template in the target Azure region, including model deployment quota. + * If no parameters are passed in, this template creates an Microsoft Foundry resource, Foundry project, Azure Cosmos DB for NoSQL, Azure AI Search, and Azure Storage account +1. Azure CLI installed and configured on your local workstation or deployment pipeline server + +--- + +## Pre-Deployment Steps + +### Networking Requirements +1. Review network requirements and plan Virtual Network address space (e.g., 192.168.0.0/16 or an alternative non-overlapping address space) + +2. Three subnets are needed: + - **Agent Subnet** (e.g., 192.168.0.0/24): Hosts Agent client for Agent workloads, delegated to Microsoft.App/environments. The recommended size should be /24 for this delegated subnet. + - **Private endpoint Subnet** (e.g., 192.168.1.0/24): Hosts private endpoints + - **MCP Subnet** (e.g., 192.168.2.0/24): Hosts MCP servers, OpenAPI tools, Azure Functions, and A2A agents on the VNet + - Ensure that the address spaces for the used VNET does not overlap with any existing networks in your Azure environment or reserved IP ranges like the following: 169.254.0.0/16,172.30.0.0/16,172.31.0.0/16,192.0.2.0/24,0.0.0.0/8,127.0.0.0/8,100.100.0.0/17,100.100.192.0/19,100.100.224.0/19,100.64.0.0/11. + This includes all address space(s) you have in your VNET if you have more than one, and peered VNETs. + + > **Notes:** + - If you do not provide an existing virtual network, the template will create a new virtual network with the default address spaces and subnets described above. If you use an existing virtual network, make sure it already contains three subnets (Agent, Private Endpoint, and MCP) before deploying the template. + - You must ensure the Foundry account was successfully created so that underlying caphost has also succeeded. Then proceed to deploying the project caphost bicep. + - You must ensure the agent subnet is exclusively delegated to __Microsoft.App/environments__ and cannot be used by any other Azure resources. + +### Limitations / Known Issues + +1. The delegated agent subnet must be exclusively used by a single Foundry account. It cannot be shared across accounts. +2. The Foundry resource and the virtual network must be in the same Azure region. BYO resources (Storage, Cosmos DB, AI Search) may be in different regions. +3. For the virtual network IP range, you may use any Private Class A, B or C IP range. Private Class A IP address ranges (10.x.x.x) are only supported in the following regions: **Australia East, Brazil South, Canada East, East US, East US 2, France Central, Germany West Central, Italy North, Japan East, South Africa North, South Central US, South India, Spain Central, Sweden Central, UAE North, UK South, West US, West US 3.** Use Class B (172.16.x.x) or C (192.168.x.x) ranges for other regions. You may not use any other IP range that overlaps to the list above or uses public IP ranges. +4. There is no upgrade path from BYO VNet (this template) to Managed Virtual Network (template 18). A Foundry resource redeployment is required. +5. All projects within the same Foundry account share model deployments. Per-project model isolation is not supported. +6. Cosmos DB is deployed as single-region. Multi-region replication must be configured manually post-deployment. + +### Switching Between Private and Public Access + +The Foundry resource has **public network access disabled by default**. You can switch between the two modes by modifying the Bicep template. + +#### To enable public access + +In [modules-network-secured/ai-account-identity.bicep](modules-network-secured/ai-account-identity.bicep), change: + +```bicep +// Change from: +publicNetworkAccess: 'Disabled' +// To: +publicNetworkAccess: 'Enabled' + +// Also change: +defaultAction: 'Deny' +// To: +defaultAction: 'Allow' +``` + +This makes the Foundry resource accessible from the internet (e.g., for portal-based development without VPN). + +#### To disable public access (default) + +Revert the changes above, setting `publicNetworkAccess: 'Disabled'` and `defaultAction: 'Deny'`. + +### Account Deletion Prerequisites and Cleanup Guidance + +Before deleting an **Account** resource, it is essential to first delete the associated **Account Capability Host**. Failure to do so may result in residual dependenciesβ€”such as subnets and other provisioned resources (e.g., ACA applications)β€”remaining linked to the capability host. This can lead to errors such as **"Subnet already in use"** when attempting to reuse the same subnet in a different account deployment. + +**Cleanup Options** + +**1. Full Account Removal**: To completely remove an account, you must delete and purge the account. Simply deleting the account is not sufficient, you must purge so that deletion of the associated capability host is triggered. The service will automatically handle the removal of the capability host and any linked resources in the background. To purge the account, use the following [link](https://learn.microsoft.com/en-us/azure/ai-services/recover-purge-resources?tabs=azure-portal#purge-a-deleted-resource). Please allow approximately max of 20 minutes for all resources to be fully unlinked from the account. + +**2. Retain Account, Remove Capability Host**: If you intend to retain the account but remove the capability host, execute the script `deleteCapHost.sh` located in this folder. After deletion, allow approximately max of 20 minutes for all resources to be fully unlinked from the account. To recreate the capability host for the account, use the script `createCapHost.sh` located in the same folder. + +> **Important**: Before deleting the account capability host, ensure that the **project capability host** is deleted. + +### Template Customization + +Note: If not provided, the following resources will be created automatically for you: +- VNet and three subnets (Agent, PE, MCP) +- Azure Cosmos DB for NoSQL +- Azure AI Search +- Azure Storage + +#### Parameters + +> **⚠️ Important: Cosmos DB Connection Requirements** +> +> If you are creating the Cosmos DB connection manually (e.g., via REST API or ARM), ensure the following: +> - The `authType` **must** be set to `AAD`. This is the only supported authentication type for the Cosmos DB connection used by the Agent Service. +> - The `metadata` section **must** include the `ResourceId` property, set to the full Azure Resource ID of your Cosmos DB account. The Agent Service relies on this property to correctly identify and connect to your Cosmos DB resource. Omitting `ResourceId` from the metadata will cause the connection to fail. +> +> Example connection properties: +> ```json +> { +> "category": "CosmosDB", +> "authType": "AAD", +> "metadata": { +> "ApiType": "Azure", +> "ResourceId": "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.DocumentDB/databaseAccounts/{cosmosDbAccountName}", +> "location": "{region}" +> } +> } +> ``` + +| Parameter | Description | Default | Required | +|-----------|-------------|---------|----------| +| `location` | Azure region for deployment | `eastus2` | Yes | +| `aiServices` | Base name for the AI Services resource (2-40 lowercase chars) | `contoso` | No | +| `firstProjectName` | Name for the Foundry project | `project` | No | +| `modelName` | Model to deploy | `gpt-4o-mini` | No | +| `modelFormat` | Model provider | `OpenAI` | No | +| `modelVersion` | Model version | `2024-07-18` | No | +| `modelSkuName` | Model deployment SKU | `GlobalStandard` | No | +| `modelCapacity` | Tokens per minute (TPM) capacity | `30` | No | +| `vnetName` | Virtual Network name (ignored when `existingVnetResourceId` is set) | `agent-vnet` | No | +| `agentSubnetName` | Agent subnet name (ignored when `existingAgentSubnetResourceId` is set) | `agent-subnet` | No | +| `agentSubnetPrefix` | Address prefix for agent subnet | `''` (auto) | No | +| `peSubnetName` | Private endpoint subnet name (ignored when `existingPeSubnetResourceId` is set) | `pe-subnet` | No | +| `peSubnetPrefix` | Address prefix for PE subnet | `''` (auto) | No | +| `mcpSubnetName` | MCP subnet name (ignored when `existingMcpSubnetResourceId` is set) | `mcp-subnet` | No | +| `mcpSubnetPrefix` | Address prefix for MCP subnet | `''` (auto) | No | +| `existingVnetResourceId` | Full ARM Resource ID of an existing VNet | `''` (creates new) | No | +| `existingAgentSubnetResourceId` | Full ARM ID of an existing agent subnet to reuse as-is | `''` (creates) | No | +| `existingPeSubnetResourceId` | Full ARM ID of an existing PE subnet to reuse as-is | `''` (creates) | No | +| `existingMcpSubnetResourceId` | Full ARM ID of an existing MCP subnet to reuse as-is | `''` (creates) | No | +| `vnetAddressPrefix` | Address space for new VNet | `''` | No | +| `existingAiSearchResourceId` | ARM Resource ID of existing AI Search | `''` (creates new) | No | +| `existingAzureStorageAccountResourceId` | ARM Resource ID of existing Storage account | `''` (creates new) | No | +| `existingAzureCosmosDBAccountResourceId` | ARM Resource ID of existing Cosmos DB | `''` (creates new) | No | +| `existingFabricWorkspaceResourceId` | ARM Resource ID of existing Fabric workspace | `''` | No | +| `existingDnsZones` | Map of `'': { subscriptionId, resourceGroup }` β€” see [Use existing Private DNS zones](#5-use-existing-private-dns-zones-cross-rg--cross-subscription) | All `{ subscriptionId: '', resourceGroup: '' }` (creates new) | No | + +> **Naming change (May 2026):** `aiSearchResourceId`, `azureStorageAccountResourceId`, `azureCosmosDBAccountResourceId`, and `fabricWorkspaceResourceId` were renamed to `existingAiSearchResourceId`, `existingAzureStorageAccountResourceId`, `existingAzureCosmosDBAccountResourceId`, and `existingFabricWorkspaceResourceId` for consistency with the `existing*ResourceId` pattern used by VNet and subnet params. Update existing parameter files accordingly. + +#### BYO Resource Details + +1. **Use Existing Virtual Network and Subnets** + +To use an existing VNet and subnets, set the `existingVnetResourceId` parameter to the full ARM ID of the target VNet. Provide the names (or, recommended, the full ARM IDs) of the three subnets you want to use. + +There are two levels of "existing" support: + +* **Existing VNet, let the template manage subnets** β€” set only `existingVnetResourceId` and the per-subnet `*Name` / `*Prefix` params. The template will look up the VNet and create/update the three subnets inside it. +* **Existing VNet AND existing subnets (recommended for shared / production VNets)** β€” also set `existingAgentSubnetResourceId`, `existingPeSubnetResourceId`, and/or `existingMcpSubnetResourceId` to the full ARM IDs of subnets you already have. When set, those subnets are referenced as-is and **not created or modified** β€” preserving their existing NSGs, route tables, and delegations. + +Example: + +```bicep +param existingVnetResourceId = '/subscriptions//resourceGroups//providers/Microsoft.Network/virtualNetworks/' +param existingAgentSubnetResourceId = '/subscriptions//resourceGroups//providers/Microsoft.Network/virtualNetworks//subnets/agent-subnet' +param existingPeSubnetResourceId = '/subscriptions//resourceGroups//providers/Microsoft.Network/virtualNetworks//subnets/pe-subnet' +param existingMcpSubnetResourceId = '/subscriptions//resourceGroups//providers/Microsoft.Network/virtualNetworks//subnets/mcp-subnet' +param existingDnsZones = { + 'privatelink.services.ai.azure.com': { subscriptionId: '', resourceGroup: 'shared-dns-rg' } // existing zone, same sub, different RG + 'privatelink.openai.azure.com': { subscriptionId: '', resourceGroup: '' } // create a new zone in this deployment’s RG + // ... etc +} +``` + +πŸ’‘ **When to use which**: +* If the VNet is yours and the subnets are empty/unused β†’ just set `existingVnetResourceId`. +* If the VNet is shared with other workloads β†’ always set the `existing*SubnetResourceId` params too. Otherwise, the template will issue subnet PUTs that can fail with `AnotherOperationInProgress` or, worse, succeed and overwrite settings managed by another team. + +πŸ’‘ If subnets information is provided then make sure it exist within the specified VNet to avoid deployment errors. If subnet information is not provided, the template will create subnets with the default address space. + + +2. **Use an existing Azure Cosmos DB for NoSQL** + +To use an existing Cosmos DB for NoSQL resource, set `existingAzureCosmosDBAccountResourceId` to the full Azure Resource ID of the target Cosmos DB. +- `param existingAzureCosmosDBAccountResourceId = '/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.DocumentDB/databaseAccounts/{cosmosDbAccountName}'` + + +3. **Use an existing Azure AI Search resource** + +To use an existing Azure AI Search resource, set `existingAiSearchResourceId` to the full ARM ID of the target search service. + - `param existingAiSearchResourceId = '/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Search/searchServices/{searchServiceName}'` + + +4. **Use an existing Azure Storage account** + +To use an existing Azure Storage account, set `existingAzureStorageAccountResourceId` to the full ARM ID of the target storage account. +- `param existingAzureStorageAccountResourceId = '/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Storage/storageAccounts/{storageAccountName}'` + +5. **Use existing Private DNS zones (cross-RG / cross-subscription)** + +The `existingDnsZones` parameter controls, **per zone**, whether the template creates a new private DNS zone in this deployment’s resource group or references an existing one (optionally in another resource group and/or subscription). + +Each map value is an object with two optional properties: + +| Property | Meaning | +|---|---| +| `resourceGroup` | RG holding the existing zone. **Empty `''` β†’ create a new zone in this deployment’s RG.** Non-empty β†’ reference the existing zone in that RG. | +| `subscriptionId` | Subscription holding the existing zone. Empty `''` defaults to the current subscription. Only used when `resourceGroup` is non-empty. | + +Three usage modes: + +```bicep +param existingDnsZones = { + // (a) Create a new zone in this deployment's RG (default behavior) + 'privatelink.blob.core.windows.net': { subscriptionId: '', resourceGroup: '' } + + // (b) Reference an existing zone in another RG, SAME subscription + 'privatelink.openai.azure.com': { subscriptionId: '', resourceGroup: 'shared-dns-rg' } + + // (c) Reference an existing zone in another RG and ANOTHER subscription + 'privatelink.search.windows.net': { subscriptionId: '11111111-2222-3333-4444-555555555555', resourceGroup: 'hub-dns-rg' } +} +``` + +> ⚠️ **You must pre-link the VNet to any referenced zone.** When the template references an existing zone (modes b and c), it intentionally **does not create the `virtualNetworkLinks` resource** β€” the deployment identity may not have write rights in the zone’s RG/subscription. Ensure the zone is already linked to the VNet hosting the private endpoints, otherwise name resolution will fail even though the deployment succeeds. + +> ⚠️ **Cross-subscription RBAC.** The deployment principal needs `Private DNS Zone Contributor` (or at least `reader` + permission to write zone groups) on each referenced zone’s scope. For mode (c), grant this in the target subscription. + +> πŸ’‘ **Migrating from the old string format.** Earlier versions accepted `'': ''`. Replace each value with `{ subscriptionId: '', resourceGroup: '' }` (or `{ subscriptionId: '', resourceGroup: '' }` to create new). + +--- + +## Deploy the bicep template + +Choose your deployment method: Use the "Deploy to Azure" button from the provided README for a guided experience in Azure Portal + +**Option 1: Automatic deployment** +Click the deploy to Azure button above to open the Azure portal and deploy the template directly. +- Fill in the parameters as needed, including the existing VNet and subnets if applicable. + + +**Option 2: Manually deploy the bicep template** +- **Create a New (or Use Existing) Resource Group** + + ```bash + az group create --name --location + ``` +- Deploy the main.bicep file + - Edit the main.bicepparams file to use an existing Virtual Network & subnets, Azure Cosmos DB, Azure Storage, and Azure AI Search. + + ```bash + az deployment group create --resource-group --template-file main.bicep --parameters main.bicepparam + ``` + +> **Note:** To access a private Foundry resource securely, use one of the following: +> - A VM or jump box on the virtual network, optionally accessed through Azure Bastion +> - Azure VPN Gateway +> - Azure ExpressRoute + +If public network access is enabled, you can also access the Foundry resource directly from the internet for portal-based development. + +### Verify Deployment + +```bash +# Check deployment status +az deployment group show \ + --resource-group "rg-hybrid-agent-test" \ + --name "main" \ + --query "properties.provisioningState" + +# List private endpoints (should see AI Search, Storage, Cosmos DB) +az network private-endpoint list \ + --resource-group "rg-hybrid-agent-test" \ + --output table +``` + +### Cleanup + +To delete all resources created by this template: + +```bash +az group delete --name --yes --no-wait +``` + +> **Important**: If you need to reuse the same subnet, follow the [Account Deletion Prerequisites and Cleanup Guidance](#account-deletion-prerequisites-and-cleanup-guidance) to properly purge the account and wait for the capability host to fully unlink (~20 minutes). + +--- + +## Network Secured Agent Project Architecture Deep Dive + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Secure Access (VPN Gateway / ExpressRoute / Azure Bastion) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Microsoft Foundry β”‚ + β”‚ (publicNetworkAccess: β”‚ + β”‚ DISABLED) β”‚ + β”‚ β”‚ + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ + β”‚ β”‚ Foundry Project β”‚ β”‚ + β”‚ β”‚ (Agent Workspace) β”‚ β”‚ + β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ Subnet Delegation + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ BYO Virtual Network β”‚ + β”‚ (192.168.0.0/16) β”‚ + β”‚ β”‚ + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ + β”‚ β”‚ Agent Subnet β”‚ β”‚ + β”‚ β”‚ (192.168.0.0/24) β”‚ β”‚ ◄── Delegated to + β”‚ β”‚ Microsoft.App/envs β”‚ β”‚ Microsoft.App/environments + β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β”‚ β”‚ + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ + β”‚ β”‚ PE Subnet β”‚ β”‚ + β”‚ β”‚ (192.168.1.0/24) β”‚ β”‚ + β”‚ β”‚ β”‚ β”‚ + β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ + β”‚ β”‚ β”‚Storage β”‚ β”‚Cosmos β”‚ β”‚ β”‚ ◄── Private endpoints + β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ (no public access) + β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ + β”‚ β”‚ β”‚Search β”‚ β”‚Foundry β”‚ β”‚ β”‚ + β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ + β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β”‚ β”‚ + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ + β”‚ β”‚ MCP Subnet β”‚ β”‚ + β”‚ β”‚ (192.168.2.0/24) β”‚ β”‚ + β”‚ β”‚ β”‚ β”‚ + β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ + β”‚ β”‚ β”‚ MCP β”‚ β”‚OpenAPI β”‚ β”‚ β”‚ ◄── Tools behind VNet + β”‚ β”‚ β”‚Servers β”‚ β”‚ Tools β”‚ β”‚ β”‚ + β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ + β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ + β”‚ β”‚ β”‚Azure β”‚ β”‚ A2A β”‚ β”‚ β”‚ + β”‚ β”‚ β”‚Funcs β”‚ β”‚Agents β”‚ β”‚ β”‚ + β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ + β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +> **Tip:** For detailed layer-by-layer deployment diagrams, see the `diagrams/` folder. + +### Core Components + +**Microsoft Foundry** resource +- Central orchestration point +- Manages service connections +- Set networking and policy configurations + +**Foundry** project +- Defines the workspace configuration +- Service integration +- Agents are created within a specific project, and each project acts as an isolated workspace. This means: + - All agents in the same project share access to the same file storage, thread storage (conversation history), and search indexes. + - Data is isolated between projects. Agents in one project cannot access resources from another. Projects are currently the unit of sharing and isolation in Foundry. See the what is AI foundry article for more information on Foundry projects. + +**Bring Your Own (BYO) Azure Resources**: ensures all sensitive data remains under customer control. All agents created using our service are stateful, meaning they retain information across interactions. With this setup, agent states are automatically stored in customer-managed, single-tenant resources. The required Bring Your Own Resources include: +- BYO File Storage: All files uploaded by developers (during agent configuration) or end-users (during interactions) are stored directly in the customer's Azure Storage account. +- BYO Search: All vector stores created by the agent leverage the customer's Azure AI Search resource. +- BYO Thread Storage: All customer messages and conversation history will be stored in the customer's own Azure Cosmos DB account. + +By bundling these BYO features (file storage, search, and thread storage), the standard setup guarantees that your deployment is secure by default. All data processed by Microsoft Foundry Agent Service is automatically stored at rest in your own Azure resources, helping you meet internal policies, compliance requirements, and enterprise security standards. + +### Azure Resources Created + +Microsoft Foundry (Cognitive Services) +- Type: Microsoft.CognitiveServices/accounts +- API version: 2025-04-01-preview +- Kind: AIServices +- SKU: S0 +- Identity: System-assigned +- Features: + - Custom subdomain name + - Disabled public network access + - Network ACLs with Azure Services bypass + +AI Model Deployment +- Type: Microsoft.CognitiveServices/accounts/deployments +- API version: 2025-04-01-preview +- SKU: Based on modelSkuName parameter, capacity set by modelCapacity +- Model properties: + - Name: From modelName parameter + - Format: From modelFormat parameter + - Version: From modelVersion parameter + +Azure AI Search +- Type: Microsoft.Search/searchServices +- API version: 2024-06-01-preview +- SKU: standard +- Partition Count: 1 +- Replica Count: 1 +- Hosting Mode: default +- Semantic Search: disabled +- Features: + - Disabled public network access + - AAD auth with HTTP 401 challenge + - System-assigned managed identity + +Storage Account +- Type: Microsoft.Storage/storageAccounts +- API version: 2023-05-0 +- Kind: StorageV2 +- SKU: ZRS or GRS (region dependent; use Standard_GRS if ZRS not available) +- Features: + - Blob service, Queue service (if Azure Function Tool supported) + - Minimum TLS Version: 1.2 + - Block public blob access + - Disabled public network access + - Force Azure AD authentication (SharedKey access disabled) + +Cosmos DB Account +- Type: Microsoft.DocumentDB/databaseAccounts +- API version: 2024-11-15 +- Kind: GlobalDocumentDB (SQL API) +- Consistency Level: Session +- Database Account Offer Type: Standard +- Features: + - Disabled public network access + - Disabled local auth + - Single region deployment + +### Network Security Design +This implementation utilizes a BYO VNet (Bring Your Own Virtual Network) approach, also known as custom VNet support with subnet delegation. Within your existing virtual network, delegated subnets will be created. + +Network Security +- Public network access disabled +- Private endpoints for all services +- Network ACLs with deny by default + +**Network Infrastructure** +- A Virtual Network (192.168.0.0/16) is created (if existing isn't passed in) +- Agent Subnet (192.168.0.0/24): Hosts Agent client +- Private endpoint Subnet (192.168.1.0/24): Hosts private endpoints +- MCP Subnet (192.168.2.0/24): Hosts MCP servers, OpenAPI tools, Azure Functions, and A2A agents + +**Private Endpoints** +Private endpoints ensure secure, internal-only connectivity. Private endpoints are created for the following: +- Microsoft Foundry +- Azure AI Search +- Azure Storage +- Azure Cosmos DB + +**Private DNS Zones** +| Private Link Resource Type | Sub Resource | Private DNS Zone Name | Public DNS Zone Forwarders | +|----------------------------|--------------|------------------------|-----------------------------| +| **Microsoft Foundry** | account | `privatelink.cognitiveservices.azure.com`
`privatelink.openai.azure.com`
`privatelink.services.ai.azure.com` | `cognitiveservices.azure.com`
`openai.azure.com`
`services.ai.azure.com` | +| **Azure AI Search** | searchService| `privatelink.search.windows.net` | `search.windows.net` | +| **Azure Cosmos DB** | Sql | `privatelink.documents.azure.com` | `documents.azure.com` | +| **Azure Storage** | blob | `privatelink.blob.core.windows.net` | `blob.core.windows.net` | + +### Authentication & Authorization + +- **Managed Identity** + - Zero-trust security model + - No credential storage + - Platform-managed rotation + + This template uses System Managed Identity, but User Assigned Managed Identity is also supported. + +- **Role Assignments** + - **Azure AI Search** + - Search Index Data Contributor (`8ebe5a00-799e-43f5-93ac-243d3dce84a7`) + - Search Service Contributor (`7ca78c08-252a-4471-8644-bb5ff32d4ba0`) + - **Azure Storage Account** + - Storage Blob Data Owner (`b7e6dc6d-f1e8-4753-8033-0f276bb0955b`) + - Storage Queue Data Contributor (`974c5e8b-45b9-4653-ba55-5f855dd0fb88`) (if Azure Function tool enabled) + - Two containers will automatically be provisioned during the project create capability host process: + - Azure Blob Storage Container: `-azureml-blobstore` + - Storage Blob Data Contributor + - Azure Blob Storage Container: `-agents-blobstore` + - Storage Blob Data Owner + - **Cosmos DB for NoSQL** + - Cosmos DB Operator (`230815da-be43-4aae-9cb4-875f7bd000aa`) + - Cosmos DB Built-in Data Contributor + - Three containers will automatically be provisioned during the create capability host process: + - Cosmos DB for NoSQL container: `<${projectWorkspaceId}>-thread-message-store` + - Cosmos DB for NoSQL container: `<${projectWorkspaceId}>-system-thread-message-store` + - Cosmos DB for NoSQL container: `<${projectWorkspaceId}>-agent-entity-store` + +--- + +## Connecting to a Private Foundry Resource + +When public network access is disabled (the default), you need a secure connection to reach the Foundry resource. Azure provides three methods: + +1. **Azure VPN Gateway** β€” Connect from your local network to the Azure VNet over an encrypted tunnel. +2. **Azure ExpressRoute** β€” Use a private, dedicated connection from your on-premises infrastructure to Azure. +3. **Azure Bastion** β€” Use a jump box VM on the VNet, accessed securely through the Azure portal. + +For detailed setup instructions, see: [Securely connect to Azure AI Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/configure-private-link?view=foundry#securely-connect-to-foundry). + +--- + +## Testing Agents with Private Resources + +### Option 1: Portal Testing + +If the Foundry resource has **public network access enabled**, you can test directly in the portal: + +1. Navigate to [Azure AI Foundry portal](https://ai.azure.com) +2. Select your project +3. Create an agent with AI Search tool +4. Test that the agent can query the private AI Search index + +If the Foundry resource has **public network access disabled** (default), you need to connect via VPN Gateway, ExpressRoute, or Azure Bastion before accessing the portal. See [Connecting to a Private Foundry Resource](#connecting-to-a-private-foundry-resource). + +### Option 2: SDK Testing + +See [tests/TESTING-GUIDE.md](tests/TESTING-GUIDE.md) for detailed SDK testing instructions. + +--- + +## MCP Server Deployment + +To deploy MCP servers on the private VNet: + +```bash +# Create Container Apps environment on mcp-subnet +az containerapp env create \ + --resource-group "rg-hybrid-agent-test" \ + --name "mcp-env" \ + --location "westus2" \ + --infrastructure-subnet-resource-id "" \ + --internal-only true + +# Deploy MCP server +az containerapp create \ + --resource-group "rg-hybrid-agent-test" \ + --name "my-mcp-server" \ + --environment "mcp-env" \ + --image "" \ + --target-port 8080 \ + --ingress external \ + --min-replicas 1 +``` + +Then configure private DNS zone for Container Apps (see TESTING-GUIDE.md Step 6.3). + +--- + +## Module Structure + +```text +modules-network-secured/ +β”œβ”€β”€ add-project-capability-host.bicep # Configuring the project's capability host +β”œβ”€β”€ ai-account-identity.bicep # Microsoft Foundry deployment and configuration +β”œβ”€β”€ ai-project-identity.bicep # Foundry project deployment and connection configuration +β”œβ”€β”€ ai-project-identity-unique.bicep # Modified project module with unique connection names +β”œβ”€β”€ ai-search-role-assignments.bicep # AI Search RBAC configuration +β”œβ”€β”€ azure-storage-account-role-assignment.bicep # Storage Account RBAC configuration +β”œβ”€β”€ blob-storage-container-role-assignments.bicep # Blob Storage Container RBAC configuration +β”œβ”€β”€ blob-storage-container-role-assignments-unique.bicep # Modified storage role assignment module +β”œβ”€β”€ cosmos-container-role-assignments.bicep # CosmosDB container Account RBAC configuration +β”œβ”€β”€ cosmosdb-account-role-assignment.bicep # CosmosDB Account RBAC configuration +β”œβ”€β”€ existing-vnet.bicep # Bring your existing virtual network to template deployment +β”œβ”€β”€ format-project-workspace-id.bicep # Formatting the project workspace ID +β”œβ”€β”€ network-agent-vnet.bicep # Logic for routing virtual network set-up if existing virtual network is selected +β”œβ”€β”€ private-endpoint-and-dns.bicep # Creating virtual networks and DNS zones. +β”œβ”€β”€ standard-dependent-resources.bicep # Deploying CosmosDB, Storage, and Search +β”œβ”€β”€ subnet.bicep # Setting the subnet for Agent network injection +β”œβ”€β”€ validate-existing-resources.bicep # Validate existing CosmosDB, Storage, and Search to template deployment +└── vnet.bicep # Deploying a new virtual network +``` + +## Maintenance + +### Regular Tasks + +1. Review role assignments +2. Monitor network security +3. Check service health +4. Update configurations as needed + +### Troubleshooting + +1. Verify private endpoint connectivity +2. Check DNS resolution +3. Validate role assignments +4. Review network security groups + +--- + +## References + +- [Microsoft Foundry Networking Documentation](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/configure-private-link?tabs=azure-portal&pivots=fdp-project) +- [Microsoft Foundry RBAC Documentation](https://learn.microsoft.com/en-us/azure/ai-foundry/concepts/rbac-azure-ai-foundry?pivots=fdp-project) +- [Private Endpoint Documentation](https://learn.microsoft.com/en-us/azure/private-link/) +- [RBAC Documentation](https://learn.microsoft.com/en-us/azure/role-based-access-control/) +- [Network Security Best Practices](https://learn.microsoft.com/en-us/azure/security/fundamentals/network-best-practices) diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/a2a-server/Dockerfile b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/a2a-server/Dockerfile similarity index 100% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/a2a-server/Dockerfile rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/a2a-server/Dockerfile diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/a2a-server/main.py b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/a2a-server/main.py similarity index 100% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/a2a-server/main.py rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/a2a-server/main.py diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/a2a-server/requirements.txt b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/a2a-server/requirements.txt similarity index 100% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/a2a-server/requirements.txt rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/a2a-server/requirements.txt diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/add-project.bicep b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/add-project.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/add-project.bicep rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/add-project.bicep diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/add-project.bicepparam b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/add-project.bicepparam similarity index 100% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/add-project.bicepparam rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/add-project.bicepparam diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/azure-function-server/README.md b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/azure-function-server/README.md similarity index 100% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/azure-function-server/README.md rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/azure-function-server/README.md diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/azure-function-server/calculator_openapi.json b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/azure-function-server/calculator_openapi.json similarity index 100% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/azure-function-server/calculator_openapi.json rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/azure-function-server/calculator_openapi.json diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/azure-function-server/deploy-function.bicep b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/azure-function-server/deploy-function.bicep similarity index 96% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/azure-function-server/deploy-function.bicep rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/azure-function-server/deploy-function.bicep index af6a75f1c..82b8052e5 100644 --- a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/azure-function-server/deploy-function.bicep +++ b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/azure-function-server/deploy-function.bicep @@ -165,14 +165,26 @@ resource functionApp 'Microsoft.Web/sites@2023-12-01' = { pythonVersion: '3.11' linuxFxVersion: 'PYTHON|3.11' appSettings: [ - { name: 'FUNCTIONS_WORKER_RUNTIME'; value: 'python' } - { name: 'FUNCTIONS_EXTENSION_VERSION'; value: '~4' } + { + name: 'FUNCTIONS_WORKER_RUNTIME' + value: 'python' + } + { + name: 'FUNCTIONS_EXTENSION_VERSION' + value: '~4' + } { name: 'AzureWebJobsStorage' value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccount.name};AccountKey=${storageAccount.listKeys().keys[0].value}' } - { name: 'WEBSITE_CONTENTOVERVNET'; value: '1' } - { name: 'WEBSITE_VNET_ROUTE_ALL'; value: '1' } + { + name: 'WEBSITE_CONTENTOVERVNET' + value: '1' + } + { + name: 'WEBSITE_VNET_ROUTE_ALL' + value: '1' + } ] } } diff --git a/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/azure-function-server/deploy-function.json b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/azure-function-server/deploy-function.json new file mode 100644 index 000000000..68e59cbb3 --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/azure-function-server/deploy-function.json @@ -0,0 +1,424 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.40.2.10011", + "templateHash": "16381039446523157932" + } + }, + "parameters": { + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]", + "metadata": { + "description": "Azure region for all resources" + } + }, + "vnetName": { + "type": "string", + "metadata": { + "description": "Name of the existing VNet" + } + }, + "integrationSubnetName": { + "type": "string", + "defaultValue": "func-integration-subnet", + "metadata": { + "description": "Name of an existing or new subnet for the Function App VNet Integration" + } + }, + "integrationSubnetPrefix": { + "type": "string", + "defaultValue": "192.168.5.0/24", + "metadata": { + "description": "Address prefix for the integration subnet (if creating new)" + } + }, + "privateEndpointSubnetName": { + "type": "string", + "metadata": { + "description": "Name of the subnet where Private Endpoints will be created" + } + }, + "baseName": { + "type": "string", + "defaultValue": "[format('functest{0}', uniqueString(resourceGroup().id))]", + "metadata": { + "description": "Base name for the Function App resources" + } + } + }, + "resources": [ + { + "type": "Microsoft.Network/virtualNetworks/subnets", + "apiVersion": "2024-01-01", + "name": "[format('{0}/{1}', parameters('vnetName'), parameters('integrationSubnetName'))]", + "properties": { + "addressPrefix": "[parameters('integrationSubnetPrefix')]", + "delegations": [ + { + "name": "Microsoft.Web.serverFarms", + "properties": { + "serviceName": "Microsoft.Web/serverFarms" + } + } + ] + } + }, + { + "type": "Microsoft.Storage/storageAccounts", + "apiVersion": "2023-05-01", + "name": "[format('{0}stor', take(parameters('baseName'), 20))]", + "location": "[parameters('location')]", + "sku": { + "name": "Standard_LRS" + }, + "kind": "StorageV2", + "properties": { + "supportsHttpsTrafficOnly": true, + "allowBlobPublicAccess": false, + "publicNetworkAccess": "Enabled", + "networkAcls": { + "defaultAction": "Allow" + } + } + }, + { + "type": "Microsoft.Network/privateEndpoints", + "apiVersion": "2024-01-01", + "name": "[format('{0}-blob-pe', parameters('baseName'))]", + "location": "[parameters('location')]", + "properties": { + "subnet": { + "id": "[resourceId('Microsoft.Network/virtualNetworks/subnets', parameters('vnetName'), parameters('privateEndpointSubnetName'))]" + }, + "privateLinkServiceConnections": [ + { + "name": "blob", + "properties": { + "privateLinkServiceId": "[resourceId('Microsoft.Storage/storageAccounts', format('{0}stor', take(parameters('baseName'), 20)))]", + "groupIds": [ + "blob" + ] + } + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.Storage/storageAccounts', format('{0}stor', take(parameters('baseName'), 20)))]" + ] + }, + { + "type": "Microsoft.Network/privateEndpoints", + "apiVersion": "2024-01-01", + "name": "[format('{0}-queue-pe', parameters('baseName'))]", + "location": "[parameters('location')]", + "properties": { + "subnet": { + "id": "[resourceId('Microsoft.Network/virtualNetworks/subnets', parameters('vnetName'), parameters('privateEndpointSubnetName'))]" + }, + "privateLinkServiceConnections": [ + { + "name": "queue", + "properties": { + "privateLinkServiceId": "[resourceId('Microsoft.Storage/storageAccounts', format('{0}stor', take(parameters('baseName'), 20)))]", + "groupIds": [ + "queue" + ] + } + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.Storage/storageAccounts', format('{0}stor', take(parameters('baseName'), 20)))]" + ] + }, + { + "type": "Microsoft.Network/privateEndpoints", + "apiVersion": "2024-01-01", + "name": "[format('{0}-file-pe', parameters('baseName'))]", + "location": "[parameters('location')]", + "properties": { + "subnet": { + "id": "[resourceId('Microsoft.Network/virtualNetworks/subnets', parameters('vnetName'), parameters('privateEndpointSubnetName'))]" + }, + "privateLinkServiceConnections": [ + { + "name": "file", + "properties": { + "privateLinkServiceId": "[resourceId('Microsoft.Storage/storageAccounts', format('{0}stor', take(parameters('baseName'), 20)))]", + "groupIds": [ + "file" + ] + } + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.Storage/storageAccounts', format('{0}stor', take(parameters('baseName'), 20)))]" + ] + }, + { + "type": "Microsoft.Web/serverfarms", + "apiVersion": "2023-12-01", + "name": "[format('{0}-plan', parameters('baseName'))]", + "location": "[parameters('location')]", + "sku": { + "name": "EP1", + "tier": "ElasticPremium" + }, + "kind": "elastic", + "properties": { + "reserved": true + } + }, + { + "type": "Microsoft.Web/sites", + "apiVersion": "2023-12-01", + "name": "[format('{0}-func', parameters('baseName'))]", + "location": "[parameters('location')]", + "kind": "functionapp,linux", + "properties": { + "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', format('{0}-plan', parameters('baseName')))]", + "publicNetworkAccess": "Enabled", + "virtualNetworkSubnetId": "[resourceId('Microsoft.Network/virtualNetworks/subnets', parameters('vnetName'), parameters('integrationSubnetName'))]", + "siteConfig": { + "pythonVersion": "3.11", + "linuxFxVersion": "PYTHON|3.11", + "appSettings": [ + { + "name": "FUNCTIONS_WORKER_RUNTIME", + "value": "python" + }, + { + "name": "FUNCTIONS_EXTENSION_VERSION", + "value": "~4" + }, + { + "name": "AzureWebJobsStorage", + "value": "[format('DefaultEndpointsProtocol=https;AccountName={0};AccountKey={1}', format('{0}stor', take(parameters('baseName'), 20)), listKeys(resourceId('Microsoft.Storage/storageAccounts', format('{0}stor', take(parameters('baseName'), 20))), '2023-05-01').keys[0].value)]" + }, + { + "name": "WEBSITE_CONTENTOVERVNET", + "value": "1" + }, + { + "name": "WEBSITE_VNET_ROUTE_ALL", + "value": "1" + } + ] + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/serverfarms', format('{0}-plan', parameters('baseName')))]", + "[resourceId('Microsoft.Network/virtualNetworks/subnets', parameters('vnetName'), parameters('integrationSubnetName'))]", + "[resourceId('Microsoft.Storage/storageAccounts', format('{0}stor', take(parameters('baseName'), 20)))]" + ] + }, + { + "type": "Microsoft.Network/privateEndpoints", + "apiVersion": "2024-01-01", + "name": "[format('{0}-func-pe', parameters('baseName'))]", + "location": "[parameters('location')]", + "properties": { + "subnet": { + "id": "[resourceId('Microsoft.Network/virtualNetworks/subnets', parameters('vnetName'), parameters('privateEndpointSubnetName'))]" + }, + "privateLinkServiceConnections": [ + { + "name": "sites", + "properties": { + "privateLinkServiceId": "[resourceId('Microsoft.Web/sites', format('{0}-func', parameters('baseName')))]", + "groupIds": [ + "sites" + ] + } + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', format('{0}-func', parameters('baseName')))]" + ] + }, + { + "type": "Microsoft.Network/privateDnsZones", + "apiVersion": "2024-06-01", + "name": "privatelink.azurewebsites.net", + "location": "global" + }, + { + "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", + "apiVersion": "2024-06-01", + "name": "[format('{0}/{1}', 'privatelink.azurewebsites.net', format('{0}-link', parameters('vnetName')))]", + "location": "global", + "properties": { + "virtualNetwork": { + "id": "[resourceId('Microsoft.Network/virtualNetworks', parameters('vnetName'))]" + }, + "registrationEnabled": false + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', 'privatelink.azurewebsites.net')]" + ] + }, + { + "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", + "apiVersion": "2024-01-01", + "name": "[format('{0}/{1}', format('{0}-func-pe', parameters('baseName')), 'default')]", + "properties": { + "privateDnsZoneConfigs": [ + { + "name": "privatelink-azurewebsites-net", + "properties": { + "privateDnsZoneId": "[resourceId('Microsoft.Network/privateDnsZones', 'privatelink.azurewebsites.net')]" + } + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateEndpoints', format('{0}-func-pe', parameters('baseName')))]", + "[resourceId('Microsoft.Network/privateDnsZones', 'privatelink.azurewebsites.net')]" + ] + }, + { + "type": "Microsoft.Network/privateDnsZones", + "apiVersion": "2024-06-01", + "name": "[format('privatelink.blob.{0}', environment().suffixes.storage)]", + "location": "global" + }, + { + "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", + "apiVersion": "2024-06-01", + "name": "[format('{0}/{1}', format('privatelink.blob.{0}', environment().suffixes.storage), format('{0}-blob-link', parameters('vnetName')))]", + "location": "global", + "properties": { + "virtualNetwork": { + "id": "[resourceId('Microsoft.Network/virtualNetworks', parameters('vnetName'))]" + }, + "registrationEnabled": false + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', format('privatelink.blob.{0}', environment().suffixes.storage))]" + ] + }, + { + "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", + "apiVersion": "2024-01-01", + "name": "[format('{0}/{1}', format('{0}-blob-pe', parameters('baseName')), 'default')]", + "properties": { + "privateDnsZoneConfigs": [ + { + "name": "privatelink-blob", + "properties": { + "privateDnsZoneId": "[resourceId('Microsoft.Network/privateDnsZones', format('privatelink.blob.{0}', environment().suffixes.storage))]" + } + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', format('privatelink.blob.{0}', environment().suffixes.storage))]", + "[resourceId('Microsoft.Network/privateEndpoints', format('{0}-blob-pe', parameters('baseName')))]" + ] + }, + { + "type": "Microsoft.Network/privateDnsZones", + "apiVersion": "2024-06-01", + "name": "[format('privatelink.queue.{0}', environment().suffixes.storage)]", + "location": "global" + }, + { + "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", + "apiVersion": "2024-06-01", + "name": "[format('{0}/{1}', format('privatelink.queue.{0}', environment().suffixes.storage), format('{0}-queue-link', parameters('vnetName')))]", + "location": "global", + "properties": { + "virtualNetwork": { + "id": "[resourceId('Microsoft.Network/virtualNetworks', parameters('vnetName'))]" + }, + "registrationEnabled": false + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', format('privatelink.queue.{0}', environment().suffixes.storage))]" + ] + }, + { + "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", + "apiVersion": "2024-01-01", + "name": "[format('{0}/{1}', format('{0}-queue-pe', parameters('baseName')), 'default')]", + "properties": { + "privateDnsZoneConfigs": [ + { + "name": "privatelink-queue", + "properties": { + "privateDnsZoneId": "[resourceId('Microsoft.Network/privateDnsZones', format('privatelink.queue.{0}', environment().suffixes.storage))]" + } + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', format('privatelink.queue.{0}', environment().suffixes.storage))]", + "[resourceId('Microsoft.Network/privateEndpoints', format('{0}-queue-pe', parameters('baseName')))]" + ] + }, + { + "type": "Microsoft.Network/privateDnsZones", + "apiVersion": "2024-06-01", + "name": "[format('privatelink.file.{0}', environment().suffixes.storage)]", + "location": "global" + }, + { + "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", + "apiVersion": "2024-06-01", + "name": "[format('{0}/{1}', format('privatelink.file.{0}', environment().suffixes.storage), format('{0}-file-link', parameters('vnetName')))]", + "location": "global", + "properties": { + "virtualNetwork": { + "id": "[resourceId('Microsoft.Network/virtualNetworks', parameters('vnetName'))]" + }, + "registrationEnabled": false + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', format('privatelink.file.{0}', environment().suffixes.storage))]" + ] + }, + { + "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", + "apiVersion": "2024-01-01", + "name": "[format('{0}/{1}', format('{0}-file-pe', parameters('baseName')), 'default')]", + "properties": { + "privateDnsZoneConfigs": [ + { + "name": "privatelink-file", + "properties": { + "privateDnsZoneId": "[resourceId('Microsoft.Network/privateDnsZones', format('privatelink.file.{0}', environment().suffixes.storage))]" + } + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', format('privatelink.file.{0}', environment().suffixes.storage))]", + "[resourceId('Microsoft.Network/privateEndpoints', format('{0}-file-pe', parameters('baseName')))]" + ] + } + ], + "outputs": { + "functionAppName": { + "type": "string", + "value": "[format('{0}-func', parameters('baseName'))]" + }, + "functionAppHostname": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Web/sites', format('{0}-func', parameters('baseName'))), '2023-12-01').defaultHostName]" + }, + "functionPrivateEndpointId": { + "type": "string", + "value": "[resourceId('Microsoft.Network/privateEndpoints', format('{0}-func-pe', parameters('baseName')))]" + }, + "functionAppResourceId": { + "type": "string", + "value": "[resourceId('Microsoft.Web/sites', format('{0}-func', parameters('baseName')))]" + } + } +} \ No newline at end of file diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/azure-function-server/function_app.py b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/azure-function-server/function_app.py similarity index 100% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/azure-function-server/function_app.py rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/azure-function-server/function_app.py diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/azure-function-server/host.json b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/azure-function-server/host.json similarity index 100% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/azure-function-server/host.json rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/azure-function-server/host.json diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/azure-function-server/local.settings.json b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/azure-function-server/local.settings.json similarity index 100% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/azure-function-server/local.settings.json rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/azure-function-server/local.settings.json diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/azure-function-server/requirements.txt b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/azure-function-server/requirements.txt similarity index 100% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/azure-function-server/requirements.txt rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/azure-function-server/requirements.txt diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/azuredeploy.json b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/azuredeploy.json similarity index 80% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/azuredeploy.json rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/azuredeploy.json index 746a1b64a..12575125b 100644 --- a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/azuredeploy.json +++ b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/azuredeploy.json @@ -4,8 +4,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "4618599826878163398" + "version": "0.43.8.12551", + "templateHash": "12812848625846814930" } }, "parameters": { @@ -44,9 +44,11 @@ }, "aiServices": { "type": "string", - "defaultValue": "aiservices", + "defaultValue": "aifoundry", + "minLength": 2, + "maxLength": 40, "metadata": { - "description": "Name for your AI Services resource." + "description": "Name prefix for your AI Services (Cognitive Services) resource. Lowercase alphanumeric only; a 4-character random suffix will be appended." } }, "modelName": { @@ -91,8 +93,10 @@ "firstProjectName": { "type": "string", "defaultValue": "project", + "minLength": 2, + "maxLength": 40, "metadata": { - "description": "Name for your project resource." + "description": "Name prefix for your Foundry project. A 4-character random suffix will be appended." } }, "projectDescription": { @@ -111,30 +115,30 @@ }, "vnetName": { "type": "string", - "defaultValue": "agent-vnet-test", + "defaultValue": "agent-vnet", "metadata": { - "description": "Virtual Network name for the Agent to create new or existing virtual network" + "description": "Virtual Network name. When `existingVnetResourceId` is set, this is ignored β€” the name is derived from the resource ID." } }, "agentSubnetName": { "type": "string", "defaultValue": "agent-subnet", "metadata": { - "description": "The name of Agents Subnet to create new or existing subnet for agents" + "description": "Agent subnet name. Ignored when `existingAgentSubnetResourceId` is set." } }, "peSubnetName": { "type": "string", "defaultValue": "pe-subnet", "metadata": { - "description": "The name of Private Endpoint subnet to create new or existing subnet for private endpoints" + "description": "Private endpoint subnet name. Ignored when `existingPeSubnetResourceId` is set." } }, "mcpSubnetName": { "type": "string", "defaultValue": "mcp-subnet", "metadata": { - "description": "The name of MCP subnet for user-deployed Container Apps (e.g., MCP servers)" + "description": "MCP subnet name (hosts user-deployed Container Apps such as MCP servers). Ignored when `existingMcpSubnetResourceId` is set." } }, "existingVnetResourceId": { @@ -172,62 +176,89 @@ "description": "Address prefix for the MCP subnet. The default value is 192.168.2.0/24." } }, - "aiSearchResourceId": { + "existingAgentSubnetResourceId": { "type": "string", "defaultValue": "", "metadata": { - "description": "The AI Search Service full ARM Resource ID. This is an optional field, and if not provided, the resource will be created." + "description": "Optional ARM Resource ID of an existing agent subnet. If provided, the subnet will be referenced as-is and will not be created/modified." } }, - "azureStorageAccountResourceId": { + "existingPeSubnetResourceId": { "type": "string", "defaultValue": "", "metadata": { - "description": "The AI Storage Account full ARM Resource ID. This is an optional field, and if not provided, the resource will be created." + "description": "Optional ARM Resource ID of an existing private endpoint subnet. If provided, the subnet will be referenced as-is and will not be created/modified." } }, - "azureCosmosDBAccountResourceId": { + "existingMcpSubnetResourceId": { "type": "string", "defaultValue": "", "metadata": { - "description": "The Cosmos DB Account full ARM Resource ID. This is an optional field, and if not provided, the resource will be created." + "description": "Optional ARM Resource ID of an existing MCP subnet. If provided, the subnet will be referenced as-is and will not be created/modified." } }, - "fabricWorkspaceResourceId": { + "existingAiSearchResourceId": { "type": "string", "defaultValue": "", "metadata": { - "description": "The Microsoft Fabric Workspace full ARM Resource ID. This is an optional field for Fabric private link connectivity." + "description": "The AI Search Service full ARM Resource ID. Optional β€” leave empty to create a new one." + } + }, + "existingAzureStorageAccountResourceId": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The AI Storage Account full ARM Resource ID. Optional β€” leave empty to create a new one." + } + }, + "existingAzureCosmosDBAccountResourceId": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The Cosmos DB Account full ARM Resource ID. Optional β€” leave empty to create a new one." + } + }, + "existingFabricWorkspaceResourceId": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The Microsoft Fabric Workspace full ARM Resource ID. Optional β€” enables Fabric private link connectivity." } }, "existingDnsZones": { "type": "object", "defaultValue": { - "privatelink.services.ai.azure.com": "", - "privatelink.openai.azure.com": "", - "privatelink.cognitiveservices.azure.com": "", - "privatelink.search.windows.net": "", - "privatelink.blob.core.windows.net": "", - "privatelink.documents.azure.com": "", - "privatelink.analysis.windows.net": "" + "privatelink.services.ai.azure.com": { + "subscriptionId": "", + "resourceGroup": "" + }, + "privatelink.openai.azure.com": { + "subscriptionId": "", + "resourceGroup": "" + }, + "privatelink.cognitiveservices.azure.com": { + "subscriptionId": "", + "resourceGroup": "" + }, + "privatelink.search.windows.net": { + "subscriptionId": "", + "resourceGroup": "" + }, + "[format('privatelink.blob.{0}', environment().suffixes.storage)]": { + "subscriptionId": "", + "resourceGroup": "" + }, + "privatelink.documents.azure.com": { + "subscriptionId": "", + "resourceGroup": "" + }, + "privatelink.fabric.microsoft.com": { + "subscriptionId": "", + "resourceGroup": "" + } }, "metadata": { - "description": "Object mapping DNS zone names to their resource group, or empty string to indicate creation" - } - }, - "dnsZoneNames": { - "type": "array", - "defaultValue": [ - "privatelink.services.ai.azure.com", - "privatelink.openai.azure.com", - "privatelink.cognitiveservices.azure.com", - "privatelink.search.windows.net", - "privatelink.blob.core.windows.net", - "privatelink.documents.azure.com", - "privatelink.analysis.windows.net" - ], - "metadata": { - "description": "Zone Names for Validation of existing Private Dns Zones" + "description": "Map of private DNS zone FQDNs to an object `{ subscriptionId, resourceGroup }` describing where the zone lives. Empty `resourceGroup` means \"create the zone in this deployment's resource group\". A non-empty `resourceGroup` references an existing zone in that RG; empty `subscriptionId` defaults to the current subscription, otherwise the zone is referenced cross-subscription. Note: when referencing an existing zone, the VNet link to that zone is NOT managed by this template β€” the caller must ensure the zone is already linked to the target VNet." } }, "projectCapHost": { @@ -245,24 +276,33 @@ "cosmosDBName": "[toLower(format('{0}{1}cosmosdb', parameters('aiServices'), variables('uniqueSuffix')))]", "aiSearchName": "[toLower(format('{0}{1}search', parameters('aiServices'), variables('uniqueSuffix')))]", "azureStorageName": "[toLower(format('{0}{1}storage', parameters('aiServices'), variables('uniqueSuffix')))]", - "storagePassedIn": "[not(equals(parameters('azureStorageAccountResourceId'), ''))]", - "searchPassedIn": "[not(equals(parameters('aiSearchResourceId'), ''))]", - "cosmosPassedIn": "[not(equals(parameters('azureCosmosDBAccountResourceId'), ''))]", + "storagePassedIn": "[not(equals(parameters('existingAzureStorageAccountResourceId'), ''))]", + "searchPassedIn": "[not(equals(parameters('existingAiSearchResourceId'), ''))]", + "cosmosPassedIn": "[not(equals(parameters('existingAzureCosmosDBAccountResourceId'), ''))]", "existingVnetPassedIn": "[not(equals(parameters('existingVnetResourceId'), ''))]", - "acsParts": "[split(parameters('aiSearchResourceId'), '/')]", + "agentSubnetExists": "[not(equals(parameters('existingAgentSubnetResourceId'), ''))]", + "peSubnetExists": "[not(equals(parameters('existingPeSubnetResourceId'), ''))]", + "mcpSubnetExists": "[not(equals(parameters('existingMcpSubnetResourceId'), ''))]", + "effectiveAgentSubnetName": "[if(variables('agentSubnetExists'), last(split(parameters('existingAgentSubnetResourceId'), '/')), parameters('agentSubnetName'))]", + "effectivePeSubnetName": "[if(variables('peSubnetExists'), last(split(parameters('existingPeSubnetResourceId'), '/')), parameters('peSubnetName'))]", + "effectiveMcpSubnetName": "[if(variables('mcpSubnetExists'), last(split(parameters('existingMcpSubnetResourceId'), '/')), parameters('mcpSubnetName'))]", + "acsParts": "[split(parameters('existingAiSearchResourceId'), '/')]", "aiSearchServiceSubscriptionId": "[if(variables('searchPassedIn'), variables('acsParts')[2], subscription().subscriptionId)]", "aiSearchServiceResourceGroupName": "[if(variables('searchPassedIn'), variables('acsParts')[4], resourceGroup().name)]", - "cosmosParts": "[split(parameters('azureCosmosDBAccountResourceId'), '/')]", + "cosmosParts": "[split(parameters('existingAzureCosmosDBAccountResourceId'), '/')]", "cosmosDBSubscriptionId": "[if(variables('cosmosPassedIn'), variables('cosmosParts')[2], subscription().subscriptionId)]", "cosmosDBResourceGroupName": "[if(variables('cosmosPassedIn'), variables('cosmosParts')[4], resourceGroup().name)]", - "storageParts": "[split(parameters('azureStorageAccountResourceId'), '/')]", + "storageParts": "[split(parameters('existingAzureStorageAccountResourceId'), '/')]", "azureStorageSubscriptionId": "[if(variables('storagePassedIn'), variables('storageParts')[2], subscription().subscriptionId)]", "azureStorageResourceGroupName": "[if(variables('storagePassedIn'), variables('storageParts')[4], resourceGroup().name)]", "vnetParts": "[split(parameters('existingVnetResourceId'), '/')]", "vnetSubscriptionId": "[if(variables('existingVnetPassedIn'), variables('vnetParts')[2], subscription().subscriptionId)]", "vnetResourceGroupName": "[if(variables('existingVnetPassedIn'), variables('vnetParts')[4], resourceGroup().name)]", "existingVnetName": "[if(variables('existingVnetPassedIn'), last(variables('vnetParts')), parameters('vnetName'))]", - "trimVnetName": "[trim(variables('existingVnetName'))]" + "trimVnetName": "[trim(variables('existingVnetName'))]", + "aiSearchExists": "[not(equals(parameters('existingAiSearchResourceId'), ''))]", + "azureStorageExists": "[not(equals(parameters('existingAzureStorageAccountResourceId'), ''))]", + "cosmosDBExists": "[not(equals(parameters('existingAzureCosmosDBAccountResourceId'), ''))]" }, "resources": [ { @@ -288,13 +328,13 @@ "value": "[variables('vnetResourceGroupName')]" }, "agentSubnetName": { - "value": "[parameters('agentSubnetName')]" + "value": "[variables('effectiveAgentSubnetName')]" }, "peSubnetName": { - "value": "[parameters('peSubnetName')]" + "value": "[variables('effectivePeSubnetName')]" }, "mcpSubnetName": { - "value": "[parameters('mcpSubnetName')]" + "value": "[variables('effectiveMcpSubnetName')]" }, "vnetAddressPrefix": { "value": "[parameters('vnetAddressPrefix')]" @@ -310,6 +350,15 @@ }, "existingVnetSubscriptionId": { "value": "[variables('vnetSubscriptionId')]" + }, + "agentSubnetExists": { + "value": "[variables('agentSubnetExists')]" + }, + "peSubnetExists": { + "value": "[variables('peSubnetExists')]" + }, + "mcpSubnetExists": { + "value": "[variables('mcpSubnetExists')]" } }, "template": { @@ -318,8 +367,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "7580422666690415547" + "version": "0.43.8.12551", + "templateHash": "3367717386889798767" } }, "parameters": { @@ -404,6 +453,27 @@ "metadata": { "description": "Address prefix for the MCP subnet" } + }, + "agentSubnetExists": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Set to true to reference the agent subnet as existing (do not create or modify it)" + } + }, + "peSubnetExists": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Set to true to reference the private endpoint subnet as existing (do not create or modify it)" + } + }, + "mcpSubnetExists": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Set to true to reference the MCP subnet as existing (do not create or modify it)" + } } }, "resources": [ @@ -452,8 +522,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "14420765200836708228" + "version": "0.43.8.12551", + "templateHash": "3231703172659113421" } }, "parameters": { @@ -660,6 +730,15 @@ }, "mcpSubnetPrefix": { "value": "[parameters('mcpSubnetPrefix')]" + }, + "agentSubnetExists": { + "value": "[parameters('agentSubnetExists')]" + }, + "peSubnetExists": { + "value": "[parameters('peSubnetExists')]" + }, + "mcpSubnetExists": { + "value": "[parameters('mcpSubnetExists')]" } }, "template": { @@ -668,8 +747,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "15142627104032952494" + "version": "0.43.8.12551", + "templateHash": "8635420737989534099" } }, "parameters": { @@ -734,6 +813,27 @@ "metadata": { "description": "Address prefix for the MCP subnet (only needed if creating new subnet)" } + }, + "agentSubnetExists": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Set to true to reference the agent subnet as existing (do not create or modify it)" + } + }, + "peSubnetExists": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Set to true to reference the private endpoint subnet as existing (do not create or modify it)" + } + }, + "mcpSubnetExists": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Set to true to reference the MCP subnet as existing (do not create or modify it)" + } } }, "resources": [ @@ -755,6 +855,9 @@ "value": "[parameters('agentSubnetName')]" }, "addressPrefix": "[if(empty(parameters('agentSubnetPrefix')), createObject('value', cidrSubnet(reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName')), '2024-05-01').addressSpace.addressPrefixes[0], 24, 0)), createObject('value', parameters('agentSubnetPrefix')))]", + "subnetExists": { + "value": "[parameters('agentSubnetExists')]" + }, "delegations": { "value": [ { @@ -772,8 +875,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "3458409006502413839" + "version": "0.43.8.12551", + "templateHash": "10805545016086603042" } }, "parameters": { @@ -791,8 +894,9 @@ }, "addressPrefix": { "type": "string", + "defaultValue": "", "metadata": { - "description": "Address prefix for the subnet" + "description": "Address prefix for the subnet (only required when creating a new subnet)" } }, "delegations": { @@ -801,10 +905,18 @@ "metadata": { "description": "Array of subnet delegations" } + }, + "subnetExists": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Set to true to reference an existing subnet instead of creating one" + } } }, "resources": [ { + "condition": "[not(parameters('subnetExists'))]", "type": "Microsoft.Network/virtualNetworks/subnets", "apiVersion": "2024-05-01", "name": "[format('{0}/{1}', parameters('vnetName'), parameters('subnetName'))]", @@ -817,7 +929,7 @@ "outputs": { "subnetId": { "type": "string", - "value": "[resourceId('Microsoft.Network/virtualNetworks/subnets', split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[0], split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[1])]" + "value": "[if(parameters('subnetExists'), resourceId('Microsoft.Network/virtualNetworks/subnets', split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[0], split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[1]), resourceId('Microsoft.Network/virtualNetworks/subnets', split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[0], split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[1]))]" }, "subnetName": { "type": "string", @@ -845,6 +957,9 @@ "value": "[parameters('peSubnetName')]" }, "addressPrefix": "[if(empty(parameters('peSubnetPrefix')), createObject('value', cidrSubnet(reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName')), '2024-05-01').addressSpace.addressPrefixes[0], 24, 1)), createObject('value', parameters('peSubnetPrefix')))]", + "subnetExists": { + "value": "[parameters('peSubnetExists')]" + }, "delegations": { "value": [] } @@ -855,8 +970,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "3458409006502413839" + "version": "0.43.8.12551", + "templateHash": "10805545016086603042" } }, "parameters": { @@ -874,8 +989,9 @@ }, "addressPrefix": { "type": "string", + "defaultValue": "", "metadata": { - "description": "Address prefix for the subnet" + "description": "Address prefix for the subnet (only required when creating a new subnet)" } }, "delegations": { @@ -884,10 +1000,18 @@ "metadata": { "description": "Array of subnet delegations" } + }, + "subnetExists": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Set to true to reference an existing subnet instead of creating one" + } } }, "resources": [ { + "condition": "[not(parameters('subnetExists'))]", "type": "Microsoft.Network/virtualNetworks/subnets", "apiVersion": "2024-05-01", "name": "[format('{0}/{1}', parameters('vnetName'), parameters('subnetName'))]", @@ -900,7 +1024,7 @@ "outputs": { "subnetId": { "type": "string", - "value": "[resourceId('Microsoft.Network/virtualNetworks/subnets', split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[0], split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[1])]" + "value": "[if(parameters('subnetExists'), resourceId('Microsoft.Network/virtualNetworks/subnets', split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[0], split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[1]), resourceId('Microsoft.Network/virtualNetworks/subnets', split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[0], split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[1]))]" }, "subnetName": { "type": "string", @@ -908,7 +1032,10 @@ } } } - } + }, + "dependsOn": [ + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, parameters('vnetResourceGroupName')), 'Microsoft.Resources/deployments', format('agent-subnet-{0}', uniqueString(deployment().name, parameters('agentSubnetName'))))]" + ] }, { "type": "Microsoft.Resources/deployments", @@ -928,6 +1055,9 @@ "value": "[parameters('mcpSubnetName')]" }, "addressPrefix": "[if(empty(parameters('mcpSubnetPrefix')), createObject('value', cidrSubnet(reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName')), '2024-05-01').addressSpace.addressPrefixes[0], 24, 2)), createObject('value', parameters('mcpSubnetPrefix')))]", + "subnetExists": { + "value": "[parameters('mcpSubnetExists')]" + }, "delegations": { "value": [ { @@ -945,8 +1075,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "3458409006502413839" + "version": "0.43.8.12551", + "templateHash": "10805545016086603042" } }, "parameters": { @@ -964,8 +1094,9 @@ }, "addressPrefix": { "type": "string", + "defaultValue": "", "metadata": { - "description": "Address prefix for the subnet" + "description": "Address prefix for the subnet (only required when creating a new subnet)" } }, "delegations": { @@ -974,10 +1105,18 @@ "metadata": { "description": "Array of subnet delegations" } + }, + "subnetExists": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Set to true to reference an existing subnet instead of creating one" + } } }, "resources": [ { + "condition": "[not(parameters('subnetExists'))]", "type": "Microsoft.Network/virtualNetworks/subnets", "apiVersion": "2024-05-01", "name": "[format('{0}/{1}', parameters('vnetName'), parameters('subnetName'))]", @@ -990,7 +1129,7 @@ "outputs": { "subnetId": { "type": "string", - "value": "[resourceId('Microsoft.Network/virtualNetworks/subnets', split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[0], split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[1])]" + "value": "[if(parameters('subnetExists'), resourceId('Microsoft.Network/virtualNetworks/subnets', split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[0], split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[1]), resourceId('Microsoft.Network/virtualNetworks/subnets', split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[0], split(format('{0}/{1}', parameters('vnetName'), parameters('subnetName')), '/')[1]))]" }, "subnetName": { "type": "string", @@ -998,7 +1137,10 @@ } } } - } + }, + "dependsOn": [ + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, parameters('vnetResourceGroupName')), 'Microsoft.Resources/deployments', format('pe-subnet-{0}', uniqueString(deployment().name, parameters('peSubnetName'))))]" + ] } ], "outputs": { @@ -1016,15 +1158,15 @@ }, "agentSubnetId": { "type": "string", - "value": "[format('{0}/subnets/{1}', extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName')), parameters('agentSubnetName'))]" + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, parameters('vnetResourceGroupName')), 'Microsoft.Resources/deployments', format('agent-subnet-{0}', uniqueString(deployment().name, parameters('agentSubnetName')))), '2025-04-01').outputs.subnetId.value]" }, "peSubnetId": { "type": "string", - "value": "[format('{0}/subnets/{1}', extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName')), parameters('peSubnetName'))]" + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, parameters('vnetResourceGroupName')), 'Microsoft.Resources/deployments', format('pe-subnet-{0}', uniqueString(deployment().name, parameters('peSubnetName')))), '2025-04-01').outputs.subnetId.value]" }, "mcpSubnetId": { "type": "string", - "value": "[format('{0}/subnets/{1}', extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName')), parameters('mcpSubnetName'))]" + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, parameters('vnetResourceGroupName')), 'Microsoft.Resources/deployments', format('mcp-subnet-{0}', uniqueString(deployment().name, parameters('mcpSubnetName')))), '2025-04-01').outputs.subnetId.value]" }, "virtualNetworkName": { "type": "string", @@ -1133,8 +1275,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "5075008770391657650" + "version": "0.43.8.12551", + "templateHash": "1400943577065869287" } }, "parameters": { @@ -1238,143 +1380,6 @@ "[resourceId('Microsoft.Resources/deployments', format('vnet-{0}-{1}-deployment', variables('trimVnetName'), variables('uniqueSuffix')))]" ] }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2025-04-01", - "name": "[format('validate-existing-resources-{0}-deployment', variables('uniqueSuffix'))]", - "properties": { - "expressionEvaluationOptions": { - "scope": "inner" - }, - "mode": "Incremental", - "parameters": { - "aiSearchResourceId": { - "value": "[parameters('aiSearchResourceId')]" - }, - "azureStorageAccountResourceId": { - "value": "[parameters('azureStorageAccountResourceId')]" - }, - "azureCosmosDBAccountResourceId": { - "value": "[parameters('azureCosmosDBAccountResourceId')]" - }, - "existingDnsZones": { - "value": "[parameters('existingDnsZones')]" - }, - "dnsZoneNames": { - "value": "[parameters('dnsZoneNames')]" - } - }, - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "16089088048916219322" - } - }, - "parameters": { - "aiSearchResourceId": { - "type": "string", - "metadata": { - "description": "Resource ID of the AI Search Service." - } - }, - "azureStorageAccountResourceId": { - "type": "string", - "metadata": { - "description": "Resource ID of the Azure Storage Account." - } - }, - "azureCosmosDBAccountResourceId": { - "type": "string", - "metadata": { - "description": "ResourceId of Cosmos DB Account" - } - }, - "existingDnsZones": { - "type": "object", - "metadata": { - "description": "Object mapping DNS zone names to their resource group, or empty string to indicate creation" - } - }, - "dnsZoneNames": { - "type": "array", - "metadata": { - "description": "List of private DNS zone names to validate" - } - } - }, - "variables": { - "storagePassedIn": "[not(equals(parameters('azureStorageAccountResourceId'), ''))]", - "searchPassedIn": "[not(equals(parameters('aiSearchResourceId'), ''))]", - "cosmosPassedIn": "[not(equals(parameters('azureCosmosDBAccountResourceId'), ''))]", - "storageParts": "[split(parameters('azureStorageAccountResourceId'), '/')]", - "azureStorageSubscriptionId": "[if(and(variables('storagePassedIn'), greater(length(variables('storageParts')), 2)), variables('storageParts')[2], subscription().subscriptionId)]", - "azureStorageResourceGroupName": "[if(and(variables('storagePassedIn'), greater(length(variables('storageParts')), 4)), variables('storageParts')[4], resourceGroup().name)]", - "acsParts": "[split(parameters('aiSearchResourceId'), '/')]", - "aiSearchServiceSubscriptionId": "[if(and(variables('searchPassedIn'), greater(length(variables('acsParts')), 2)), variables('acsParts')[2], subscription().subscriptionId)]", - "aiSearchServiceResourceGroupName": "[if(and(variables('searchPassedIn'), greater(length(variables('acsParts')), 4)), variables('acsParts')[4], resourceGroup().name)]", - "cosmosParts": "[split(parameters('azureCosmosDBAccountResourceId'), '/')]", - "cosmosDBSubscriptionId": "[if(and(variables('cosmosPassedIn'), greater(length(variables('cosmosParts')), 2)), variables('cosmosParts')[2], subscription().subscriptionId)]", - "cosmosDBResourceGroupName": "[if(and(variables('cosmosPassedIn'), greater(length(variables('cosmosParts')), 4)), variables('cosmosParts')[4], resourceGroup().name)]", - "dnsZoneTypes": [ - "Microsoft.Network/privateDnsZones" - ] - }, - "resources": [], - "outputs": { - "aiSearchExists": { - "type": "bool", - "value": "[and(variables('searchPassedIn'), equals(last(split(parameters('aiSearchResourceId'), '/')), variables('acsParts')[8]))]" - }, - "cosmosDBExists": { - "type": "bool", - "value": "[and(variables('cosmosPassedIn'), equals(last(split(parameters('azureCosmosDBAccountResourceId'), '/')), variables('cosmosParts')[8]))]" - }, - "azureStorageExists": { - "type": "bool", - "value": "[and(variables('storagePassedIn'), equals(last(split(parameters('azureStorageAccountResourceId'), '/')), variables('storageParts')[8]))]" - }, - "aiSearchServiceSubscriptionId": { - "type": "string", - "value": "[variables('aiSearchServiceSubscriptionId')]" - }, - "aiSearchServiceResourceGroupName": { - "type": "string", - "value": "[variables('aiSearchServiceResourceGroupName')]" - }, - "cosmosDBSubscriptionId": { - "type": "string", - "value": "[variables('cosmosDBSubscriptionId')]" - }, - "cosmosDBResourceGroupName": { - "type": "string", - "value": "[variables('cosmosDBResourceGroupName')]" - }, - "azureStorageSubscriptionId": { - "type": "string", - "value": "[variables('azureStorageSubscriptionId')]" - }, - "azureStorageResourceGroupName": { - "type": "string", - "value": "[variables('azureStorageResourceGroupName')]" - }, - "dnsZoneExists": { - "type": "array", - "copy": { - "count": "[length(parameters('dnsZoneNames'))]", - "input": { - "name": "[parameters('dnsZoneNames')[copyIndex()]]", - "exists": "[not(empty(parameters('existingDnsZones')[parameters('dnsZoneNames')[copyIndex()]]))]" - } - } - } - } - } - } - }, { "type": "Microsoft.Resources/deployments", "apiVersion": "2025-04-01", @@ -1397,23 +1402,23 @@ "cosmosDBName": { "value": "[variables('cosmosDBName')]" }, - "aiSearchResourceId": { - "value": "[parameters('aiSearchResourceId')]" + "existingAiSearchResourceId": { + "value": "[parameters('existingAiSearchResourceId')]" }, "aiSearchExists": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('validate-existing-resources-{0}-deployment', variables('uniqueSuffix'))), '2025-04-01').outputs.aiSearchExists.value]" + "value": "[variables('aiSearchExists')]" }, - "azureStorageAccountResourceId": { - "value": "[parameters('azureStorageAccountResourceId')]" + "existingAzureStorageAccountResourceId": { + "value": "[parameters('existingAzureStorageAccountResourceId')]" }, "azureStorageExists": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('validate-existing-resources-{0}-deployment', variables('uniqueSuffix'))), '2025-04-01').outputs.azureStorageExists.value]" + "value": "[variables('azureStorageExists')]" }, - "cosmosDBResourceId": { - "value": "[parameters('azureCosmosDBAccountResourceId')]" + "existingCosmosDBResourceId": { + "value": "[parameters('existingAzureCosmosDBAccountResourceId')]" }, "cosmosDBExists": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', format('validate-existing-resources-{0}-deployment', variables('uniqueSuffix'))), '2025-04-01').outputs.cosmosDBExists.value]" + "value": "[variables('cosmosDBExists')]" } }, "template": { @@ -1422,8 +1427,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "14445641712219672614" + "version": "0.43.8.12551", + "templateHash": "6113332263495979803" } }, "parameters": { @@ -1451,22 +1456,22 @@ "description": "Name of the new Cosmos DB account" } }, - "aiSearchResourceId": { + "existingAiSearchResourceId": { "type": "string", "metadata": { - "description": "The AI Search Service full ARM Resource ID. This is an optional field, and if not provided, the resource will be created." + "description": "The AI Search Service full ARM Resource ID. Optional β€” leave empty to create a new one." } }, - "azureStorageAccountResourceId": { + "existingAzureStorageAccountResourceId": { "type": "string", "metadata": { - "description": "The AI Storage Account full ARM Resource ID. This is an optional field, and if not provided, the resource will be created." + "description": "The AI Storage Account full ARM Resource ID. Optional β€” leave empty to create a new one." } }, - "cosmosDBResourceId": { + "existingCosmosDBResourceId": { "type": "string", "metadata": { - "description": "The Cosmos DB Account full ARM Resource ID. This is an optional field, and if not provided, the resource will be created." + "description": "The Cosmos DB Account full ARM Resource ID. Optional β€” leave empty to create a new one." } }, "aiSearchExists": { @@ -1491,14 +1496,14 @@ } }, "variables": { - "cosmosParts": "[split(parameters('cosmosDBResourceId'), '/')]", + "cosmosParts": "[split(parameters('existingCosmosDBResourceId'), '/')]", "canaryRegions": [ "eastus2euap", "centraluseuap" ], "cosmosDbRegion": "[if(contains(variables('canaryRegions'), parameters('location')), 'westus', parameters('location'))]", - "acsParts": "[split(parameters('aiSearchResourceId'), '/')]", - "azureStorageParts": "[split(parameters('azureStorageAccountResourceId'), '/')]" + "acsParts": "[split(parameters('existingAiSearchResourceId'), '/')]", + "azureStorageParts": "[split(parameters('existingAzureStorageAccountResourceId'), '/')]" }, "resources": [ { @@ -1632,10 +1637,7 @@ } } } - }, - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', format('validate-existing-resources-{0}-deployment', variables('uniqueSuffix')))]" - ] + } }, { "type": "Microsoft.Resources/deployments", @@ -1660,7 +1662,7 @@ "value": "[reference(resourceId('Microsoft.Resources/deployments', format('dependencies-{0}-deployment', variables('uniqueSuffix'))), '2025-04-01').outputs.cosmosDBName.value]" }, "fabricWorkspaceResourceId": { - "value": "[parameters('fabricWorkspaceResourceId')]" + "value": "[parameters('existingFabricWorkspaceResourceId')]" }, "vnetName": { "value": "[reference(resourceId('Microsoft.Resources/deployments', format('vnet-{0}-{1}-deployment', variables('trimVnetName'), variables('uniqueSuffix'))), '2025-04-01').outputs.virtualNetworkName.value]" @@ -1705,8 +1707,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "4936875384661892557" + "version": "0.43.8.12551", + "templateHash": "9883330111104552489" } }, "parameters": { @@ -1818,16 +1820,37 @@ "existingDnsZones": { "type": "object", "defaultValue": { - "privatelink.services.ai.azure.com": "", - "privatelink.openai.azure.com": "", - "privatelink.cognitiveservices.azure.com": "", - "privatelink.search.windows.net": "", - "[format('privatelink.blob.{0}', environment().suffixes.storage)]": "", - "privatelink.documents.azure.com": "", - "privatelink.fabric.microsoft.com": "" + "privatelink.services.ai.azure.com": { + "subscriptionId": "", + "resourceGroup": "" + }, + "privatelink.openai.azure.com": { + "subscriptionId": "", + "resourceGroup": "" + }, + "privatelink.cognitiveservices.azure.com": { + "subscriptionId": "", + "resourceGroup": "" + }, + "privatelink.search.windows.net": { + "subscriptionId": "", + "resourceGroup": "" + }, + "[format('privatelink.blob.{0}', environment().suffixes.storage)]": { + "subscriptionId": "", + "resourceGroup": "" + }, + "privatelink.documents.azure.com": { + "subscriptionId": "", + "resourceGroup": "" + }, + "privatelink.fabric.microsoft.com": { + "subscriptionId": "", + "resourceGroup": "" + } }, "metadata": { - "description": "Map of DNS zone FQDNs to resource group names. If provided, reference existing DNS zones in this resource group instead of creating them." + "description": "Map of DNS zone FQDNs to an object describing where the zone lives. Each value must be an object with optional `subscriptionId` and `resourceGroup` properties. Empty `resourceGroup` means \"create the zone in this deployment's resource group\". Non-empty `resourceGroup` references an existing zone; empty `subscriptionId` defaults to the current subscription." } } }, @@ -1842,20 +1865,8 @@ "storageDnsZoneName": "[format('privatelink.blob.{0}', environment().suffixes.storage)]", "cosmosDBDnsZoneName": "privatelink.documents.azure.com", "fabricDnsZoneName": "privatelink.fabric.microsoft.com", - "aiServicesDnsZoneRG": "[parameters('existingDnsZones')[variables('aiServicesDnsZoneName')]]", - "openAiDnsZoneRG": "[parameters('existingDnsZones')[variables('openAiDnsZoneName')]]", - "cognitiveServicesDnsZoneRG": "[parameters('existingDnsZones')[variables('cognitiveServicesDnsZoneName')]]", - "aiSearchDnsZoneRG": "[parameters('existingDnsZones')[variables('aiSearchDnsZoneName')]]", - "storageDnsZoneRG": "[parameters('existingDnsZones')[variables('storageDnsZoneName')]]", - "cosmosDBDnsZoneRG": "[parameters('existingDnsZones')[variables('cosmosDBDnsZoneName')]]", - "fabricDnsZoneRG": "[coalesce(tryGet(parameters('existingDnsZones'), 'fabricDnsZoneName'), '')]", - "aiServicesDnsZoneId": "[if(empty(variables('aiServicesDnsZoneRG')), resourceId('Microsoft.Network/privateDnsZones', variables('aiServicesDnsZoneName')), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('aiServicesDnsZoneRG')), 'Microsoft.Network/privateDnsZones', variables('aiServicesDnsZoneName')))]", - "openAiDnsZoneId": "[if(empty(variables('openAiDnsZoneRG')), resourceId('Microsoft.Network/privateDnsZones', variables('openAiDnsZoneName')), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('openAiDnsZoneRG')), 'Microsoft.Network/privateDnsZones', variables('openAiDnsZoneName')))]", - "cognitiveServicesDnsZoneId": "[if(empty(variables('cognitiveServicesDnsZoneRG')), resourceId('Microsoft.Network/privateDnsZones', variables('cognitiveServicesDnsZoneName')), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('cognitiveServicesDnsZoneRG')), 'Microsoft.Network/privateDnsZones', variables('cognitiveServicesDnsZoneName')))]", - "aiSearchDnsZoneId": "[if(empty(variables('aiSearchDnsZoneRG')), resourceId('Microsoft.Network/privateDnsZones', variables('aiSearchDnsZoneName')), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('aiSearchDnsZoneRG')), 'Microsoft.Network/privateDnsZones', variables('aiSearchDnsZoneName')))]", - "storageDnsZoneId": "[if(empty(variables('storageDnsZoneRG')), resourceId('Microsoft.Network/privateDnsZones', variables('storageDnsZoneName')), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('storageDnsZoneRG')), 'Microsoft.Network/privateDnsZones', variables('storageDnsZoneName')))]", - "cosmosDBDnsZoneId": "[if(empty(variables('cosmosDBDnsZoneRG')), resourceId('Microsoft.Network/privateDnsZones', variables('cosmosDBDnsZoneName')), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('cosmosDBDnsZoneRG')), 'Microsoft.Network/privateDnsZones', variables('cosmosDBDnsZoneName')))]", - "fabricDnsZoneId": "[if(variables('fabricPassedIn'), if(empty(variables('fabricDnsZoneRG')), resourceId('Microsoft.Network/privateDnsZones', variables('fabricDnsZoneName')), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('fabricDnsZoneRG')), 'Microsoft.Network/privateDnsZones', variables('fabricDnsZoneName'))), '')]" + "dnsZoneEntries": "[items(parameters('existingDnsZones'))]", + "dnsZoneKeys": "[map(variables('dnsZoneEntries'), lambda('e', lambdaVariables('e').key))]" }, "resources": [ { @@ -1969,167 +1980,6 @@ ] } }, - { - "condition": "[empty(variables('aiServicesDnsZoneRG'))]", - "type": "Microsoft.Network/privateDnsZones", - "apiVersion": "2020-06-01", - "name": "[variables('aiServicesDnsZoneName')]", - "location": "global" - }, - { - "condition": "[empty(variables('openAiDnsZoneRG'))]", - "type": "Microsoft.Network/privateDnsZones", - "apiVersion": "2020-06-01", - "name": "[variables('openAiDnsZoneName')]", - "location": "global" - }, - { - "condition": "[empty(variables('cognitiveServicesDnsZoneRG'))]", - "type": "Microsoft.Network/privateDnsZones", - "apiVersion": "2020-06-01", - "name": "[variables('cognitiveServicesDnsZoneName')]", - "location": "global" - }, - { - "condition": "[empty(variables('aiSearchDnsZoneRG'))]", - "type": "Microsoft.Network/privateDnsZones", - "apiVersion": "2020-06-01", - "name": "[variables('aiSearchDnsZoneName')]", - "location": "global" - }, - { - "condition": "[empty(variables('storageDnsZoneRG'))]", - "type": "Microsoft.Network/privateDnsZones", - "apiVersion": "2020-06-01", - "name": "[variables('storageDnsZoneName')]", - "location": "global" - }, - { - "condition": "[empty(variables('cosmosDBDnsZoneRG'))]", - "type": "Microsoft.Network/privateDnsZones", - "apiVersion": "2020-06-01", - "name": "[variables('cosmosDBDnsZoneName')]", - "location": "global" - }, - { - "condition": "[and(variables('fabricPassedIn'), empty(variables('fabricDnsZoneRG')))]", - "type": "Microsoft.Network/privateDnsZones", - "apiVersion": "2020-06-01", - "name": "[variables('fabricDnsZoneName')]", - "location": "global" - }, - { - "condition": "[empty(variables('aiServicesDnsZoneRG'))]", - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2024-06-01", - "name": "[format('{0}/{1}', variables('aiServicesDnsZoneName'), format('aiServices-{0}-link', parameters('suffix')))]", - "location": "global", - "properties": { - "virtualNetwork": { - "id": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('vnetSubscriptionId'), parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName'))]" - }, - "registrationEnabled": false - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones', variables('aiServicesDnsZoneName'))]" - ] - }, - { - "condition": "[empty(variables('openAiDnsZoneRG'))]", - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2024-06-01", - "name": "[format('{0}/{1}', variables('openAiDnsZoneName'), format('aiServicesOpenAI-{0}-link', parameters('suffix')))]", - "location": "global", - "properties": { - "virtualNetwork": { - "id": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('vnetSubscriptionId'), parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName'))]" - }, - "registrationEnabled": false - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones', variables('openAiDnsZoneName'))]" - ] - }, - { - "condition": "[empty(variables('cognitiveServicesDnsZoneRG'))]", - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2024-06-01", - "name": "[format('{0}/{1}', variables('cognitiveServicesDnsZoneName'), format('aiServicesCognitiveServices-{0}-link', parameters('suffix')))]", - "location": "global", - "properties": { - "virtualNetwork": { - "id": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('vnetSubscriptionId'), parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName'))]" - }, - "registrationEnabled": false - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones', variables('cognitiveServicesDnsZoneName'))]" - ] - }, - { - "condition": "[empty(variables('aiSearchDnsZoneRG'))]", - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2024-06-01", - "name": "[format('{0}/{1}', variables('aiSearchDnsZoneName'), format('aiSearch-{0}-link', parameters('suffix')))]", - "location": "global", - "properties": { - "virtualNetwork": { - "id": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('vnetSubscriptionId'), parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName'))]" - }, - "registrationEnabled": false - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones', variables('aiSearchDnsZoneName'))]" - ] - }, - { - "condition": "[empty(variables('storageDnsZoneRG'))]", - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2024-06-01", - "name": "[format('{0}/{1}', variables('storageDnsZoneName'), format('storage-{0}-link', parameters('suffix')))]", - "location": "global", - "properties": { - "virtualNetwork": { - "id": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('vnetSubscriptionId'), parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName'))]" - }, - "registrationEnabled": false - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones', variables('storageDnsZoneName'))]" - ] - }, - { - "condition": "[empty(variables('cosmosDBDnsZoneRG'))]", - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2024-06-01", - "name": "[format('{0}/{1}', variables('cosmosDBDnsZoneName'), format('cosmosDB-{0}-link', parameters('suffix')))]", - "location": "global", - "properties": { - "virtualNetwork": { - "id": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('vnetSubscriptionId'), parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName'))]" - }, - "registrationEnabled": false - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones', variables('cosmosDBDnsZoneName'))]" - ] - }, - { - "condition": "[and(variables('fabricPassedIn'), empty(variables('fabricDnsZoneRG')))]", - "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", - "apiVersion": "2024-06-01", - "name": "[format('{0}/{1}', variables('fabricDnsZoneName'), format('fabric-{0}-link', parameters('suffix')))]", - "location": "global", - "properties": { - "virtualNetwork": { - "id": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('vnetSubscriptionId'), parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName'))]" - }, - "registrationEnabled": false - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones', variables('fabricDnsZoneName'))]" - ] - }, { "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", "apiVersion": "2024-05-01", @@ -2139,31 +1989,28 @@ { "name": "[format('{0}-dns-aiserv-config', parameters('aiAccountName'))]", "properties": { - "privateDnsZoneId": "[variables('aiServicesDnsZoneId')]" + "privateDnsZoneId": "[reference(resourceId('Microsoft.Resources/deployments', format('dns-{0}-{1}', replace(variables('dnsZoneEntries')[indexOf(variables('dnsZoneKeys'), variables('aiServicesDnsZoneName'))].key, '.', '-'), parameters('suffix'))), '2025-04-01').outputs.zoneId.value]" } }, { "name": "[format('{0}-dns-openai-config', parameters('aiAccountName'))]", "properties": { - "privateDnsZoneId": "[variables('openAiDnsZoneId')]" + "privateDnsZoneId": "[reference(resourceId('Microsoft.Resources/deployments', format('dns-{0}-{1}', replace(variables('dnsZoneEntries')[indexOf(variables('dnsZoneKeys'), variables('openAiDnsZoneName'))].key, '.', '-'), parameters('suffix'))), '2025-04-01').outputs.zoneId.value]" } }, { "name": "[format('{0}-dns-cogserv-config', parameters('aiAccountName'))]", "properties": { - "privateDnsZoneId": "[variables('cognitiveServicesDnsZoneId')]" + "privateDnsZoneId": "[reference(resourceId('Microsoft.Resources/deployments', format('dns-{0}-{1}', replace(variables('dnsZoneEntries')[indexOf(variables('dnsZoneKeys'), variables('cognitiveServicesDnsZoneName'))].key, '.', '-'), parameters('suffix'))), '2025-04-01').outputs.zoneId.value]" } } ] }, "dependsOn": [ "[resourceId('Microsoft.Network/privateEndpoints', format('{0}-private-endpoint', parameters('aiAccountName')))]", - "[resourceId('Microsoft.Network/privateDnsZones/virtualNetworkLinks', variables('aiServicesDnsZoneName'), format('aiServices-{0}-link', parameters('suffix')))]", - "[resourceId('Microsoft.Network/privateDnsZones', variables('aiServicesDnsZoneName'))]", - "[resourceId('Microsoft.Network/privateDnsZones/virtualNetworkLinks', variables('cognitiveServicesDnsZoneName'), format('aiServicesCognitiveServices-{0}-link', parameters('suffix')))]", - "[resourceId('Microsoft.Network/privateDnsZones', variables('cognitiveServicesDnsZoneName'))]", - "[resourceId('Microsoft.Network/privateDnsZones/virtualNetworkLinks', variables('openAiDnsZoneName'), format('aiServicesOpenAI-{0}-link', parameters('suffix')))]", - "[resourceId('Microsoft.Network/privateDnsZones', variables('openAiDnsZoneName'))]" + "[resourceId('Microsoft.Resources/deployments', format('dns-{0}-{1}', replace(variables('dnsZoneEntries')[indexOf(variables('dnsZoneKeys'), variables('aiServicesDnsZoneName'))].key, '.', '-'), parameters('suffix')))]", + "[resourceId('Microsoft.Resources/deployments', format('dns-{0}-{1}', replace(variables('dnsZoneEntries')[indexOf(variables('dnsZoneKeys'), variables('openAiDnsZoneName'))].key, '.', '-'), parameters('suffix')))]", + "[resourceId('Microsoft.Resources/deployments', format('dns-{0}-{1}', replace(variables('dnsZoneEntries')[indexOf(variables('dnsZoneKeys'), variables('cognitiveServicesDnsZoneName'))].key, '.', '-'), parameters('suffix')))]" ] }, { @@ -2175,15 +2022,14 @@ { "name": "[format('{0}-dns-config', parameters('aiSearchName'))]", "properties": { - "privateDnsZoneId": "[variables('aiSearchDnsZoneId')]" + "privateDnsZoneId": "[reference(resourceId('Microsoft.Resources/deployments', format('dns-{0}-{1}', replace(variables('dnsZoneEntries')[indexOf(variables('dnsZoneKeys'), variables('aiSearchDnsZoneName'))].key, '.', '-'), parameters('suffix'))), '2025-04-01').outputs.zoneId.value]" } } ] }, "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones/virtualNetworkLinks', variables('aiSearchDnsZoneName'), format('aiSearch-{0}-link', parameters('suffix')))]", - "[resourceId('Microsoft.Network/privateDnsZones', variables('aiSearchDnsZoneName'))]", - "[resourceId('Microsoft.Network/privateEndpoints', format('{0}-private-endpoint', parameters('aiSearchName')))]" + "[resourceId('Microsoft.Network/privateEndpoints', format('{0}-private-endpoint', parameters('aiSearchName')))]", + "[resourceId('Microsoft.Resources/deployments', format('dns-{0}-{1}', replace(variables('dnsZoneEntries')[indexOf(variables('dnsZoneKeys'), variables('aiSearchDnsZoneName'))].key, '.', '-'), parameters('suffix')))]" ] }, { @@ -2195,14 +2041,13 @@ { "name": "[format('{0}-dns-config', parameters('storageName'))]", "properties": { - "privateDnsZoneId": "[variables('storageDnsZoneId')]" + "privateDnsZoneId": "[reference(resourceId('Microsoft.Resources/deployments', format('dns-{0}-{1}', replace(variables('dnsZoneEntries')[indexOf(variables('dnsZoneKeys'), variables('storageDnsZoneName'))].key, '.', '-'), parameters('suffix'))), '2025-04-01').outputs.zoneId.value]" } } ] }, "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones/virtualNetworkLinks', variables('storageDnsZoneName'), format('storage-{0}-link', parameters('suffix')))]", - "[resourceId('Microsoft.Network/privateDnsZones', variables('storageDnsZoneName'))]", + "[resourceId('Microsoft.Resources/deployments', format('dns-{0}-{1}', replace(variables('dnsZoneEntries')[indexOf(variables('dnsZoneKeys'), variables('storageDnsZoneName'))].key, '.', '-'), parameters('suffix')))]", "[resourceId('Microsoft.Network/privateEndpoints', format('{0}-private-endpoint', parameters('storageName')))]" ] }, @@ -2215,15 +2060,14 @@ { "name": "[format('{0}-dns-config', parameters('cosmosDBName'))]", "properties": { - "privateDnsZoneId": "[variables('cosmosDBDnsZoneId')]" + "privateDnsZoneId": "[reference(resourceId('Microsoft.Resources/deployments', format('dns-{0}-{1}', replace(variables('dnsZoneEntries')[indexOf(variables('dnsZoneKeys'), variables('cosmosDBDnsZoneName'))].key, '.', '-'), parameters('suffix'))), '2025-04-01').outputs.zoneId.value]" } } ] }, "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones/virtualNetworkLinks', variables('cosmosDBDnsZoneName'), format('cosmosDB-{0}-link', parameters('suffix')))]", - "[resourceId('Microsoft.Network/privateDnsZones', variables('cosmosDBDnsZoneName'))]", - "[resourceId('Microsoft.Network/privateEndpoints', format('{0}-private-endpoint', parameters('cosmosDBName')))]" + "[resourceId('Microsoft.Network/privateEndpoints', format('{0}-private-endpoint', parameters('cosmosDBName')))]", + "[resourceId('Microsoft.Resources/deployments', format('dns-{0}-{1}', replace(variables('dnsZoneEntries')[indexOf(variables('dnsZoneKeys'), variables('cosmosDBDnsZoneName'))].key, '.', '-'), parameters('suffix')))]" ] }, { @@ -2236,16 +2080,140 @@ { "name": "[format('{0}-dns-config', variables('fabricWorkspaceName'))]", "properties": { - "privateDnsZoneId": "[variables('fabricDnsZoneId')]" + "privateDnsZoneId": "[if(variables('fabricPassedIn'), reference(resourceId('Microsoft.Resources/deployments', format('dns-{0}-{1}', replace(variables('dnsZoneEntries')[indexOf(variables('dnsZoneKeys'), variables('fabricDnsZoneName'))].key, '.', '-'), parameters('suffix'))), '2025-04-01').outputs.zoneId.value, '')]" } } ] }, "dependsOn": [ - "[resourceId('Microsoft.Network/privateDnsZones/virtualNetworkLinks', variables('fabricDnsZoneName'), format('fabric-{0}-link', parameters('suffix')))]", - "[resourceId('Microsoft.Network/privateDnsZones', variables('fabricDnsZoneName'))]", + "[resourceId('Microsoft.Resources/deployments', format('dns-{0}-{1}', replace(variables('dnsZoneEntries')[indexOf(variables('dnsZoneKeys'), variables('fabricDnsZoneName'))].key, '.', '-'), parameters('suffix')))]", "[resourceId('Microsoft.Network/privateEndpoints', format('{0}-fabric-private-endpoint', variables('fabricWorkspaceName')))]" ] + }, + { + "copy": { + "name": "dnsZones", + "count": "[length(variables('dnsZoneEntries'))]" + }, + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "[format('dns-{0}-{1}', replace(variables('dnsZoneEntries')[copyIndex()].key, '.', '-'), parameters('suffix'))]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "zoneName": { + "value": "[variables('dnsZoneEntries')[copyIndex()].key]" + }, + "existingResourceGroup": { + "value": "[coalesce(tryGet(variables('dnsZoneEntries')[copyIndex()].value, 'resourceGroup'), '')]" + }, + "existingSubscriptionId": { + "value": "[coalesce(tryGet(variables('dnsZoneEntries')[copyIndex()].value, 'subscriptionId'), '')]" + }, + "vnetId": { + "value": "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', parameters('vnetSubscriptionId'), parameters('vnetResourceGroupName')), 'Microsoft.Network/virtualNetworks', parameters('vnetName'))]" + }, + "suffix": { + "value": "[parameters('suffix')]" + }, + "enabled": "[if(equals(variables('dnsZoneEntries')[copyIndex()].key, variables('fabricDnsZoneName')), createObject('value', variables('fabricPassedIn')), createObject('value', true()))]" + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.43.8.12551", + "templateHash": "9671566114533960613" + } + }, + "parameters": { + "zoneName": { + "type": "string", + "metadata": { + "description": "Fully-qualified DNS zone name (e.g. privatelink.openai.azure.com)." + } + }, + "existingResourceGroup": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Resource group of an existing zone. Empty means create the zone in this RG." + } + }, + "existingSubscriptionId": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Subscription ID of an existing zone. Empty defaults to the current subscription. Only used when existingResourceGroup is non-empty." + } + }, + "vnetId": { + "type": "string", + "metadata": { + "description": "ARM ID of the VNet to link the zone to." + } + }, + "suffix": { + "type": "string", + "metadata": { + "description": "Suffix used to make the vnet-link name unique." + } + }, + "enabled": { + "type": "bool", + "defaultValue": true, + "metadata": { + "description": "Disable the entire module (zone, link, output). Used to skip optional zones." + } + } + }, + "variables": { + "shouldCreate": "[and(parameters('enabled'), empty(parameters('existingResourceGroup')))]", + "shouldReference": "[and(parameters('enabled'), not(empty(parameters('existingResourceGroup'))))]", + "effectiveExistingSubscriptionId": "[if(empty(parameters('existingSubscriptionId')), subscription().subscriptionId, parameters('existingSubscriptionId'))]" + }, + "resources": [ + { + "condition": "[variables('shouldCreate')]", + "type": "Microsoft.Network/privateDnsZones", + "apiVersion": "2020-06-01", + "name": "[parameters('zoneName')]", + "location": "global" + }, + { + "condition": "[variables('shouldCreate')]", + "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", + "apiVersion": "2024-06-01", + "name": "[format('{0}/{1}', parameters('zoneName'), format('{0}-{1}-link', replace(parameters('zoneName'), '.', '-'), parameters('suffix')))]", + "location": "global", + "properties": { + "virtualNetwork": { + "id": "[parameters('vnetId')]" + }, + "registrationEnabled": false + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName'))]" + ] + } + ], + "outputs": { + "zoneId": { + "type": "string", + "value": "[if(parameters('enabled'), if(variables('shouldCreate'), resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName')), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('effectiveExistingSubscriptionId'), parameters('existingResourceGroup')), 'Microsoft.Network/privateDnsZones', parameters('zoneName'))), '')]" + }, + "zoneName": { + "type": "string", + "value": "[parameters('zoneName')]" + } + } + } + } } ] } @@ -2315,8 +2283,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "17621807749606533298" + "version": "0.43.8.12551", + "templateHash": "5316230502381000043" } }, "parameters": { @@ -2490,8 +2458,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "12210490978743733890" + "version": "0.43.8.12551", + "templateHash": "16292565234555392040" } }, "parameters": { @@ -2523,7 +2491,7 @@ { "type": "Microsoft.Resources/deployments", "apiVersion": "2025-04-01", - "name": "[format('storage-{0}-{1}-deployment', variables('azureStorageName'), variables('uniqueSuffix'))]", + "name": "[format('storage-ra-{0}-deployment', variables('uniqueSuffix'))]", "subscriptionId": "[variables('azureStorageSubscriptionId')]", "resourceGroup": "[variables('azureStorageResourceGroupName')]", "properties": { @@ -2545,8 +2513,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "10088093552813477461" + "version": "0.43.8.12551", + "templateHash": "10985949897065090550" } }, "parameters": { @@ -2603,8 +2571,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "1708673367057519655" + "version": "0.43.8.12551", + "templateHash": "17331576029113373855" } }, "parameters": { @@ -2667,8 +2635,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "18126535000597477931" + "version": "0.43.8.12551", + "templateHash": "15580039133385622424" } }, "parameters": { @@ -2752,8 +2720,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "12585440226619351603" + "version": "0.43.8.12551", + "templateHash": "9752585897450875181" } }, "parameters": { @@ -2810,12 +2778,11 @@ }, "dependsOn": [ "[resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('accountName'), variables('uniqueSuffix')))]", - "[resourceId('Microsoft.Resources/deployments', format('dependencies-{0}-deployment', variables('uniqueSuffix')))]", "[resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('projectName'), variables('uniqueSuffix')))]", "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('aiSearchServiceSubscriptionId'), variables('aiSearchServiceResourceGroupName')), 'Microsoft.Resources/deployments', format('ai-search-ra-{0}-deployment', variables('uniqueSuffix')))]", "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('cosmosDBSubscriptionId'), variables('cosmosDBResourceGroupName')), 'Microsoft.Resources/deployments', format('cosmos-account-ra-{0}-deployment', variables('uniqueSuffix')))]", "[resourceId('Microsoft.Resources/deployments', format('{0}-private-endpoint', variables('uniqueSuffix')))]", - "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('azureStorageSubscriptionId'), variables('azureStorageResourceGroupName')), 'Microsoft.Resources/deployments', format('storage-{0}-{1}-deployment', variables('azureStorageName'), variables('uniqueSuffix')))]" + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('azureStorageSubscriptionId'), variables('azureStorageResourceGroupName')), 'Microsoft.Resources/deployments', format('storage-ra-{0}-deployment', variables('uniqueSuffix')))]" ] }, { @@ -2846,8 +2813,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "6504031662567695076" + "version": "0.43.8.12551", + "templateHash": "13556924300142468207" } }, "parameters": { @@ -2894,7 +2861,8 @@ "[resourceId('Microsoft.Resources/deployments', format('capabilityHost-configuration-{0}-deployment', variables('uniqueSuffix')))]", "[resourceId('Microsoft.Resources/deployments', format('dependencies-{0}-deployment', variables('uniqueSuffix')))]", "[resourceId('Microsoft.Resources/deployments', format('{0}-{1}-deployment', variables('projectName'), variables('uniqueSuffix')))]", - "[resourceId('Microsoft.Resources/deployments', format('format-project-workspace-id-{0}-deployment', variables('uniqueSuffix')))]" + "[resourceId('Microsoft.Resources/deployments', format('format-project-workspace-id-{0}-deployment', variables('uniqueSuffix')))]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('azureStorageSubscriptionId'), variables('azureStorageResourceGroupName')), 'Microsoft.Resources/deployments', format('storage-ra-{0}-deployment', variables('uniqueSuffix')))]" ] }, { @@ -2925,8 +2893,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "10995262424499479562" + "version": "0.43.8.12551", + "templateHash": "12588788614140985947" } }, "parameters": { diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/azuredeploy.parameters.json b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/azuredeploy.parameters.json similarity index 50% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/azuredeploy.parameters.json rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/azuredeploy.parameters.json index f3621743e..0a2f1c70f 100644 --- a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/azuredeploy.parameters.json +++ b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/azuredeploy.parameters.json @@ -56,13 +56,31 @@ "peSubnetPrefix": { "value": "" }, - "aiSearchResourceId": { + "existingAiSearchResourceId": { "value": "" }, - "azureStorageAccountResourceId": { + "existingAzureStorageAccountResourceId": { "value": "" }, - "azureCosmosDBAccountResourceId": { + "existingAzureCosmosDBAccountResourceId": { + "value": "" + }, + "existingFabricWorkspaceResourceId": { + "value": "" + }, + "existingPeSubnetResourceId": { + "value": "" + }, + "existingAgentSubnetResourceId": { + "value": "" + }, + "existingMcpSubnetResourceId": { + "value": "" + }, + "mcpSubnetName": { + "value": "" + }, + "mcpSubnetPrefix": { "value": "" }, "projectCapHost": { @@ -70,12 +88,13 @@ }, "existingDnsZones": { "value": { - "privatelink.services.ai.azure.com": "", - "privatelink.openai.azure.com": "", - "privatelink.cognitiveservices.azure.com": "", - "privatelink.search.windows.net": "", - "privatelink.blob.core.windows.net": "", - "privatelink.documents.azure.com": "" + "privatelink.services.ai.azure.com": { "subscriptionId": "", "resourceGroup": "" }, + "privatelink.openai.azure.com": { "subscriptionId": "", "resourceGroup": "" }, + "privatelink.cognitiveservices.azure.com": { "subscriptionId": "", "resourceGroup": "" }, + "privatelink.search.windows.net": { "subscriptionId": "", "resourceGroup": "" }, + "privatelink.blob.core.windows.net": { "subscriptionId": "", "resourceGroup": "" }, + "privatelink.documents.azure.com": { "subscriptionId": "", "resourceGroup": "" }, + "privatelink.fabric.microsoft.com": { "subscriptionId": "", "resourceGroup": "" } } } } diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/createCapHost.sh b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/createCapHost.sh similarity index 100% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/createCapHost.sh rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/createCapHost.sh diff --git a/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/createUiDefinition.json b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/createUiDefinition.json new file mode 100644 index 000000000..66bdc510f --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/createUiDefinition.json @@ -0,0 +1,288 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/0.1.2-preview/CreateUIDefinition.MultiVm.json#", + "handler": "Microsoft.Azure.CreateUIDef", + "version": "0.1.2-preview", + "parameters": { + "config": { + "isWizard": true, + "basics": { + "description": "Deploy a network-isolated Microsoft Foundry account with private endpoints for AI Search, Cosmos DB, and Storage, plus a delegated subnet for tools (MCP, OpenAPI, Functions, A2A) running on a VNet.", + "location": { + "label": "Region", + "allowedValues": [ + "westus", "westus2", "westus3", "eastus", "eastus2", "southcentralus", "canadaeast", + "westeurope", "uksouth", "swedencentral", "francecentral", "germanywestcentral", "italynorth", "spaincentral", "norwayeast", "polandcentral", "switzerlandnorth", + "japaneast", "koreacentral", "australiaeast", "southindia", + "uaenorth", "southafricanorth", "brazilsouth" + ] + } + } + }, + "basics": [], + "steps": [ + { + "name": "foundry", + "label": "Foundry account & project", + "elements": [ + { + "name": "aiServices", + "type": "Microsoft.Common.TextBox", + "label": "AI Services name prefix", + "defaultValue": "aifoundry", + "toolTip": "2-40 lowercase letters or digits. A 4-character random suffix is appended to make the resource name unique.", + "constraints": { + "required": true, + "regex": "^[a-z0-9]{2,40}$", + "validationMessage": "2-40 lowercase letters or digits." + } + }, + { + "name": "firstProjectName", + "type": "Microsoft.Common.TextBox", + "label": "Project name prefix", + "defaultValue": "project", + "constraints": { + "required": true, + "regex": "^[a-z0-9]{2,40}$", + "validationMessage": "2-40 lowercase letters or digits." + } + }, + { + "name": "displayName", + "type": "Microsoft.Common.TextBox", + "label": "Project display name", + "defaultValue": "network secured agent project" + }, + { + "name": "projectDescription", + "type": "Microsoft.Common.TextBox", + "label": "Project description", + "defaultValue": "A project for the AI Foundry account with network secured deployed Agent", + "multiLine": true + } + ] + }, + { + "name": "model", + "label": "Model deployment", + "elements": [ + { + "name": "modelName", + "type": "Microsoft.Common.DropDown", + "label": "Model", + "defaultValue": "gpt-4o-mini", + "constraints": { + "required": true, + "allowedValues": [ + { "label": "gpt-4o-mini", "value": "gpt-4o-mini" }, + { "label": "gpt-4o", "value": "gpt-4o" }, + { "label": "gpt-4", "value": "gpt-4" } + ] + } + }, + { + "name": "modelVersion", + "type": "Microsoft.Common.TextBox", + "label": "Model version", + "defaultValue": "2024-07-18", + "toolTip": "API version of the selected model." + }, + { + "name": "modelSkuName", + "type": "Microsoft.Common.DropDown", + "label": "Deployment SKU", + "defaultValue": "GlobalStandard", + "constraints": { + "allowedValues": [ + { "label": "GlobalStandard", "value": "GlobalStandard" }, + { "label": "Standard", "value": "Standard" } + ] + } + }, + { + "name": "modelCapacity", + "type": "Microsoft.Common.Slider", + "label": "Capacity (TPM x 1000)", + "min": 1, + "max": 300, + "defaultValue": 30, + "showStepMarkers": false + } + ] + }, + { + "name": "network", + "label": "Networking", + "elements": [ + { + "name": "vnetMode", + "type": "Microsoft.Common.OptionsGroup", + "label": "Virtual Network", + "defaultValue": "Create new", + "toolTip": "Create a brand-new VNet, or use an existing one (recommended for shared/enterprise environments).", + "constraints": { + "required": true, + "allowedValues": [ + { "label": "Create new", "value": "new" }, + { "label": "Use existing", "value": "existing" } + ] + } + }, + + { + "name": "newVnetName", + "type": "Microsoft.Common.TextBox", + "label": "VNet name", + "defaultValue": "agent-vnet", + "visible": "[equals(steps('network').vnetMode, 'new')]", + "constraints": { "required": true } + }, + { + "name": "newVnetAddressPrefix", + "type": "Microsoft.Common.TextBox", + "label": "VNet address space", + "defaultValue": "192.168.0.0/16", + "visible": "[equals(steps('network').vnetMode, 'new')]", + "constraints": { + "required": true, + "regex": "^([0-9]{1,3}\\.){3}[0-9]{1,3}/[0-9]{1,2}$", + "validationMessage": "Must be a valid CIDR (e.g. 192.168.0.0/16)." + } + }, + { + "name": "newAgentSubnetPrefix", + "type": "Microsoft.Common.TextBox", + "label": "Agent subnet prefix", + "defaultValue": "192.168.0.0/24", + "visible": "[equals(steps('network').vnetMode, 'new')]" + }, + { + "name": "newPeSubnetPrefix", + "type": "Microsoft.Common.TextBox", + "label": "Private-endpoint subnet prefix", + "defaultValue": "192.168.1.0/24", + "visible": "[equals(steps('network').vnetMode, 'new')]" + }, + { + "name": "newMcpSubnetPrefix", + "type": "Microsoft.Common.TextBox", + "label": "MCP subnet prefix", + "defaultValue": "192.168.2.0/24", + "visible": "[equals(steps('network').vnetMode, 'new')]" + }, + + { + "name": "existingVnet", + "type": "Microsoft.Network.VirtualNetworkCombo", + "label": { + "virtualNetwork": "Existing VNet", + "subnets": "Subnets" + }, + "visible": "[equals(steps('network').vnetMode, 'existing')]", + "scope": { + "subscriptionId": "[subscription().subscriptionId]" + }, + "subnets": { + "agentSubnet": { + "label": "Agent subnet", + "constraints": { "minAddressCount": 32, "requireContiguousAddresses": true } + }, + "peSubnet": { + "label": "Private-endpoint subnet", + "constraints": { "minAddressCount": 32, "requireContiguousAddresses": true } + }, + "mcpSubnet": { + "label": "MCP subnet", + "constraints": { "minAddressCount": 32, "requireContiguousAddresses": true } + } + } + }, + { + "name": "existingSubnetReuseInfo", + "type": "Microsoft.Common.InfoBox", + "visible": "[equals(steps('network').vnetMode, 'existing')]", + "options": { + "icon": "Info", + "text": "When using an existing VNet, the subnets you select above will be referenced as-is. Their existing NSGs, route tables, and delegations are preserved." + } + } + ] + }, + { + "name": "byo", + "label": "Bring-your-own backing resources", + "elements": [ + { + "name": "byoInfo", + "type": "Microsoft.Common.InfoBox", + "options": { + "icon": "Info", + "text": "Optional. Leave any of these unselected to have the template create a brand-new resource for you." + } + }, + { + "name": "aiSearch", + "type": "Microsoft.Solutions.ResourceSelector", + "label": "Existing AI Search service (optional)", + "resourceType": "Microsoft.Search/searchServices", + "options": { + "filter": { "subscription": "onePerLine" } + } + }, + { + "name": "cosmos", + "type": "Microsoft.Solutions.ResourceSelector", + "label": "Existing Cosmos DB account (optional)", + "resourceType": "Microsoft.DocumentDB/databaseAccounts" + }, + { + "name": "storage", + "type": "Microsoft.Solutions.ResourceSelector", + "label": "Existing Storage account (optional)", + "resourceType": "Microsoft.Storage/storageAccounts" + }, + { + "name": "fabric", + "type": "Microsoft.Common.TextBox", + "label": "Microsoft Fabric workspace ARM ID (optional)", + "toolTip": "Full ARM resource ID of an existing Fabric workspace. Leave empty to skip Fabric private link.", + "constraints": { + "regex": "^$|^/subscriptions/.+/resourceGroups/.+/providers/Microsoft.Fabric/.+$", + "validationMessage": "Must be empty or a valid Microsoft.Fabric ARM resource ID." + } + } + ] + } + ], + "outputs": { + "location": "[location()]", + + "aiServices": "[steps('foundry').aiServices]", + "firstProjectName": "[steps('foundry').firstProjectName]", + "displayName": "[steps('foundry').displayName]", + "projectDescription": "[steps('foundry').projectDescription]", + + "modelName": "[steps('model').modelName]", + "modelFormat": "OpenAI", + "modelVersion": "[steps('model').modelVersion]", + "modelSkuName": "[steps('model').modelSkuName]", + "modelCapacity": "[steps('model').modelCapacity]", + + "vnetName": "[if(equals(steps('network').vnetMode, 'new'), steps('network').newVnetName, 'agent-vnet')]", + "vnetAddressPrefix": "[if(equals(steps('network').vnetMode, 'new'), steps('network').newVnetAddressPrefix, '')]", + "agentSubnetPrefix": "[if(equals(steps('network').vnetMode, 'new'), steps('network').newAgentSubnetPrefix, '')]", + "peSubnetPrefix": "[if(equals(steps('network').vnetMode, 'new'), steps('network').newPeSubnetPrefix, '')]", + "mcpSubnetPrefix": "[if(equals(steps('network').vnetMode, 'new'), steps('network').newMcpSubnetPrefix, '')]", + + "existingVnetResourceId": "[if(equals(steps('network').vnetMode, 'existing'), steps('network').existingVnet.virtualNetwork.id, '')]", + "existingAgentSubnetResourceId": "[if(equals(steps('network').vnetMode, 'existing'), steps('network').existingVnet.subnets.agentSubnet.id, '')]", + "existingPeSubnetResourceId": "[if(equals(steps('network').vnetMode, 'existing'), steps('network').existingVnet.subnets.peSubnet.id, '')]", + "existingMcpSubnetResourceId": "[if(equals(steps('network').vnetMode, 'existing'), steps('network').existingVnet.subnets.mcpSubnet.id, '')]", + + "existingAiSearchResourceId": "[coalesce(steps('byo').aiSearch.id, '')]", + "existingAzureCosmosDBAccountResourceId": "[coalesce(steps('byo').cosmos.id, '')]", + "existingAzureStorageAccountResourceId": "[coalesce(steps('byo').storage.id, '')]", + "existingFabricWorkspaceResourceId": "[steps('byo').fabric]" + } + } +} diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/deleteCapHost.sh b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/deleteCapHost.sh similarity index 100% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/deleteCapHost.sh rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/deleteCapHost.sh diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/diagrams/deployment_flow.png b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/diagrams/deployment_flow.png similarity index 100% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/diagrams/deployment_flow.png rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/diagrams/deployment_flow.png diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/diagrams/generate_diagrams.py b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/diagrams/generate_diagrams.py similarity index 100% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/diagrams/generate_diagrams.py rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/diagrams/generate_diagrams.py diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/diagrams/layer1_network_foundation.png b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/diagrams/layer1_network_foundation.png similarity index 100% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/diagrams/layer1_network_foundation.png rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/diagrams/layer1_network_foundation.png diff --git a/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/diagrams/layer2_data_resources.png b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/diagrams/layer2_data_resources.png new file mode 100644 index 000000000..2f8a80cac Binary files /dev/null and b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/diagrams/layer2_data_resources.png differ diff --git a/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/diagrams/layer3_ai_services.png b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/diagrams/layer3_ai_services.png new file mode 100644 index 000000000..498c159b2 Binary files /dev/null and b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/diagrams/layer3_ai_services.png differ diff --git a/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/diagrams/layer4_project_connections.png b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/diagrams/layer4_project_connections.png new file mode 100644 index 000000000..82e57f9a1 Binary files /dev/null and b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/diagrams/layer4_project_connections.png differ diff --git a/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/diagrams/layer5_capability_host.png b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/diagrams/layer5_capability_host.png new file mode 100644 index 000000000..b8c040870 Binary files /dev/null and b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/diagrams/layer5_capability_host.png differ diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/get-existing-resources.ps1 b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/get-existing-resources.ps1 similarity index 100% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/get-existing-resources.ps1 rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/get-existing-resources.ps1 diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/main.bicep b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/main.bicep similarity index 68% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/main.bicep rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/main.bicep index 11d4c2a06..2ad04c322 100644 --- a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/main.bicep +++ b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/main.bicep @@ -47,8 +47,10 @@ Architecture: ]) param location string = 'eastus2' -@description('Name for your AI Services resource.') -param aiServices string = 'aiservices' +@description('Name prefix for your AI Services (Cognitive Services) resource. Lowercase alphanumeric only; a 4-character random suffix will be appended.') +@minLength(2) +@maxLength(40) +param aiServices string = 'aifoundry' // Model deployment parameters @description('The name of the model you want to deploy') @@ -67,7 +69,9 @@ param deploymentTimestamp string = utcNow('yyyyMMddHHmmss') var uniqueSuffix = substring(uniqueString('${resourceGroup().id}-${deploymentTimestamp}'), 0, 4) var accountName = toLower('${aiServices}${uniqueSuffix}') -@description('Name for your project resource.') +@description('Name prefix for your Foundry project. A 4-character random suffix will be appended.') +@minLength(2) +@maxLength(40) param firstProjectName string = 'project' @description('This project will be a sub-resource of your account') @@ -77,16 +81,16 @@ param projectDescription string = 'A project for the AI Foundry account with net param displayName string = 'network secured agent project' // Existing Virtual Network parameters -@description('Virtual Network name for the Agent to create new or existing virtual network') -param vnetName string = 'agent-vnet-test' +@description('Virtual Network name. When `existingVnetResourceId` is set, this is ignored β€” the name is derived from the resource ID.') +param vnetName string = 'agent-vnet' -@description('The name of Agents Subnet to create new or existing subnet for agents') +@description('Agent subnet name. Ignored when `existingAgentSubnetResourceId` is set.') param agentSubnetName string = 'agent-subnet' -@description('The name of Private Endpoint subnet to create new or existing subnet for private endpoints') +@description('Private endpoint subnet name. Ignored when `existingPeSubnetResourceId` is set.') param peSubnetName string = 'pe-subnet' -@description('The name of MCP subnet for user-deployed Container Apps (e.g., MCP servers)') +@description('MCP subnet name (hosts user-deployed Container Apps such as MCP servers). Ignored when `existingMcpSubnetResourceId` is set.') param mcpSubnetName string = 'mcp-subnet' //Existing standard Agent required resources @@ -105,62 +109,69 @@ param peSubnetPrefix string = '' @description('Address prefix for the MCP subnet. The default value is 192.168.2.0/24.') param mcpSubnetPrefix string = '' -@description('The AI Search Service full ARM Resource ID. This is an optional field, and if not provided, the resource will be created.') -param aiSearchResourceId string = '' -@description('The AI Storage Account full ARM Resource ID. This is an optional field, and if not provided, the resource will be created.') -param azureStorageAccountResourceId string = '' -@description('The Cosmos DB Account full ARM Resource ID. This is an optional field, and if not provided, the resource will be created.') -param azureCosmosDBAccountResourceId string = '' +@description('Optional ARM Resource ID of an existing agent subnet. If provided, the subnet will be referenced as-is and will not be created/modified.') +param existingAgentSubnetResourceId string = '' -@description('The Microsoft Fabric Workspace full ARM Resource ID. This is an optional field for Fabric private link connectivity.') -param fabricWorkspaceResourceId string = '' +@description('Optional ARM Resource ID of an existing private endpoint subnet. If provided, the subnet will be referenced as-is and will not be created/modified.') +param existingPeSubnetResourceId string = '' + +@description('Optional ARM Resource ID of an existing MCP subnet. If provided, the subnet will be referenced as-is and will not be created/modified.') +param existingMcpSubnetResourceId string = '' + +@description('The AI Search Service full ARM Resource ID. Optional β€” leave empty to create a new one.') +param existingAiSearchResourceId string = '' +@description('The AI Storage Account full ARM Resource ID. Optional β€” leave empty to create a new one.') +param existingAzureStorageAccountResourceId string = '' +@description('The Cosmos DB Account full ARM Resource ID. Optional β€” leave empty to create a new one.') +param existingAzureCosmosDBAccountResourceId string = '' + +@description('The Microsoft Fabric Workspace full ARM Resource ID. Optional β€” enables Fabric private link connectivity.') +param existingFabricWorkspaceResourceId string = '' //New Param for resource group of Private DNS zones //@description('Optional: Resource group containing existing private DNS zones. If specified, DNS zones will not be created.') //param existingDnsZonesResourceGroup string = '' -@description('Object mapping DNS zone names to their resource group, or empty string to indicate creation') +@description('Map of private DNS zone FQDNs to an object `{ subscriptionId, resourceGroup }` describing where the zone lives. Empty `resourceGroup` means "create the zone in this deployment\'s resource group". A non-empty `resourceGroup` references an existing zone in that RG; empty `subscriptionId` defaults to the current subscription, otherwise the zone is referenced cross-subscription. Note: when referencing an existing zone, the VNet link to that zone is NOT managed by this template β€” the caller must ensure the zone is already linked to the target VNet.') param existingDnsZones object = { - 'privatelink.services.ai.azure.com': '' - 'privatelink.openai.azure.com': '' - 'privatelink.cognitiveservices.azure.com': '' - 'privatelink.search.windows.net': '' - 'privatelink.blob.core.windows.net': '' - 'privatelink.documents.azure.com': '' - 'privatelink.analysis.windows.net': '' + 'privatelink.services.ai.azure.com': { subscriptionId: '', resourceGroup: '' } + 'privatelink.openai.azure.com': { subscriptionId: '', resourceGroup: '' } + 'privatelink.cognitiveservices.azure.com': { subscriptionId: '', resourceGroup: '' } + 'privatelink.search.windows.net': { subscriptionId: '', resourceGroup: '' } + 'privatelink.blob.${environment().suffixes.storage}': { subscriptionId: '', resourceGroup: '' } + 'privatelink.documents.azure.com': { subscriptionId: '', resourceGroup: '' } + 'privatelink.fabric.microsoft.com': { subscriptionId: '', resourceGroup: '' } } -@description('Zone Names for Validation of existing Private Dns Zones') -param dnsZoneNames array = [ - 'privatelink.services.ai.azure.com' - 'privatelink.openai.azure.com' - 'privatelink.cognitiveservices.azure.com' - 'privatelink.search.windows.net' - 'privatelink.blob.core.windows.net' - 'privatelink.documents.azure.com' - 'privatelink.analysis.windows.net' -] - var projectName = toLower('${firstProjectName}${uniqueSuffix}') var cosmosDBName = toLower('${aiServices}${uniqueSuffix}cosmosdb') var aiSearchName = toLower('${aiServices}${uniqueSuffix}search') var azureStorageName = toLower('${aiServices}${uniqueSuffix}storage') // Check if existing resources have been passed in -var storagePassedIn = azureStorageAccountResourceId != '' -var searchPassedIn = aiSearchResourceId != '' -var cosmosPassedIn = azureCosmosDBAccountResourceId != '' +var storagePassedIn = existingAzureStorageAccountResourceId != '' +var searchPassedIn = existingAiSearchResourceId != '' +var cosmosPassedIn = existingAzureCosmosDBAccountResourceId != '' var existingVnetPassedIn = existingVnetResourceId != '' -var acsParts = split(aiSearchResourceId, '/') +// Existing-subnet flags. When a subnet ARM ID is provided we derive the subnet name +// from the ID itself so we look up the right subnet (instead of trusting the *SubnetName param). +var agentSubnetExists = existingAgentSubnetResourceId != '' +var peSubnetExists = existingPeSubnetResourceId != '' +var mcpSubnetExists = existingMcpSubnetResourceId != '' +var effectiveAgentSubnetName = agentSubnetExists ? last(split(existingAgentSubnetResourceId, '/')) : agentSubnetName +var effectivePeSubnetName = peSubnetExists ? last(split(existingPeSubnetResourceId, '/')) : peSubnetName +var effectiveMcpSubnetName = mcpSubnetExists ? last(split(existingMcpSubnetResourceId, '/')) : mcpSubnetName + +var acsParts = split(existingAiSearchResourceId, '/') var aiSearchServiceSubscriptionId = searchPassedIn ? acsParts[2] : subscription().subscriptionId var aiSearchServiceResourceGroupName = searchPassedIn ? acsParts[4] : resourceGroup().name -var cosmosParts = split(azureCosmosDBAccountResourceId, '/') +var cosmosParts = split(existingAzureCosmosDBAccountResourceId, '/') var cosmosDBSubscriptionId = cosmosPassedIn ? cosmosParts[2] : subscription().subscriptionId var cosmosDBResourceGroupName = cosmosPassedIn ? cosmosParts[4] : resourceGroup().name -var storageParts = split(azureStorageAccountResourceId, '/') +var storageParts = split(existingAzureStorageAccountResourceId, '/') var azureStorageSubscriptionId = storagePassedIn ? storageParts[2] : subscription().subscriptionId var azureStorageResourceGroupName = storagePassedIn ? storageParts[4] : resourceGroup().name @@ -181,14 +192,17 @@ module vnet 'modules-network-secured/network-agent-vnet.bicep' = { vnetName: trimVnetName useExistingVnet: existingVnetPassedIn existingVnetResourceGroupName: vnetResourceGroupName - agentSubnetName: agentSubnetName - peSubnetName: peSubnetName - mcpSubnetName: mcpSubnetName + agentSubnetName: effectiveAgentSubnetName + peSubnetName: effectivePeSubnetName + mcpSubnetName: effectiveMcpSubnetName vnetAddressPrefix: vnetAddressPrefix agentSubnetPrefix: agentSubnetPrefix peSubnetPrefix: peSubnetPrefix mcpSubnetPrefix: mcpSubnetPrefix existingVnetSubscriptionId: vnetSubscriptionId + agentSubnetExists: agentSubnetExists + peSubnetExists: peSubnetExists + mcpSubnetExists: mcpSubnetExists } } @@ -210,20 +224,14 @@ module aiAccount 'modules-network-secured/ai-account-identity.bicep' = { } } /* - Validate existing resources - This module will check if the AI Search Service, Storage Account, and Cosmos DB Account already exist. - If they do, it will set the corresponding output to true. If they do not exist, it will set the output to false. + Inline existence checks (replaces the previous validate-existing-resources.bicep module, + which was tautological: it set `*Exists = passedIn && (resource.name == parts[8])` where + `parts[8]` was the very same string used to reference the resource by name). + An empty resource ID means "create new". */ -module validateExistingResources 'modules-network-secured/validate-existing-resources.bicep' = { - name: 'validate-existing-resources-${uniqueSuffix}-deployment' - params: { - aiSearchResourceId: aiSearchResourceId - azureStorageAccountResourceId: azureStorageAccountResourceId - azureCosmosDBAccountResourceId: azureCosmosDBAccountResourceId - existingDnsZones: existingDnsZones - dnsZoneNames: dnsZoneNames - } -} +var aiSearchExists = existingAiSearchResourceId != '' +var azureStorageExists = existingAzureStorageAccountResourceId != '' +var cosmosDBExists = existingAzureCosmosDBAccountResourceId != '' // This module will create new agent dependent resources // A Cosmos DB account, an AI Search Service, and a Storage Account are created if they do not already exist @@ -236,36 +244,23 @@ module aiDependencies 'modules-network-secured/standard-dependent-resources.bice cosmosDBName: cosmosDBName // AI Search Service parameters - aiSearchResourceId: aiSearchResourceId - aiSearchExists: validateExistingResources.outputs.aiSearchExists + existingAiSearchResourceId: existingAiSearchResourceId + aiSearchExists: aiSearchExists // Storage Account - azureStorageAccountResourceId: azureStorageAccountResourceId - azureStorageExists: validateExistingResources.outputs.azureStorageExists + existingAzureStorageAccountResourceId: existingAzureStorageAccountResourceId + azureStorageExists: azureStorageExists // Cosmos DB Account - cosmosDBResourceId: azureCosmosDBAccountResourceId - cosmosDBExists: validateExistingResources.outputs.cosmosDBExists + existingCosmosDBResourceId: existingAzureCosmosDBAccountResourceId + cosmosDBExists: cosmosDBExists } } -resource storage 'Microsoft.Storage/storageAccounts@2022-05-01' existing = { - name: aiDependencies.outputs.azureStorageName - scope: resourceGroup(azureStorageSubscriptionId, azureStorageResourceGroupName) -} - -resource aiSearch 'Microsoft.Search/searchServices@2023-11-01' existing = { - name: aiDependencies.outputs.aiSearchName - scope: resourceGroup( - aiDependencies.outputs.aiSearchServiceSubscriptionId, - aiDependencies.outputs.aiSearchServiceResourceGroupName - ) -} - -resource cosmosDB 'Microsoft.DocumentDB/databaseAccounts@2024-11-15' existing = { - name: aiDependencies.outputs.cosmosDBName - scope: resourceGroup(cosmosDBSubscriptionId, cosmosDBResourceGroupName) -} +// Note: previously this file declared `existing` references to storage / aiSearch / cosmosDB +// solely to use them in module `dependsOn` blocks. That pattern is a no-op (dependsOn on +// `existing` resources is silently ignored), so they were removed. The real dependency on +// these resources flows implicitly through `aiDependencies.outputs.*` references in params. // Private Endpoint and DNS Configuration // This module sets up private network access for all Azure services: @@ -280,7 +275,7 @@ module privateEndpointAndDNS 'modules-network-secured/private-endpoint-and-dns.b aiSearchName: aiDependencies.outputs.aiSearchName // AI Search to secure storageName: aiDependencies.outputs.azureStorageName // Storage to secure cosmosDBName: aiDependencies.outputs.cosmosDBName - fabricWorkspaceResourceId: fabricWorkspaceResourceId // Microsoft Fabric workspace (optional) + fabricWorkspaceResourceId: existingFabricWorkspaceResourceId // Microsoft Fabric workspace (optional) vnetName: vnet.outputs.virtualNetworkName // VNet containing subnets peSubnetName: vnet.outputs.peSubnetName // Subnet for private endpoints suffix: uniqueSuffix // Unique identifier @@ -294,11 +289,8 @@ module privateEndpointAndDNS 'modules-network-secured/private-endpoint-and-dns.b storageAccountSubscriptionId: azureStorageSubscriptionId // Subscription ID for Storage Account existingDnsZones: existingDnsZones } - dependsOn: [ - aiSearch // Ensure AI Search exists - storage // Ensure Storage exists - cosmosDB // Ensure Cosmos DB exists - ] + // Dependencies on `aiDependencies` and `vnet` are implicit through param references + // (e.g. aiAccount.outputs, aiDependencies.outputs.*, vnet.outputs.*). } /* @@ -329,9 +321,6 @@ module aiProject 'modules-network-secured/ai-project-identity.bicep' = { } dependsOn: [ privateEndpointAndDNS - cosmosDB - aiSearch - storage ] } @@ -346,14 +335,13 @@ module formatProjectWorkspaceId 'modules-network-secured/format-project-workspac Assigns the project SMI the storage blob data contributor role on the storage account */ module storageAccountRoleAssignment 'modules-network-secured/azure-storage-account-role-assignment.bicep' = { - name: 'storage-${azureStorageName}-${uniqueSuffix}-deployment' + name: 'storage-ra-${uniqueSuffix}-deployment' scope: resourceGroup(azureStorageSubscriptionId, azureStorageResourceGroupName) params: { azureStorageName: aiDependencies.outputs.azureStorageName projectPrincipalId: aiProject.outputs.projectPrincipalId } dependsOn: [ - storage privateEndpointAndDNS ] } @@ -367,7 +355,6 @@ module cosmosAccountRoleAssignments 'modules-network-secured/cosmosdb-account-ro projectPrincipalId: aiProject.outputs.projectPrincipalId } dependsOn: [ - cosmosDB privateEndpointAndDNS ] } @@ -381,7 +368,6 @@ module aiSearchRoleAssignments 'modules-network-secured/ai-search-role-assignmen projectPrincipalId: aiProject.outputs.projectPrincipalId } dependsOn: [ - aiSearch privateEndpointAndDNS ] } @@ -398,9 +384,6 @@ module addProjectCapabilityHost 'modules-network-secured/add-project-capability- projectCapHost: projectCapHost } dependsOn: [ - aiSearch // Ensure AI Search exists - storage // Ensure Storage exists - cosmosDB privateEndpointAndDNS cosmosAccountRoleAssignments storageAccountRoleAssignment @@ -419,6 +402,7 @@ module storageContainersRoleAssignment 'modules-network-secured/blob-storage-con } dependsOn: [ addProjectCapabilityHost + storageAccountRoleAssignment ] } diff --git a/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/main.bicepparam b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/main.bicepparam new file mode 100644 index 000000000..baa9300ea --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/main.bicepparam @@ -0,0 +1,81 @@ +using './main.bicep' + +// ----------------------------------------------------------------------------- +// Foundry account & project +// ----------------------------------------------------------------------------- +param location = 'eastus2' +param aiServices = 'contoso' // 2-40 lowercase chars; a 4-char suffix is appended +param firstProjectName = 'project' +param projectDescription = 'A project for the AI Foundry account with network secured deployed Agent' +param displayName = 'project' + +// ----------------------------------------------------------------------------- +// Model deployment +// ----------------------------------------------------------------------------- +param modelName = 'gpt-4o-mini' +param modelFormat = 'OpenAI' +param modelVersion = '2024-07-18' +param modelSkuName = 'GlobalStandard' +param modelCapacity = 1 + +// ----------------------------------------------------------------------------- +// Networking +// +// Two scenarios: +// 1) NEW VNet (default): leave `existingVnetResourceId` empty. The template will +// create a new VNet plus three subnets (agent / pe / mcp). Set `vnetAddressPrefix` +// and the per-subnet prefixes if you want non-default CIDRs. +// 2) EXISTING VNet: set `existingVnetResourceId` to the VNet's full ARM ID. The +// `vnetName` and per-subnet `*Name` params are then ignored β€” names come from the +// ARM IDs. To **reuse** existing subnets without modification, also set the +// matching `existing*SubnetResourceId` params (highly recommended for shared VNets; +// otherwise the template will try to (re)create the subnets and may overwrite NSGs +// or routing). +// ----------------------------------------------------------------------------- +param vnetName = 'agent-vnet' +param agentSubnetName = 'agent-subnet' +param peSubnetName = 'pe-subnet' +param mcpSubnetName = 'mcp-subnet' + +param vnetAddressPrefix = '' // e.g. '192.168.0.0/16' +param agentSubnetPrefix = '' // e.g. '192.168.0.0/24' +param peSubnetPrefix = '' // e.g. '192.168.1.0/24' +param mcpSubnetPrefix = '' // e.g. '192.168.2.0/24' + +param existingVnetResourceId = '' +// param existingVnetResourceId = '/subscriptions//resourceGroups//providers/Microsoft.Network/virtualNetworks/' + +// Optional - reuse existing subnets in the existing VNet (no create / no modify). +// Provide the full ARM ID for each subnet you want to leave untouched. +param existingAgentSubnetResourceId = '' +param existingPeSubnetResourceId = '' +param existingMcpSubnetResourceId = '' +// param existingAgentSubnetResourceId = '/subscriptions//resourceGroups//providers/Microsoft.Network/virtualNetworks//subnets/agent-subnet' + +// ----------------------------------------------------------------------------- +// Bring-your-own backing resources (optional) +// Leave empty to create new ones in the deployment resource group. +// ----------------------------------------------------------------------------- +param existingAiSearchResourceId = '' +param existingAzureStorageAccountResourceId = '' +param existingAzureCosmosDBAccountResourceId = '' +param existingFabricWorkspaceResourceId = '' + +// ----------------------------------------------------------------------------- +// Private DNS zones +// Each value is an object: { subscriptionId: '', resourceGroup: '' }. +// - Both empty => create the zone in THIS deployment RG. +// - resourceGroup only => reference existing zone in that RG, current subscription. +// - both set => reference existing zone in another subscription/RG. +// IMPORTANT: When referencing an existing zone, this template does NOT create the +// VNet link. You must ensure the shared zone is already linked to the target VNet. +// ----------------------------------------------------------------------------- +param existingDnsZones = { + 'privatelink.services.ai.azure.com': { subscriptionId: '', resourceGroup: '' } + 'privatelink.openai.azure.com': { subscriptionId: '', resourceGroup: '' } + 'privatelink.cognitiveservices.azure.com': { subscriptionId: '', resourceGroup: '' } + 'privatelink.search.windows.net': { subscriptionId: '', resourceGroup: '' } + 'privatelink.blob.core.windows.net': { subscriptionId: '', resourceGroup: '' } + 'privatelink.documents.azure.com': { subscriptionId: '', resourceGroup: '' } + 'privatelink.fabric.microsoft.com': { subscriptionId: '', resourceGroup: '' } +} diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/mcp-http-server/Dockerfile b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/mcp-http-server/Dockerfile similarity index 100% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/mcp-http-server/Dockerfile rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/mcp-http-server/Dockerfile diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/metadata.json b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/metadata.json similarity index 95% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/metadata.json rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/metadata.json index a91a5830a..05eda9eb0 100644 --- a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/metadata.json +++ b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/metadata.json @@ -18,7 +18,7 @@ }, "prerequisites": [ "Azure subscription with Owner or Contributor role", - "Azure AI Account Owner role for creating AI Services", + "Foundry Account Owner role for creating AI Services", "Sufficient quota for gpt-4o-mini model deployment" ], "resources_created": [ diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/modules-network-secured/add-project-capability-host.bicep b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/add-project-capability-host.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/modules-network-secured/add-project-capability-host.bicep rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/add-project-capability-host.bicep diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/modules-network-secured/ai-account-identity.bicep b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/ai-account-identity.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/modules-network-secured/ai-account-identity.bicep rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/ai-account-identity.bicep diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/modules-network-secured/ai-project-identity-unique.bicep b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/ai-project-identity-unique.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/modules-network-secured/ai-project-identity-unique.bicep rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/ai-project-identity-unique.bicep diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/modules-network-secured/ai-project-identity.bicep b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/ai-project-identity.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/modules-network-secured/ai-project-identity.bicep rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/ai-project-identity.bicep diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/modules-network-secured/ai-search-role-assignments.bicep b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/ai-search-role-assignments.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/modules-network-secured/ai-search-role-assignments.bicep rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/ai-search-role-assignments.bicep diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/modules-network-secured/azure-storage-account-role-assignment.bicep b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/azure-storage-account-role-assignment.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/modules-network-secured/azure-storage-account-role-assignment.bicep rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/azure-storage-account-role-assignment.bicep diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/modules-network-secured/blob-storage-container-role-assignments-unique.bicep b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/blob-storage-container-role-assignments-unique.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/modules-network-secured/blob-storage-container-role-assignments-unique.bicep rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/blob-storage-container-role-assignments-unique.bicep diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/modules-network-secured/blob-storage-container-role-assignments.bicep b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/blob-storage-container-role-assignments.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/modules-network-secured/blob-storage-container-role-assignments.bicep rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/blob-storage-container-role-assignments.bicep diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/modules-network-secured/cosmos-container-role-assignments.bicep b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/cosmos-container-role-assignments.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/modules-network-secured/cosmos-container-role-assignments.bicep rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/cosmos-container-role-assignments.bicep diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/modules-network-secured/cosmosdb-account-role-assignment.bicep b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/cosmosdb-account-role-assignment.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/modules-network-secured/cosmosdb-account-role-assignment.bicep rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/cosmosdb-account-role-assignment.bicep diff --git a/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/existing-vnet.bicep b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/existing-vnet.bicep new file mode 100644 index 000000000..1b7d4c491 --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/existing-vnet.bicep @@ -0,0 +1,138 @@ +/* +Virtual Network Module +This module works with existing virtual networks and required subnets. + +1. Flexibility: + - Works with any existing VNet address space + - Can use existing subnets or create new ones + - Cross-resource group support + +2. Security Features: + - Network isolation + - Subnet delegation for containerized workloads + - Private endpoint subnet for secure connectivity +*/ + +@description('The name of the existing virtual network') +param vnetName string + +@description('Subscription ID of virtual network (if different from current subscription)') +param vnetSubscriptionId string = subscription().subscriptionId + +@description('Resource Group name of the existing VNet (if different from current resource group)') +param vnetResourceGroupName string = resourceGroup().name + +@description('The name of Agents Subnet') +param agentSubnetName string = 'agent-subnet' + +@description('The name of Private Endpoint subnet') +param peSubnetName string = 'pe-subnet' + +@description('The name of MCP subnet for user-deployed Container Apps') +param mcpSubnetName string = 'mcp-subnet' + +@description('Address prefix for the agent subnet (only needed if creating new subnet)') +param agentSubnetPrefix string = '' + +@description('Address prefix for the private endpoint subnet (only needed if creating new subnet)') +param peSubnetPrefix string = '' + +@description('Address prefix for the MCP subnet (only needed if creating new subnet)') +param mcpSubnetPrefix string = '' + +@description('Set to true to reference the agent subnet as existing (do not create or modify it)') +param agentSubnetExists bool = false + +@description('Set to true to reference the private endpoint subnet as existing (do not create or modify it)') +param peSubnetExists bool = false + +@description('Set to true to reference the MCP subnet as existing (do not create or modify it)') +param mcpSubnetExists bool = false + +// Get the address space (array of CIDR strings) +var vnetAddressSpace = existingVNet.properties.addressSpace.addressPrefixes[0] + +var agentSubnetSpaces = empty(agentSubnetPrefix) ? cidrSubnet(vnetAddressSpace, 24, 0) : agentSubnetPrefix +var peSubnetSpaces = empty(peSubnetPrefix) ? cidrSubnet(vnetAddressSpace, 24, 1) : peSubnetPrefix +var mcpSubnetSpaces = empty(mcpSubnetPrefix) ? cidrSubnet(vnetAddressSpace, 24, 2) : mcpSubnetPrefix + +// Reference the existing virtual network +resource existingVNet 'Microsoft.Network/virtualNetworks@2024-05-01' existing = { + name: vnetName + scope: resourceGroup(vnetResourceGroupName) +} + +// Create (or reference) the agent subnet +// Subnet writes against the same VNet are serialized by ARM, so the three subnet +// modules below are explicitly chained via dependsOn to avoid AnotherOperationInProgress. +module agentSubnet 'subnet.bicep' = { + name: 'agent-subnet-${uniqueString(deployment().name, agentSubnetName)}' + scope: resourceGroup(vnetResourceGroupName) + params: { + vnetName: vnetName + subnetName: agentSubnetName + addressPrefix: agentSubnetSpaces + subnetExists: agentSubnetExists + delegations: [ + { + name: 'Microsoft.App/environments' + properties: { + serviceName: 'Microsoft.App/environments' + } + } + ] + } +} + +// Create (or reference) the private endpoint subnet +module peSubnet 'subnet.bicep' = { + name: 'pe-subnet-${uniqueString(deployment().name, peSubnetName)}' + scope: resourceGroup(vnetResourceGroupName) + params: { + vnetName: vnetName + subnetName: peSubnetName + addressPrefix: peSubnetSpaces + subnetExists: peSubnetExists + delegations: [] + } + dependsOn: [ + agentSubnet + ] +} + +// Create (or reference) the MCP subnet for user-deployed Container Apps +module mcpSubnet 'subnet.bicep' = { + name: 'mcp-subnet-${uniqueString(deployment().name, mcpSubnetName)}' + scope: resourceGroup(vnetResourceGroupName) + params: { + vnetName: vnetName + subnetName: mcpSubnetName + addressPrefix: mcpSubnetSpaces + subnetExists: mcpSubnetExists + delegations: [ + { + name: 'Microsoft.App/environments' + properties: { + serviceName: 'Microsoft.App/environments' + } + } + ] + } + dependsOn: [ + peSubnet + ] +} + +// Output variables +output peSubnetName string = peSubnetName +output agentSubnetName string = agentSubnetName +output mcpSubnetName string = mcpSubnetName +// Use module outputs (not string concatenation) so consumers get an implicit dependency +// on the subnet modules and don't race ahead while subnets are still being written. +output agentSubnetId string = agentSubnet.outputs.subnetId +output peSubnetId string = peSubnet.outputs.subnetId +output mcpSubnetId string = mcpSubnet.outputs.subnetId +output virtualNetworkName string = existingVNet.name +output virtualNetworkId string = existingVNet.id +output virtualNetworkResourceGroup string = vnetResourceGroupName +output virtualNetworkSubscriptionId string = vnetSubscriptionId diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/modules-network-secured/format-project-workspace-id.bicep b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/format-project-workspace-id.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/modules-network-secured/format-project-workspace-id.bicep rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/format-project-workspace-id.bicep diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/modules-network-secured/network-agent-vnet.bicep b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/network-agent-vnet.bicep similarity index 67% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/modules-network-secured/network-agent-vnet.bicep rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/network-agent-vnet.bicep index 7be3fa966..e321cfcb1 100644 --- a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/modules-network-secured/network-agent-vnet.bicep +++ b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/network-agent-vnet.bicep @@ -34,6 +34,15 @@ param peSubnetPrefix string = '' @description('Address prefix for the MCP subnet') param mcpSubnetPrefix string = '' +@description('Set to true to reference the agent subnet as existing (do not create or modify it)') +param agentSubnetExists bool = false + +@description('Set to true to reference the private endpoint subnet as existing (do not create or modify it)') +param peSubnetExists bool = false + +@description('Set to true to reference the MCP subnet as existing (do not create or modify it)') +param mcpSubnetExists bool = false + // Create new VNet if needed module newVNet 'vnet.bicep' = if (!useExistingVnet) { name: 'vnet-deployment' @@ -63,25 +72,28 @@ module existingVNet 'existing-vnet.bicep' = if (useExistingVnet) { agentSubnetPrefix: agentSubnetPrefix peSubnetPrefix: peSubnetPrefix mcpSubnetPrefix: mcpSubnetPrefix + agentSubnetExists: agentSubnetExists + peSubnetExists: peSubnetExists + mcpSubnetExists: mcpSubnetExists } } // Provide unified outputs regardless of which module was used output virtualNetworkName string = useExistingVnet - ? existingVNet.outputs.virtualNetworkName - : newVNet.outputs.virtualNetworkName + ? existingVNet!.outputs.virtualNetworkName + : newVNet!.outputs.virtualNetworkName output virtualNetworkId string = useExistingVnet - ? existingVNet.outputs.virtualNetworkId - : newVNet.outputs.virtualNetworkId + ? existingVNet!.outputs.virtualNetworkId + : newVNet!.outputs.virtualNetworkId output virtualNetworkSubscriptionId string = useExistingVnet - ? existingVNet.outputs.virtualNetworkSubscriptionId - : newVNet.outputs.virtualNetworkSubscriptionId + ? existingVNet!.outputs.virtualNetworkSubscriptionId + : newVNet!.outputs.virtualNetworkSubscriptionId output virtualNetworkResourceGroup string = useExistingVnet - ? existingVNet.outputs.virtualNetworkResourceGroup - : newVNet.outputs.virtualNetworkResourceGroup + ? existingVNet!.outputs.virtualNetworkResourceGroup + : newVNet!.outputs.virtualNetworkResourceGroup output agentSubnetName string = agentSubnetName output peSubnetName string = peSubnetName output mcpSubnetName string = mcpSubnetName -output agentSubnetId string = useExistingVnet ? existingVNet.outputs.agentSubnetId : newVNet.outputs.agentSubnetId -output peSubnetId string = useExistingVnet ? existingVNet.outputs.peSubnetId : newVNet.outputs.peSubnetId -output mcpSubnetId string = useExistingVnet ? existingVNet.outputs.mcpSubnetId : newVNet.outputs.mcpSubnetId +output agentSubnetId string = useExistingVnet ? existingVNet!.outputs.agentSubnetId : newVNet!.outputs.agentSubnetId +output peSubnetId string = useExistingVnet ? existingVNet!.outputs.peSubnetId : newVNet!.outputs.peSubnetId +output mcpSubnetId string = useExistingVnet ? existingVNet!.outputs.mcpSubnetId : newVNet!.outputs.mcpSubnetId diff --git a/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/private-dns-zone.bicep b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/private-dns-zone.bicep new file mode 100644 index 000000000..31dcb9e2c --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/private-dns-zone.bicep @@ -0,0 +1,54 @@ +/* +Private DNS Zone (per-zone) module +Creates a private DNS zone (or references an existing one) and links it to the supplied VNet. +Used by `private-endpoint-and-dns.bicep` in a `for` loop over `existingDnsZones`. + +When `enabled` is false the module is a no-op (used to skip optional zones such as Fabric). +*/ + +@description('Fully-qualified DNS zone name (e.g. privatelink.openai.azure.com).') +param zoneName string + +@description('Resource group of an existing zone. Empty means create the zone in this RG.') +param existingResourceGroup string = '' + +@description('Subscription ID of an existing zone. Empty defaults to the current subscription. Only used when existingResourceGroup is non-empty.') +param existingSubscriptionId string = '' + +@description('ARM ID of the VNet to link the zone to.') +param vnetId string + +@description('Suffix used to make the vnet-link name unique.') +param suffix string + +@description('Disable the entire module (zone, link, output). Used to skip optional zones.') +param enabled bool = true + +var shouldCreate = enabled && empty(existingResourceGroup) +var shouldReference = enabled && !empty(existingResourceGroup) +var effectiveExistingSubscriptionId = empty(existingSubscriptionId) ? subscription().subscriptionId : existingSubscriptionId + +resource newZone 'Microsoft.Network/privateDnsZones@2020-06-01' = if (shouldCreate) { + name: zoneName + location: 'global' +} + +resource existingZone 'Microsoft.Network/privateDnsZones@2020-06-01' existing = if (shouldReference) { + name: zoneName + scope: resourceGroup(effectiveExistingSubscriptionId, existingResourceGroup) +} + +// Only link when we own the zone. If the user supplied an existing zone in another RG, +// link management is their responsibility (and we may not have rights to write into that RG). +resource link 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = if (shouldCreate) { + parent: newZone + name: '${replace(zoneName, '.', '-')}-${suffix}-link' + location: 'global' + properties: { + virtualNetwork: { id: vnetId } + registrationEnabled: false + } +} + +output zoneId string = enabled ? (shouldCreate ? newZone.id : existingZone.id) : '' +output zoneName string = zoneName diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/modules-network-secured/private-endpoint-and-dns.bicep b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/private-endpoint-and-dns.bicep similarity index 54% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/modules-network-secured/private-endpoint-and-dns.bicep rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/private-endpoint-and-dns.bicep index 96387c417..4693cdbeb 100644 --- a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/modules-network-secured/private-endpoint-and-dns.bicep +++ b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/private-endpoint-and-dns.bicep @@ -64,15 +64,15 @@ param cosmosDBSubscriptionId string = subscription().subscriptionId @description('Resource group name for Cosmos DB account') param cosmosDBResourceGroupName string = resourceGroup().name -@description('Map of DNS zone FQDNs to resource group names. If provided, reference existing DNS zones in this resource group instead of creating them.') +@description('Map of DNS zone FQDNs to an object describing where the zone lives. Each value must be an object with optional `subscriptionId` and `resourceGroup` properties. Empty `resourceGroup` means "create the zone in this deployment\'s resource group". Non-empty `resourceGroup` references an existing zone; empty `subscriptionId` defaults to the current subscription.') param existingDnsZones object = { - 'privatelink.services.ai.azure.com': '' - 'privatelink.openai.azure.com': '' - 'privatelink.cognitiveservices.azure.com': '' - 'privatelink.search.windows.net': '' - 'privatelink.blob.${environment().suffixes.storage}': '' - 'privatelink.documents.azure.com': '' - 'privatelink.fabric.microsoft.com': '' + 'privatelink.services.ai.azure.com': { subscriptionId: '', resourceGroup: '' } + 'privatelink.openai.azure.com': { subscriptionId: '', resourceGroup: '' } + 'privatelink.cognitiveservices.azure.com': { subscriptionId: '', resourceGroup: '' } + 'privatelink.search.windows.net': { subscriptionId: '', resourceGroup: '' } + 'privatelink.blob.${environment().suffixes.storage}': { subscriptionId: '', resourceGroup: '' } + 'privatelink.documents.azure.com': { subscriptionId: '', resourceGroup: '' } + 'privatelink.fabric.microsoft.com': { subscriptionId: '', resourceGroup: '' } } // ---- Resource references ---- @@ -219,14 +219,16 @@ resource fabricPrivateEndpoint 'Microsoft.Network/privateEndpoints@2024-05-01' = } } -/* -------------------------------------------- Private DNS Zones -------------------------------------------- */ +/* -------------------------------------------- Private DNS Zones -------------------------------------------- -// Format: 1) Private DNS Zone -// 2) Link Private DNS Zone to VNet -// 3) Create DNS Zone Group for Private Endpoint + This block used to declare 7 zones Γ— 3 resources each (~150 lines). It is now a single + `for` loop over `existingDnsZones`, with one tiny sub-module (`private-dns-zone.bicep`) + per zone. Per-PE DNS zone groups below look up zone IDs by zone name via `indexOf`. -// Private DNS Zone for AI Services (Account) -// 1) Enables custom DNS resolution for AI Services private endpoint + To skip the optional Fabric zone when no Fabric workspace is provided, the corresponding + module call is disabled (`enabled: false`) instead of being filtered out β€” keeping the + array indices stable and `indexOf` lookups safe. +*/ var aiServicesDnsZoneName = 'privatelink.services.ai.azure.com' var openAiDnsZoneName = 'privatelink.openai.azure.com' @@ -236,178 +238,30 @@ var storageDnsZoneName = 'privatelink.blob.${environment().suffixes.storage}' var cosmosDBDnsZoneName = 'privatelink.documents.azure.com' var fabricDnsZoneName = 'privatelink.fabric.microsoft.com' -// ---- DNS Zone Resource Group lookups ---- -var aiServicesDnsZoneRG = existingDnsZones[aiServicesDnsZoneName] -var openAiDnsZoneRG = existingDnsZones[openAiDnsZoneName] -var cognitiveServicesDnsZoneRG = existingDnsZones[cognitiveServicesDnsZoneName] -var aiSearchDnsZoneRG = existingDnsZones[aiSearchDnsZoneName] -var storageDnsZoneRG = existingDnsZones[storageDnsZoneName] -var cosmosDBDnsZoneRG = existingDnsZones[cosmosDBDnsZoneName] -var fabricDnsZoneRG = existingDnsZones.?fabricDnsZoneName ?? '' - -// ---- DNS Zone Resources and References ---- -resource aiServicesPrivateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' = if (empty(aiServicesDnsZoneRG)) { - name: aiServicesDnsZoneName - location: 'global' -} - -// Reference existing private DNS zone if provided -resource existingAiServicesPrivateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' existing = if (!empty(aiServicesDnsZoneRG)) { - name: aiServicesDnsZoneName - scope: resourceGroup(aiServicesDnsZoneRG) -} -//creating condition if user pass existing dns zones or not -var aiServicesDnsZoneId = empty(aiServicesDnsZoneRG) ? aiServicesPrivateDnsZone.id : existingAiServicesPrivateDnsZone.id - -resource openAiPrivateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' = if (empty(openAiDnsZoneRG)) { - name: openAiDnsZoneName - location: 'global' -} - -// Reference existing private DNS zone if provided -resource existingOpenAiPrivateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' existing = if (!empty(openAiDnsZoneRG)) { - name: openAiDnsZoneName - scope: resourceGroup(openAiDnsZoneRG) -} -//creating condition if user pass existing dns zones or not -var openAiDnsZoneId = empty(openAiDnsZoneRG) ? openAiPrivateDnsZone.id : existingOpenAiPrivateDnsZone.id - -resource cognitiveServicesPrivateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' = if (empty(cognitiveServicesDnsZoneRG)) { - name: cognitiveServicesDnsZoneName - location: 'global' -} - -// Reference existing private DNS zone if provided -resource existingCognitiveServicesPrivateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' existing = if (!empty(cognitiveServicesDnsZoneRG)) { - name: cognitiveServicesDnsZoneName - scope: resourceGroup(cognitiveServicesDnsZoneRG) -} -//creating condition if user pass existing dns zones or not -var cognitiveServicesDnsZoneId = empty(cognitiveServicesDnsZoneRG) - ? cognitiveServicesPrivateDnsZone.id - : existingCognitiveServicesPrivateDnsZone.id - -resource aiSearchPrivateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' = if (empty(aiSearchDnsZoneRG)) { - name: aiSearchDnsZoneName - location: 'global' -} - -// Reference existing private DNS zone if provided -resource existingAiSearchPrivateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' existing = if (!empty(aiSearchDnsZoneRG)) { - name: aiSearchDnsZoneName - scope: resourceGroup(aiSearchDnsZoneRG) -} -//creating condition if user pass existing dns zones or not -var aiSearchDnsZoneId = empty(aiSearchDnsZoneRG) ? aiSearchPrivateDnsZone.id : existingAiSearchPrivateDnsZone.id - -resource storagePrivateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' = if (empty(storageDnsZoneRG)) { - name: storageDnsZoneName - location: 'global' -} - -// Reference existing private DNS zone if provided -resource existingStoragePrivateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' existing = if (!empty(storageDnsZoneRG)) { - name: storageDnsZoneName - scope: resourceGroup(storageDnsZoneRG) -} -//creating condition if user pass existing dns zones or not -var storageDnsZoneId = empty(storageDnsZoneRG) ? storagePrivateDnsZone.id : existingStoragePrivateDnsZone.id - -resource cosmosDBPrivateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' = if (empty(cosmosDBDnsZoneRG)) { - name: cosmosDBDnsZoneName - location: 'global' -} - -// Reference existing private DNS zone if provided -resource existingCosmosDBPrivateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' existing = if (!empty(cosmosDBDnsZoneRG)) { - name: cosmosDBDnsZoneName - scope: resourceGroup(cosmosDBDnsZoneRG) -} -//creating condition if user pass existing dns zones or not -var cosmosDBDnsZoneId = empty(cosmosDBDnsZoneRG) ? cosmosDBPrivateDnsZone.id : existingCosmosDBPrivateDnsZone.id - -// Microsoft Fabric Private DNS Zone - only created if Fabric workspace is provided -resource fabricPrivateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' = if (fabricPassedIn && empty(fabricDnsZoneRG)) { - name: fabricDnsZoneName - location: 'global' -} - -// Reference existing Fabric private DNS zone if provided -resource existingFabricPrivateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' existing = if (fabricPassedIn && !empty(fabricDnsZoneRG)) { - name: fabricDnsZoneName - scope: resourceGroup(fabricDnsZoneRG) -} -// Fabric DNS Zone ID - conditional based on whether Fabric is configured -var fabricDnsZoneId = fabricPassedIn - ? (empty(fabricDnsZoneRG) ? fabricPrivateDnsZone.id : existingFabricPrivateDnsZone.id) - : '' - -// ---- DNS VNet Links ---- -resource aiServicesLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = if (empty(aiServicesDnsZoneRG)) { - parent: aiServicesPrivateDnsZone - location: 'global' - name: 'aiServices-${suffix}-link' - properties: { - virtualNetwork: { id: vnet.id } - registrationEnabled: false - } -} -resource openAiLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = if (empty(openAiDnsZoneRG)) { - parent: openAiPrivateDnsZone - location: 'global' - name: 'aiServicesOpenAI-${suffix}-link' - properties: { - virtualNetwork: { id: vnet.id } - registrationEnabled: false - } -} -resource cognitiveServicesLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = if (empty(cognitiveServicesDnsZoneRG)) { - parent: cognitiveServicesPrivateDnsZone - location: 'global' - name: 'aiServicesCognitiveServices-${suffix}-link' - properties: { - virtualNetwork: { id: vnet.id } - registrationEnabled: false +var dnsZoneEntries = items(existingDnsZones) +var dnsZoneKeys = map(dnsZoneEntries, e => e.key) + +module dnsZones 'private-dns-zone.bicep' = [for (entry, i) in dnsZoneEntries: { + name: 'dns-${replace(entry.key, '.', '-')}-${suffix}' + params: { + zoneName: entry.key + existingResourceGroup: entry.value.?resourceGroup ?? '' + existingSubscriptionId: entry.value.?subscriptionId ?? '' + vnetId: vnet.id + suffix: suffix + // Disable the Fabric zone when no Fabric workspace was supplied β€” saves a no-op zone. + enabled: entry.key == fabricDnsZoneName ? fabricPassedIn : true } -} -resource aiSearchLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = if (empty(aiSearchDnsZoneRG)) { - parent: aiSearchPrivateDnsZone - location: 'global' - name: 'aiSearch-${suffix}-link' - properties: { - virtualNetwork: { id: vnet.id } - registrationEnabled: false - } -} -resource storageLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = if (empty(storageDnsZoneRG)) { - parent: storagePrivateDnsZone - location: 'global' - name: 'storage-${suffix}-link' - properties: { - virtualNetwork: { id: vnet.id } - registrationEnabled: false - } -} -resource cosmosDBLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = if (empty(cosmosDBDnsZoneRG)) { - parent: cosmosDBPrivateDnsZone - location: 'global' - name: 'cosmosDB-${suffix}-link' - properties: { - virtualNetwork: { id: vnet.id } - registrationEnabled: false - } -} +}] -// Fabric VNet Link - only created if Fabric workspace is provided -resource fabricLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = if (fabricPassedIn && empty(fabricDnsZoneRG)) { - parent: fabricPrivateDnsZone - location: 'global' - name: 'fabric-${suffix}-link' - properties: { - virtualNetwork: { id: vnet.id } - registrationEnabled: false - } -} +// ---- Per-zone ID lookups (used to wire DNS zone groups onto each Private Endpoint) ---- +var aiServicesDnsZoneId = dnsZones[indexOf(dnsZoneKeys, aiServicesDnsZoneName)].outputs.zoneId +var openAiDnsZoneId = dnsZones[indexOf(dnsZoneKeys, openAiDnsZoneName)].outputs.zoneId +var cognitiveServicesDnsZoneId = dnsZones[indexOf(dnsZoneKeys, cognitiveServicesDnsZoneName)].outputs.zoneId +var aiSearchDnsZoneId = dnsZones[indexOf(dnsZoneKeys, aiSearchDnsZoneName)].outputs.zoneId +var storageDnsZoneId = dnsZones[indexOf(dnsZoneKeys, storageDnsZoneName)].outputs.zoneId +var cosmosDBDnsZoneId = dnsZones[indexOf(dnsZoneKeys, cosmosDBDnsZoneName)].outputs.zoneId +var fabricDnsZoneId = fabricPassedIn ? dnsZones[indexOf(dnsZoneKeys, fabricDnsZoneName)].outputs.zoneId : '' // ---- DNS Zone Groups ---- resource aiServicesDnsGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2024-05-01' = { @@ -420,11 +274,7 @@ resource aiServicesDnsGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGr { name: '${aiAccountName}-dns-cogserv-config', properties: { privateDnsZoneId: cognitiveServicesDnsZoneId } } ] } - dependsOn: [ - empty(aiServicesDnsZoneRG) ? aiServicesLink : null - empty(openAiDnsZoneRG) ? openAiLink : null - empty(cognitiveServicesDnsZoneRG) ? cognitiveServicesLink : null - ] + // Implicit dependencies on the dnsZones[*] modules via the *DnsZoneId vars above. } resource aiSearchDnsGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2024-05-01' = { parent: aiSearchPrivateEndpoint @@ -434,9 +284,6 @@ resource aiSearchDnsGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGrou { name: '${aiSearchName}-dns-config', properties: { privateDnsZoneId: aiSearchDnsZoneId } } ] } - dependsOn: [ - empty(aiSearchDnsZoneRG) ? aiSearchLink : null - ] } resource storageDnsGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2024-05-01' = { parent: storagePrivateEndpoint @@ -446,9 +293,6 @@ resource storageDnsGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroup { name: '${storageName}-dns-config', properties: { privateDnsZoneId: storageDnsZoneId } } ] } - dependsOn: [ - empty(storageDnsZoneRG) ? storageLink : null - ] } resource cosmosDBDnsGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2024-05-01' = { parent: cosmosDBPrivateEndpoint @@ -458,9 +302,6 @@ resource cosmosDBDnsGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGrou { name: '${cosmosDBName}-dns-config', properties: { privateDnsZoneId: cosmosDBDnsZoneId } } ] } - dependsOn: [ - empty(cosmosDBDnsZoneRG) ? cosmosDBLink : null - ] } // Fabric DNS Zone Group - only created if Fabric workspace is provided @@ -472,7 +313,4 @@ resource fabricDnsGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups { name: '${fabricWorkspaceName}-dns-config', properties: { privateDnsZoneId: fabricDnsZoneId } } ] } - dependsOn: [ - (fabricPassedIn && empty(fabricDnsZoneRG)) ? fabricLink : null - ] } diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/modules-network-secured/standard-dependent-resources.bicep b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/standard-dependent-resources.bicep similarity index 86% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/modules-network-secured/standard-dependent-resources.bicep rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/standard-dependent-resources.bicep index c4c9fb657..9af539a30 100644 --- a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/modules-network-secured/standard-dependent-resources.bicep +++ b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/standard-dependent-resources.bicep @@ -15,21 +15,21 @@ param azureStorageName string @description('Name of the new Cosmos DB account') param cosmosDBName string -@description('The AI Search Service full ARM Resource ID. This is an optional field, and if not provided, the resource will be created.') -param aiSearchResourceId string +@description('The AI Search Service full ARM Resource ID. Optional β€” leave empty to create a new one.') +param existingAiSearchResourceId string -@description('The AI Storage Account full ARM Resource ID. This is an optional field, and if not provided, the resource will be created.') -param azureStorageAccountResourceId string +@description('The AI Storage Account full ARM Resource ID. Optional β€” leave empty to create a new one.') +param existingAzureStorageAccountResourceId string -@description('The Cosmos DB Account full ARM Resource ID. This is an optional field, and if not provided, the resource will be created.') -param cosmosDBResourceId string +@description('The Cosmos DB Account full ARM Resource ID. Optional β€” leave empty to create a new one.') +param existingCosmosDBResourceId string // param aiServiceExists bool param aiSearchExists bool param azureStorageExists bool param cosmosDBExists bool -var cosmosParts = split(cosmosDBResourceId, '/') +var cosmosParts = split(existingCosmosDBResourceId, '/') resource existingCosmosDB 'Microsoft.DocumentDB/databaseAccounts@2024-11-15' existing = if (cosmosDBExists) { name: cosmosParts[8] @@ -64,7 +64,7 @@ resource cosmosDB 'Microsoft.DocumentDB/databaseAccounts@2024-11-15' = if(!cosmo } } -var acsParts = split(aiSearchResourceId, '/') +var acsParts = split(existingAiSearchResourceId, '/') resource existingSearchService 'Microsoft.Search/searchServices@2024-06-01-preview' existing = if (aiSearchExists) { name: acsParts[8] @@ -100,7 +100,7 @@ resource aiSearch 'Microsoft.Search/searchServices@2024-06-01-preview' = if(!aiS } } -var azureStorageParts = split(azureStorageAccountResourceId, '/') +var azureStorageParts = split(existingAzureStorageAccountResourceId, '/') resource existingAzureStorageAccount 'Microsoft.Storage/storageAccounts@2023-05-01' existing = if (azureStorageExists) { name: azureStorageParts[8] diff --git a/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/subnet.bicep b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/subnet.bicep new file mode 100644 index 000000000..94088e811 --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/subnet.bicep @@ -0,0 +1,29 @@ +@description('Name of the virtual network') +param vnetName string + +@description('Name of the subnet') +param subnetName string + +@description('Address prefix for the subnet (only required when creating a new subnet)') +param addressPrefix string = '' + +@description('Array of subnet delegations') +param delegations array = [] + +@description('Set to true to reference an existing subnet instead of creating one') +param subnetExists bool = false + +resource newSubnet 'Microsoft.Network/virtualNetworks/subnets@2024-05-01' = if (!subnetExists) { + name: '${vnetName}/${subnetName}' + properties: { + addressPrefix: addressPrefix + delegations: delegations + } +} + +resource existingSubnet 'Microsoft.Network/virtualNetworks/subnets@2024-05-01' existing = if (subnetExists) { + name: '${vnetName}/${subnetName}' +} + +output subnetId string = subnetExists ? existingSubnet.id : newSubnet.id +output subnetName string = subnetName diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/modules-network-secured/vnet.bicep b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/vnet.bicep similarity index 100% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/modules-network-secured/vnet.bicep rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/modules-network-secured/vnet.bicep diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/openapi-server/Dockerfile b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/openapi-server/Dockerfile similarity index 100% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/openapi-server/Dockerfile rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/openapi-server/Dockerfile diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/openapi-server/main.py b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/openapi-server/main.py similarity index 100% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/openapi-server/main.py rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/openapi-server/main.py diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/openapi-server/requirements.txt b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/openapi-server/requirements.txt similarity index 100% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/openapi-server/requirements.txt rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/openapi-server/requirements.txt diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/tests/TESTING-GUIDE.md b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/tests/TESTING-GUIDE.md similarity index 100% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/tests/TESTING-GUIDE.md rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/tests/TESTING-GUIDE.md diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/tests/calculator_openapi.json b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/tests/calculator_openapi.json similarity index 100% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/tests/calculator_openapi.json rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/tests/calculator_openapi.json diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/tests/test_a2a_connector_agents_v2.py b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/tests/test_a2a_connector_agents_v2.py similarity index 95% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/tests/test_a2a_connector_agents_v2.py rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/tests/test_a2a_connector_agents_v2.py index fd4c2ee45..95eecea13 100644 --- a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/tests/test_a2a_connector_agents_v2.py +++ b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/tests/test_a2a_connector_agents_v2.py @@ -220,6 +220,7 @@ def test_a2a_tool_via_agent( connection_id: str, endpoint_url: str = "", label: str = "A2A Agent", + cleanup_agent: bool = True, ): """ Test that an agent can use the A2A tool to communicate with a remote agent @@ -246,15 +247,12 @@ def test_a2a_tool_via_agent( ): print(f"βœ“ Connected to AI Project at {PROJECT_ENDPOINT}") - # Create A2A tool a2a_tool = A2APreviewTool( project_connection_id=connection_id, ) - # Override endpoint URL if provided if endpoint_url: a2a_tool.base_url = endpoint_url - # Create agent with A2A tool agent = project_client.agents.create_version( agent_name="a2a-tool-test", definition=PromptAgentDefinition( @@ -271,7 +269,6 @@ def test_a2a_tool_via_agent( if endpoint_url: print(f" A2A Endpoint Override: {endpoint_url}") - # Use streaming to handle A2A response events print(" Sending request via A2A tool (streaming)...") full_text = "" @@ -306,11 +303,13 @@ def test_a2a_tool_via_agent( display_text = full_text[:500] + "..." if len(full_text) > 500 else full_text print(f"\nβœ“ Agent response: {display_text}") - # Cleanup - project_client.agents.delete_version( - agent_name=agent.name, agent_version=agent.version - ) - print(f" Cleaned up agent: {agent.name}") + if cleanup_agent: + project_client.agents.delete_version( + agent_name=agent.name, agent_version=agent.version + ) + print(f" Cleaned up agent: {agent.name}") + else: + print(f" Preserved agent version: {agent.name}:{agent.version}") if a2a_call_seen: print(f"\nβœ“ TEST PASSED: A2A tool via {label}") @@ -341,7 +340,7 @@ def test_a2a_tool_via_agent( traceback.print_exc() # Cleanup agent if created - if agent is not None: + if agent is not None and cleanup_agent: try: with ( DefaultAzureCredential() as credential, @@ -355,6 +354,8 @@ def test_a2a_tool_via_agent( print(f" Cleaned up agent: {agent.name}") except Exception: pass + elif agent is not None: + print(f" Preserved agent version after failure: {agent.name}:{agent.version}") return False @@ -391,6 +392,11 @@ def main(): default=1, help="Number of retries for agent tests (default: 1)", ) + parser.add_argument( + "--keep-agent", + action="store_true", + help="Preserve created agent versions instead of deleting them at the end of the test", + ) args = parser.parse_args() print("=" * 60) @@ -426,6 +432,7 @@ def main(): A2A_CONNECTION_ID_PUBLIC, A2A_ENDPOINT_PUBLIC, "Public A2A Agent", + cleanup_agent=not args.keep_agent, ) if result is not None: results["agent_public"] = result @@ -444,6 +451,7 @@ def main(): A2A_CONNECTION_ID_PRIVATE, A2A_ENDPOINT_PRIVATE, "Private A2A Agent", + cleanup_agent=not args.keep_agent, ) if result is not None: results["agent_private"] = result diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/tests/test_agents_v2.py b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/tests/test_agents_v2.py similarity index 99% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/tests/test_agents_v2.py rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/tests/test_agents_v2.py index 1cab26e80..1160c8a2c 100644 --- a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/tests/test_agents_v2.py +++ b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/tests/test_agents_v2.py @@ -57,7 +57,7 @@ from azure.ai.projects.models import ( MCPTool, PromptAgentDefinition, - AzureAISearchAgentTool, + AzureAISearchTool, AzureAISearchToolResource, AISearchIndexResource, AzureAISearchQueryType, @@ -406,7 +406,7 @@ def test_ai_search_tool(): print(f"βœ“ Connected to AI Project at {PROJECT_ENDPOINT}") # Create AI Search tool with SIMPLE query type (our index doesn't have vector fields) - search_tool = AzureAISearchAgentTool( + search_tool = AzureAISearchTool( azure_ai_search=AzureAISearchToolResource(indexes=[ AISearchIndexResource( project_connection_id=AI_SEARCH_CONNECTION_NAME, diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/tests/test_ai_search_tool_agents_v2.py b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/tests/test_ai_search_tool_agents_v2.py similarity index 93% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/tests/test_ai_search_tool_agents_v2.py rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/tests/test_ai_search_tool_agents_v2.py index e3eabc56d..02fd3e548 100644 --- a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/tests/test_ai_search_tool_agents_v2.py +++ b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/tests/test_ai_search_tool_agents_v2.py @@ -43,7 +43,7 @@ from azure.ai.projects import AIProjectClient from azure.ai.projects.models import ( - AzureAISearchAgentTool, + AzureAISearchTool, AzureAISearchToolResource, AISearchIndexResource, AzureAISearchQueryType, @@ -183,7 +183,7 @@ def test_ai_search_connectivity(): return False -def test_ai_search_tool_via_agent(): +def test_ai_search_tool_via_agent(cleanup_agent: bool = True): """ Test AI Search tool via Azure AI Agent. @@ -218,7 +218,7 @@ def test_ai_search_tool_via_agent(): print(f"βœ“ Connected to AI Project at {PROJECT_ENDPOINT}") # Create AI Search tool with SIMPLE query type - search_tool = AzureAISearchAgentTool( + search_tool = AzureAISearchTool( azure_ai_search=AzureAISearchToolResource(indexes=[ AISearchIndexResource( project_connection_id=AI_SEARCH_CONNECTION_NAME, @@ -263,12 +263,14 @@ def test_ai_search_tool_via_agent(): else: print(f"\nβœ“ Agent response: {output_text}") - # Cleanup - project_client.agents.delete_version( - agent_name=agent.name, - agent_version=agent.version - ) - print(f" Cleaned up agent: {agent.name}") + if cleanup_agent: + project_client.agents.delete_version( + agent_name=agent.name, + agent_version=agent.version + ) + print(f" Cleaned up agent: {agent.name}") + else: + print(f" Preserved agent version: {agent.name}:{agent.version}") print("\n" + "=" * 60) print("βœ“ TEST PASSED: AI Search tool via agent") @@ -282,7 +284,7 @@ def test_ai_search_tool_via_agent(): traceback.print_exc() # Cleanup on failure - if agent: + if agent and cleanup_agent: try: project_client.agents.delete_version( agent_name=agent.name, @@ -290,6 +292,8 @@ def test_ai_search_tool_via_agent(): ) except: pass + elif agent: + print(f" Preserved agent version after failure: {agent.name}:{agent.version}") return False @@ -325,9 +329,14 @@ def main(): default=0, help="Number of times to retry failed tests (default: 0)" ) - + parser.add_argument( + "--keep-agent", + action="store_true", + help="Preserve created agent versions instead of deleting them at the end of the test" + ) + args = parser.parse_args() - + print("=" * 60) print("AI SEARCH TOOL TEST") print("=" * 60) @@ -340,37 +349,33 @@ def main(): print(f" AI Search Endpoint: {AI_SEARCH_ENDPOINT or '(not set - connectivity test skipped)'}") results = {} - - # Run connectivity test + if args.test in ["connectivity", "all"]: result = test_ai_search_connectivity() if result is not None: results['connectivity'] = result - # Run agent test if args.test in ["agent", "all"]: for attempt in range(args.retry + 1): if attempt > 0: print(f"\n--- Retry attempt {attempt}/{args.retry} ---") - - result = test_ai_search_tool_via_agent() + + result = test_ai_search_tool_via_agent(cleanup_agent=not args.keep_agent) if result is not None: results['agent'] = result if result: - break # Success, no need to retry + break else: - break # Skipped, no need to retry + break - # Summary print("\n" + "=" * 60) print("TEST SUMMARY") print("=" * 60) - + for test_name, passed in results.items(): status = "βœ“ PASSED" if passed else "βœ— FAILED" print(f" {test_name}: {status}") - # Exit with appropriate code all_passed = all(results.values()) if results else True if all_passed: print("\n" + "=" * 60) @@ -383,6 +388,5 @@ def main(): print("=" * 60) sys.exit(1) - if __name__ == "__main__": main() diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/tests/test_azure_function_agents_v2.py b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/tests/test_azure_function_agents_v2.py similarity index 96% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/tests/test_azure_function_agents_v2.py rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/tests/test_azure_function_agents_v2.py index 60531c4b3..2b72eac86 100644 --- a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/tests/test_azure_function_agents_v2.py +++ b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/tests/test_azure_function_agents_v2.py @@ -256,6 +256,7 @@ def test_function_openapi_tool_via_agent( function_url: str, label: str = "Azure Function", expect_storage: bool = False, + cleanup_agent: bool = True, ): """Test that an agent can call an Azure Function via OpenAPI tool. @@ -345,11 +346,13 @@ def test_function_openapi_tool_via_agent( output = response.output_text print(f"\nβœ“ Agent response: {output}") - # Cleanup - project_client.agents.delete_version( - agent_name=agent.name, agent_version=agent.version - ) - print(f" Cleaned up agent: {agent.name}") + if cleanup_agent: + project_client.agents.delete_version( + agent_name=agent.name, agent_version=agent.version + ) + print(f" Cleaned up agent: {agent.name}") + else: + print(f" Preserved agent version: {agent.name}:{agent.version}") print(f"\nβœ“ TEST PASSED: Azure Function as OpenAPI tool via {label}") return True @@ -370,7 +373,7 @@ def test_function_openapi_tool_via_agent( traceback.print_exc() - if agent is not None: + if agent is not None and cleanup_agent: try: with ( DefaultAzureCredential() as credential, @@ -384,6 +387,8 @@ def test_function_openapi_tool_via_agent( print(f" Cleaned up agent: {agent.name}") except Exception: pass + elif agent is not None: + print(f" Preserved agent version after failure: {agent.name}:{agent.version}") return False @@ -423,6 +428,11 @@ def main(): default=1, help="Number of retries for agent tests (default: 1)", ) + parser.add_argument( + "--keep-agent", + action="store_true", + help="Preserve created agent versions instead of deleting them at the end of the test", + ) args = parser.parse_args() print("=" * 60) @@ -465,6 +475,7 @@ def main(): FUNCTION_APP_PUBLIC, "Public Azure Function (no VNet)", expect_storage=False, + cleanup_agent=not args.keep_agent, ) if result is not None: results["agent_public"] = result @@ -482,6 +493,7 @@ def main(): FUNCTION_APP_PRIVATE, "Private Azure Function (VNet Integration)", expect_storage=True, + cleanup_agent=not args.keep_agent, ) if result is not None: results["agent_private"] = result diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/tests/test_fabric_data_agent_v2.py b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/tests/test_fabric_data_agent_v2.py similarity index 100% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/tests/test_fabric_data_agent_v2.py rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/tests/test_fabric_data_agent_v2.py diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/tests/test_mcp_tools_agents_v2.py b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/tests/test_mcp_tools_agents_v2.py similarity index 93% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/tests/test_mcp_tools_agents_v2.py rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/tests/test_mcp_tools_agents_v2.py index 73501680d..ecaeae90d 100644 --- a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/tests/test_mcp_tools_agents_v2.py +++ b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/tests/test_mcp_tools_agents_v2.py @@ -261,7 +261,7 @@ def test_mcp_connectivity(mcp_url: str, label: str = "MCP Server"): return False -def test_mcp_tool_via_agent(mcp_url: str, label: str = "MCP Server"): +def test_mcp_tool_via_agent(mcp_url: str, label: str = "MCP Server", cleanup_agent: bool = True): """Test that an agent can use MCP tool via the Data Proxy.""" print("\n" + "=" * 60) print(f"TEST: MCP Tool via Agent - {label}") @@ -332,12 +332,14 @@ def test_mcp_tool_via_agent(mcp_url: str, label: str = "MCP Server"): print(f"\nβœ“ Agent response: {response.output_text}") - # Cleanup - project_client.agents.delete_version( - agent_name=agent.name, - agent_version=agent.version - ) - print(f" Cleaned up agent: {agent.name}") + if cleanup_agent: + project_client.agents.delete_version( + agent_name=agent.name, + agent_version=agent.version + ) + print(f" Cleaned up agent: {agent.name}") + else: + print(f" Preserved agent version: {agent.name}:{agent.version}") print(f"\nβœ“ TEST PASSED: MCP tool via {label}") return True @@ -361,7 +363,7 @@ def test_mcp_tool_via_agent(mcp_url: str, label: str = "MCP Server"): traceback.print_exc() # Cleanup agent if created - if agent is not None: + if agent is not None and cleanup_agent: try: with ( DefaultAzureCredential() as credential, @@ -371,6 +373,8 @@ def test_mcp_tool_via_agent(mcp_url: str, label: str = "MCP Server"): print(f" Cleaned up agent: {agent.name}") except: pass + elif agent is not None: + print(f" Preserved agent version after failure: {agent.name}:{agent.version}") return False @@ -389,6 +393,11 @@ def main(): default=1, help="Number of retries for agent tests (default: 1)" ) + parser.add_argument( + "--keep-agent", + action="store_true", + help="Preserve created agent versions instead of deleting them at the end of the test" + ) args = parser.parse_args() print("=" * 60) @@ -411,7 +420,11 @@ def main(): for attempt in range(args.retry): if attempt > 0: print(f"\n Retry attempt {attempt + 1}/{args.retry}...") - result = test_mcp_tool_via_agent(MCP_SERVER_PUBLIC, "Public MCP Server") + result = test_mcp_tool_via_agent( + MCP_SERVER_PUBLIC, + "Public MCP Server", + cleanup_agent=not args.keep_agent, + ) if result: results['agent_public'] = True break @@ -423,7 +436,11 @@ def main(): for attempt in range(args.retry): if attempt > 0: print(f"\n Retry attempt {attempt + 1}/{args.retry}...") - result = test_mcp_tool_via_agent(MCP_SERVER_PRIVATE, "Private MCP Server") + result = test_mcp_tool_via_agent( + MCP_SERVER_PRIVATE, + "Private MCP Server", + cleanup_agent=not args.keep_agent, + ) if result: results['agent_private'] = True break diff --git a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/tests/test_openapi_tool_agents_v2.py b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/tests/test_openapi_tool_agents_v2.py similarity index 94% rename from infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/tests/test_openapi_tool_agents_v2.py rename to infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/tests/test_openapi_tool_agents_v2.py index 9a0381627..37b2f31c3 100644 --- a/infrastructure/infrastructure-setup-bicep/19-hybrid-private-resources-agent-setup/tests/test_openapi_tool_agents_v2.py +++ b/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools/tests/test_openapi_tool_agents_v2.py @@ -210,7 +210,7 @@ def test_openapi_connectivity(server_url: str, label: str = "OpenAPI Server"): return False -def test_openapi_tool_via_agent(server_url: str, label: str = "OpenAPI Server"): +def test_openapi_tool_via_agent(server_url: str, label: str = "OpenAPI Server", cleanup_agent: bool = True): """Test that an agent can use an OpenAPI tool via the Data Proxy.""" print("\n" + "=" * 60) print(f"TEST: OpenAPI Tool via Agent - {label}") @@ -282,11 +282,13 @@ def test_openapi_tool_via_agent(server_url: str, label: str = "OpenAPI Server"): print(f"\nβœ“ Agent response: {response.output_text}") - # Cleanup - project_client.agents.delete_version( - agent_name=agent.name, agent_version=agent.version - ) - print(f" Cleaned up agent: {agent.name}") + if cleanup_agent: + project_client.agents.delete_version( + agent_name=agent.name, agent_version=agent.version + ) + print(f" Cleaned up agent: {agent.name}") + else: + print(f" Preserved agent version: {agent.name}:{agent.version}") print(f"\nβœ“ TEST PASSED: OpenAPI tool via {label}") return True @@ -310,7 +312,7 @@ def test_openapi_tool_via_agent(server_url: str, label: str = "OpenAPI Server"): traceback.print_exc() # Cleanup agent if created - if agent is not None: + if agent is not None and cleanup_agent: try: with ( DefaultAzureCredential() as credential, @@ -324,6 +326,8 @@ def test_openapi_tool_via_agent(server_url: str, label: str = "OpenAPI Server"): print(f" Cleaned up agent: {agent.name}") except Exception: pass + elif agent is not None: + print(f" Preserved agent version after failure: {agent.name}:{agent.version}") return False @@ -358,6 +362,11 @@ def main(): default=1, help="Number of retries for agent tests (default: 1)", ) + parser.add_argument( + "--keep-agent", + action="store_true", + help="Preserve created agent versions instead of deleting them at the end of the test", + ) args = parser.parse_args() print("=" * 60) @@ -371,7 +380,6 @@ def main(): results = {} - # Connectivity test if args.test in ["public", "all"] and OPENAPI_SERVER_PUBLIC: result = test_openapi_connectivity(OPENAPI_SERVER_PUBLIC, "Public OpenAPI Server") if result is not None: @@ -384,13 +392,14 @@ def main(): if result is not None: results["connectivity_private"] = result - # Agent tests: OpenAPI Tool via Agent (Public) if args.test in ["public", "all"] and OPENAPI_SERVER_PUBLIC: for attempt in range(args.retry): if attempt > 0: print(f"\n Retry attempt {attempt + 1}/{args.retry}...") result = test_openapi_tool_via_agent( - OPENAPI_SERVER_PUBLIC, "Public OpenAPI Server" + OPENAPI_SERVER_PUBLIC, + "Public OpenAPI Server", + cleanup_agent=not args.keep_agent, ) if result is not None: results["agent_public"] = result @@ -400,13 +409,14 @@ def main(): if "agent_public" not in results: results["agent_public"] = False - # Agent tests: OpenAPI Tool via Agent (Private) if args.test in ["private", "all"] and OPENAPI_SERVER_PRIVATE: for attempt in range(args.retry): if attempt > 0: print(f"\n Retry attempt {attempt + 1}/{args.retry}...") result = test_openapi_tool_via_agent( - OPENAPI_SERVER_PRIVATE, "Private OpenAPI Server" + OPENAPI_SERVER_PRIVATE, + "Private OpenAPI Server", + cleanup_agent=not args.keep_agent, ) if result is not None: results["agent_private"] = result @@ -416,7 +426,6 @@ def main(): if "agent_private" not in results: results["agent_private"] = False - # Summary print("\n" + "=" * 60) print("TEST SUMMARY") print("=" * 60) diff --git a/infrastructure/infrastructure-setup-bicep/40-basic-agent-setup/README.md b/infrastructure/infrastructure-setup-bicep/40-basic-agent-setup/README.md index a62fbf75c..6ac8db7fd 100644 --- a/infrastructure/infrastructure-setup-bicep/40-basic-agent-setup/README.md +++ b/infrastructure/infrastructure-setup-bicep/40-basic-agent-setup/README.md @@ -9,9 +9,9 @@ All agents created in this project will automatically use Microsoft managed, mul ### Prerequisites 1. To deploy the template, you must have the following roles: - * **Azure AI Account Owner** or **Contributor** + * **Foundry Account Owner** or **Contributor** 1. To create your first agent you must have the permissions: - * **Azure AI User** + * **Foundry User** For more information, [see the getting started guide.](https://learn.microsoft.com/en-us/azure/ai-services/agents/environment-setup) diff --git a/infrastructure/infrastructure-setup-bicep/41-standard-agent-setup/README.md b/infrastructure/infrastructure-setup-bicep/41-standard-agent-setup/README.md index b4e8c2673..a9e389a68 100644 --- a/infrastructure/infrastructure-setup-bicep/41-standard-agent-setup/README.md +++ b/infrastructure/infrastructure-setup-bicep/41-standard-agent-setup/README.md @@ -2,7 +2,7 @@ ## Required Permissions 1. To deploy this template and create a Standard Setup project you need the follow permissions: - * **Azure AI Account Owner** + * **Foundry Account Owner** * **Role Based Access Administrator** For more information on the setup process, [see the getting started documentation.](https://learn.microsoft.com/en-us/azure/ai-services/agents/environment-setup) diff --git a/infrastructure/infrastructure-setup-bicep/42-basic-agent-setup-with-customization/README.md b/infrastructure/infrastructure-setup-bicep/42-basic-agent-setup-with-customization/README.md index ff298adee..49eb9f5f4 100644 --- a/infrastructure/infrastructure-setup-bicep/42-basic-agent-setup-with-customization/README.md +++ b/infrastructure/infrastructure-setup-bicep/42-basic-agent-setup-with-customization/README.md @@ -11,8 +11,8 @@ All agents created in this project will automatically use Microsoft-managed, mul ## Prerequisites 1. **Azure roles** - - To deploy the template: **Azure AI Account Owner** or **Contributor** - - To create your first agent: **Azure AI User** + - To deploy the template: **Foundry Account Owner** or **Contributor** + - To create your first agent: **Foundry User** 2. **Existing Azure OpenAI resource** – you must already have one. Get the resource id in **Azure Portal** diff --git a/infrastructure/infrastructure-setup-bicep/43-standard-agent-setup-with-customization/README.md b/infrastructure/infrastructure-setup-bicep/43-standard-agent-setup-with-customization/README.md index 6be8e848c..902c4a030 100644 --- a/infrastructure/infrastructure-setup-bicep/43-standard-agent-setup-with-customization/README.md +++ b/infrastructure/infrastructure-setup-bicep/43-standard-agent-setup-with-customization/README.md @@ -3,7 +3,7 @@ ## Required Permissions 1. To deploy this template and create a Standard Setup project you need the follow permissions: - * **Azure AI Account Owner** + * **Foundry Account Owner** * **Role Based Access Administrator** For more information on the setup process, [see the getting started documentation.](https://learn.microsoft.com/en-us/azure/ai-services/agents/environment-setup) diff --git a/infrastructure/infrastructure-setup-terraform/00-basic-azurerm/README.md b/infrastructure/infrastructure-setup-terraform/00-basic-azurerm/README.md index c79b01842..73e33ec6f 100644 --- a/infrastructure/infrastructure-setup-terraform/00-basic-azurerm/README.md +++ b/infrastructure/infrastructure-setup-terraform/00-basic-azurerm/README.md @@ -23,9 +23,9 @@ This infrastructure-as-code (IaC) solution deploys Microsoft Foundry with public - **Workload Subscription** - **Role Based Access Control Administrator**: Needed over the resource group to create the relevant role assignments - **Network Contributor**: Needed over the resource group to create virtual network and Private Endpoint resources - - **Azure AI Account Owner**: Needed to create a cognitive services account and project + - **Foundry Account Owner**: Needed to create a cognitive services account and project - **Owner or Role Based Access Administrator**: Needed to assign RBAC to the required resources (Cosmos DB, Azure AI Search, Storage) - - **Azure AI User**: Needed to create and edit agents + - **Foundry User**: Needed to create and edit agents 2. **Register Resource Providers** diff --git a/infrastructure/infrastructure-setup-terraform/00-basic/README.md b/infrastructure/infrastructure-setup-terraform/00-basic/README.md index b5a846650..323249d17 100644 --- a/infrastructure/infrastructure-setup-terraform/00-basic/README.md +++ b/infrastructure/infrastructure-setup-terraform/00-basic/README.md @@ -23,9 +23,9 @@ This infrastructure-as-code (IaC) solution deploys Azure AI Foundry with public - **Workload Subscription** - **Role Based Access Control Administrator**: Needed over the resource group to create the relevant role assignments - **Network Contributor**: Needed over the resource group to create virtual network and Private Endpoint resources - - **Azure AI Account Owner**: Needed to create a cognitive services account and project + - **Foundry Account Owner**: Needed to create a cognitive services account and project - **Owner or Role Based Access Administrator**: Needed to assign RBAC to the required resources (Cosmos DB, Azure AI Search, Storage) - - **Azure AI User**: Needed to create and edit agents + - **Foundry User**: Needed to create and edit agents 2. **Register Resource Providers** diff --git a/infrastructure/infrastructure-setup-terraform/15a-private-network-standard-agent-setup/README.md b/infrastructure/infrastructure-setup-terraform/15a-private-network-standard-agent-setup/README.md index e221e47e1..8f2572cdb 100644 --- a/infrastructure/infrastructure-setup-terraform/15a-private-network-standard-agent-setup/README.md +++ b/infrastructure/infrastructure-setup-terraform/15a-private-network-standard-agent-setup/README.md @@ -36,9 +36,9 @@ This infrastructure-as-code (IaC) solution deploys a network-secured Azure AI ag - **Workload Subscription** - **Role Based Access Control Administrator**: Needed over the resource group to create the relevant role assignments - **Network Contributor**: Needed over the resource group to create virtual network and Private Endpoint resources - - **Azure AI Account Owner**: Needed to create a cognitive services account and project + - **Foundry Account Owner**: Needed to create a cognitive services account and project - **Owner or Role Based Access Administrator**: Needed to assign RBAC to the required resources (Cosmos DB, Azure AI Search, Storage) - - **Azure AI User**: Needed to create and edit agents + - **Foundry User**: Needed to create and edit agents 2. **Register Resource Providers** @@ -193,7 +193,7 @@ The deployment creates an isolated network environment: ### Post Deployment -1. Once all resources are provisioned, assign all developers who want to create/edit agents in the project the role: Azure AI User on the project scope. +1. Once all resources are provisioned, assign all developers who want to create/edit agents in the project the role: Foundry User on the project scope. ### Core Components diff --git a/infrastructure/infrastructure-setup-terraform/15b-private-network-standard-agent-setup-byovnet/README.md b/infrastructure/infrastructure-setup-terraform/15b-private-network-standard-agent-setup-byovnet/README.md index dabbb0b94..fa1076d88 100644 --- a/infrastructure/infrastructure-setup-terraform/15b-private-network-standard-agent-setup-byovnet/README.md +++ b/infrastructure/infrastructure-setup-terraform/15b-private-network-standard-agent-setup-byovnet/README.md @@ -1,5 +1,5 @@ --- -description: This set of templates demonstrates how to set up Azure AI Agent Service with virtual network isolation with private network links to connect the agent to your secure data using a pre-existing virtual network. +description: This set of templates demonstrates how to set up Microsoft Foundry Agent Service with virtual network isolation with private network links to connect the agent to your secure data using a pre-existing virtual network. page_type: sample products: - azure @@ -8,16 +8,8 @@ urlFragment: network-secured-agent languages: - hcl --- -# Updates -**06-27-2025** Initial release -# Azure AI Agent Service: Standard Agent Setup with E2E Network Isolation with Bring-Your-Own Virtual Network - -> **IMPORTANT** -> -> Private Class A subnet support is GA and available in the following regions. **Supported regions: Australia East, Brazil South, Canada East, East US, East US 2, France Central, Germany West Central, Italy North, Japan East, South Africa North, South Central US, South India, Spain Central, Sweden Central, UAE North, UK South, West Europe, West US, West US 3.** -> -> Private Class B and C subnet support is already GA and available in all regions supported by Microsoft Foundry Agent Service. Deployment templates and setup steps are identical for Class A, B, and C subnets. For more on the supported regions of the Microsoft Foundry Agent service, see [Models supported by Microsoft Foundry Agent Service](https://learn.microsoft.com/en-us/azure/ai-foundry/agents/concepts/model-region-support?tabs=global-standard) +# Microsoft Foundry Agent Service: Standard Agent Setup with E2E Network Isolation with Bring-Your-Own Virtual Network ## Key Information @@ -25,9 +17,42 @@ languages: - **All Foundry workspace resources should be in the same region as the VNet**, including CosmosDB, Storage Account, AI Search, Foundry Account, Project, Managed Identity. The only exception is within the Foundry Account, you may choose to deploy your model to a different region, and any cross-region communication will be handled securely within our network infrastructure. - **Note:** Your Virtual Network can be in a different resource group than your Foundry workspace resources -This infrastructure-as-code (IaC) solution deploys a network-secured Azure AI agent environment with private networking and role-based access control (RBAC). It supports scenarios where a customer has a pre-existing virtual network deployed to a workload subscription which is connected to a platform landing zone where the platform components (such as Private DNS Zones) are stored in the same or a separate subscription. +--- +## Overview +This infrastructure-as-code (IaC) solution deploys a network-secured Microsoft Foundry agent environment with private networking and role-based access control (RBAC). + +Standard setup supports private network isolation through utilizing **Bring Your Own Virtual Network (BYO VNet)** approach, also known as **custom VNet support with subnet delegation.** This template is designed for scenarios where a customer has a pre-existing virtual network deployed to a workload subscription which is connected to a platform landing zone where the platform components (such as Private DNS Zones) are stored in the same or a separate subscription. + +This implementation gives you full control over the inbound and outbound communication paths for your agent. You can restrict access to only the resources explicitly required by your agent, such as storage accounts, databases, or APIs, while blocking all other traffic by default. This approach ensures that your agent operates within a tightly scoped network boundary, reducing the risk of data leakage or unauthorized access. By default, this setup simplifies security configuration while enforcing strong isolation guarantees, ensuring that each agent deployment remains secure, compliant, and aligned with enterprise networking policies. + +If you wish to deploy a standalone deployment that creates all networking resources for you, use the [15a-private-network-standard-agent-setup deployment](../15a-private-network-standard-agent-setup/README.md). + +--- + +## When to Use This Template + +Use this template when you need: +- **Full end-to-end network isolation** β€” All resources behind private endpoints with no public internet access +- **Pre-existing VNet and DNS integration** β€” You already have a virtual network and Private DNS Zones deployed, possibly in a separate subscription +- **Enterprise landing zone compatibility** β€” Cross-subscription support for hub-spoke or platform landing zone architectures +- **Standard agent setup with BYO resources** β€” Customer-managed Storage, Cosmos DB, and AI Search for data residency and compliance +- **System Assigned Managed Identity** β€” Simplified identity management with platform-managed credentials + +### Template Decision Guide -If you wish to deploy a standalone deployment use the [15a-private-network-standard-agent-setup deployment](../15a-private-network-standard-agent-setup/README.md). +Use the table below to choose the right Terraform infrastructure template for your scenario: + +| Template | Agent Type | Networking | Identity | Key Use Case | +|----------|-----------|------------|----------|-------------| +| [**15b** (this template)](../15b-private-network-standard-agent-setup-byovnet/) | Standard (BYO resources) | BYO VNet + Private Endpoints | System Assigned MI | E2E network isolation with pre-existing VNet and cross-subscription DNS | +| [**15a**](../15a-private-network-standard-agent-setup/) | Standard (BYO resources) | BYO VNet + Private Endpoints | System Assigned MI | Standalone E2E network isolation (creates VNet, DNS zones, and resource group) | +| [**17**](../17-private-network-standard-user-assigned-identity-agent-setup/) | Standard (BYO resources) | BYO VNet + Private Endpoints | **User Assigned MI** | Same as 15a but with user-managed identity | +| [**16**](../16-private-network-standard-agent-apim-setup-preview/) | Standard (BYO resources) | BYO VNet + Private Endpoints | System Assigned MI | Same as 15a **plus** private APIM integration (preview) | +| [**19**](../19-hybrid-private-resources-agent-setup/) | Standard (BYO resources) | Hybrid (selective private/public) | System Assigned MI | Hybrid networking with selective private endpoints | +| [**10**](../10-private-network-basic/) | Basic (platform-managed) | BYO VNet + Private Endpoints | System Assigned MI | Basic Foundry with private networking β€” no agent BYO resources | +| [**41**](../41-standard-agent-setup/) | Standard (BYO resources) | **Public** (no VNet) | System Assigned MI | Standard agents without network isolation | + +--- ## Prerequisites @@ -39,9 +64,9 @@ If you wish to deploy a standalone deployment use the [15a-private-network-stand - **Workload Subscription** - **Role Based Access Control Administrator**: Needed over the resource group to create relevant role assignments - **Network Contributor**: Needed over the resource group to create the Private Endpoints - - **Azure AI Account Owner**: Needed to create a cognitive services account and project + - **Foundry Account Owner**: Needed to create a cognitive services account and project - **Owner or Role Based Access Administrator**: Needed to assign RBAC to the required resources (Cosmos DB, Azure AI Search, Storage) - - **Azure AI User**: Needed to create and edit agents + - **Foundry User**: Needed to create and edit agents 2. **Register Resource Providers** @@ -80,6 +105,29 @@ If you wish to deploy a standalone deployment use the [15a-private-network-stand - privatelink.blob.core.windows.net - privatelink.search.windows.net - privatelink.documents.azure.com + +### Limitations / Known Issues + +1. The delegated agent subnet must be exclusively used by a single Foundry account. It cannot be shared across accounts. +2. The Foundry resource and the virtual network must be in the same Azure region. BYO resources (Storage, Cosmos DB, AI Search) may be in different regions. +3. Private Class A IP address ranges (10.x.x.x) are only supported in the following regions: **Australia East, Brazil South, Canada East, East US, East US 2, France Central, Germany West Central, Italy North, Japan East, South Africa North, South Central US, South India, Spain Central, Sweden Central, UAE North, UK South, West US, West US 3.** Use Class B (172.16.x.x) or C (192.168.x.x) ranges for other regions. +4. This template does **not** support tools (MCP servers, OpenAPI tools, Azure Functions, A2A) behind the VNet. Use [template 19](../19-hybrid-private-resources-agent-setup/) for that scenario. +5. There is no upgrade path from BYO VNet (this template) to Managed Virtual Network. A Foundry resource redeployment is required. +6. All projects within the same Foundry account share model deployments. Per-project model isolation is not supported. +7. Cosmos DB is deployed as single-region. Multi-region replication must be configured manually post-deployment. + +### Account Deletion Prerequisites and Cleanup Guidance + +Before deleting an **Account** resource, it is essential to first delete the associated **Account Capability Host**. Failure to do so may result in residual dependenciesβ€”such as subnets and other provisioned resources (e.g., ACA applications)β€”remaining linked to the capability host. This can lead to errors such as **"Subnet already in use"** when attempting to reuse the same subnet in a different account deployment. + +**Cleanup Options** + +**1. Full Account Removal**: To completely remove an account, you must delete and purge the account. Simply deleting the account is not sufficient, you must purge so that deletion of the associated capability host is triggered. The service will automatically handle the removal of the capability host and any linked resources in the background. To purge the account, use the following [link](https://learn.microsoft.com/en-us/azure/ai-services/recover-purge-resources?tabs=azure-portal#purge-a-deleted-resource). Please allow approximately max of 20 minutes for all resources to be fully unlinked from the account. + +**2. Retain Account, Remove Capability Host**: If you intend to retain the account but remove the capability host, you must delete the capability host resource directly. After deletion, allow approximately max of 20 minutes for all resources to be fully unlinked from the account. + +> **Important**: Before deleting the account capability host, ensure that the **project capability host** is deleted first. + --- ## Template Customization @@ -140,6 +188,21 @@ terraform init terraform apply ``` +> **Note:** To access a private Foundry resource securely, use one of the following: +> - A VM or jump box on the virtual network, optionally accessed through Azure Bastion +> - Azure VPN Gateway +> - Azure ExpressRoute + +### Cleanup + +To delete all resources created by this template, run: + +```bash +terraform destroy +``` + +> **Important**: If you need to reuse the same subnet, follow the [Account Deletion Prerequisites and Cleanup Guidance](#account-deletion-prerequisites-and-cleanup-guidance) to properly purge the account and wait for the capability host to fully unlink (~20 minutes). + ## Architecture Overview The architecture this deployment supports is pictured below with the resources deployed by these templates highlighted in red. You can reference official Microsoft documentation for details on the networking set-up on [Microsoft Learn](https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/virtual-networks). @@ -147,6 +210,46 @@ The architecture this deployment supports is pictured below with the resources d ![deployment_architecture](./images/foundry-byovnet.svg) +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Secure Access (VPN Gateway / ExpressRoute / Azure Bastion) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Microsoft Foundry β”‚ + β”‚ (publicNetworkAccess: β”‚ + β”‚ DISABLED) β”‚ + β”‚ β”‚ + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ + β”‚ β”‚ Foundry Project β”‚ β”‚ + β”‚ β”‚ (Agent Workspace) β”‚ β”‚ + β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ Subnet Delegation + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ BYO Virtual Network β”‚ + β”‚ (Pre-existing) β”‚ + β”‚ β”‚ + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ + β”‚ β”‚ Agent Subnet β”‚ β”‚ + β”‚ β”‚ (e.g. 192.168.0.0/24) β”‚ β”‚ ◄── Delegated to + β”‚ β”‚ Microsoft.App/envs β”‚ β”‚ Microsoft.App/environments + β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β”‚ β”‚ + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ + β”‚ β”‚ PE Subnet β”‚ β”‚ + β”‚ β”‚ (e.g. 192.168.1.0/24) β”‚ β”‚ + β”‚ β”‚ β”‚ β”‚ + β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ + β”‚ β”‚ β”‚Storage β”‚ β”‚Cosmos β”‚ β”‚ β”‚ ◄── Private endpoints + β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ (no public access) + β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ + β”‚ β”‚ β”‚Search β”‚ β”‚Foundry β”‚ β”‚ β”‚ + β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ + β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + ### Step-by-Step Provisioning Process (main.tf) @@ -195,22 +298,99 @@ The deployment creates an isolated network environment: ### Post Deployment -1. Once all resources are provisioned, assign all developers who want to create/edit agents in the project the role: Azure AI User on the project scope. +1. Once all resources are provisioned, assign all developers who want to create/edit agents in the project the role: Foundry User on the project scope. ### Core Components 1. **Microsoft Foundry Resource** - Central orchestration point - Manages service connections - - Network-isolated capability hosts + - Set networking and policy configurations + 2. **AI Project** - - Workspace configuration + - Defines the workspace configuration - Service integration - - Agent deployment -3. **Supporting Services for Standard Agent Deployment** - - Azure AI Search - - CosmosDB - - Storage Account + - Agents are created within a specific project, and each project acts as an isolated workspace. This means: + - All agents in the same project share access to the same file storage, thread storage (conversation history), and search indexes. + - Data is isolated between projects. Agents in one project cannot access resources from another. + +3. **Bring Your Own (BYO) Azure Resources**: ensures all sensitive data remains under customer control. All agents created using our service are stateful, meaning they retain information across interactions. With this setup, agent states are automatically stored in customer-managed, single-tenant resources. The required Bring Your Own Resources include: + - **BYO File Storage**: All files uploaded by developers (during agent configuration) or end-users (during interactions) are stored directly in the customer's Azure Storage account. + - **BYO Search**: All vector stores created by the agent leverage the customer's Azure AI Search resource. + - **BYO Thread Storage**: All customer messages and conversation history will be stored in the customer's own Azure Cosmos DB account. + +By bundling these BYO features (file storage, search, and thread storage), the standard setup guarantees that your deployment is secure by default. All data processed by Microsoft Foundry Agent Service is automatically stored at rest in your own Azure resources, helping you meet internal policies, compliance requirements, and enterprise security standards. + +### Azure Resources Created + +**Microsoft Foundry (Cognitive Services)** +- Type: Microsoft.CognitiveServices/accounts +- Kind: AIServices +- SKU: S0 +- Identity: System-assigned +- Features: + - Custom subdomain name + - Disabled public network access + - Network ACLs with Azure Services bypass + +**AI Model Deployment** +- Type: Microsoft.CognitiveServices/accounts/deployments +- SKU: Based on deployment configuration, capacity set by model capacity +- Model properties: + - Name, Format, and Version configurable via variables + +**Azure AI Search** +- Type: Microsoft.Search/searchServices +- SKU: standard +- Partition Count: 1 +- Replica Count: 1 +- Hosting Mode: default +- Features: + - Disabled public network access + - AAD auth with HTTP 401 challenge + - System-assigned managed identity + +**Storage Account** +- Type: Microsoft.Storage/storageAccounts +- Kind: StorageV2 +- SKU: ZRS or GRS (region dependent) +- Features: + - Blob service + - Minimum TLS Version: 1.2 + - Block public blob access + - Disabled public network access + - Force Azure AD authentication (SharedKey access disabled) + +**Cosmos DB Account** +- Type: Microsoft.DocumentDB/databaseAccounts +- Kind: GlobalDocumentDB (SQL API) +- Consistency Level: Session +- Database Account Offer Type: Standard +- Features: + - Disabled public network access + - Disabled local auth + - Single region deployment + +### Network Security Design + +This implementation utilizes a BYO VNet (Bring Your Own Virtual Network) approach with subnet delegation. The pre-existing virtual network must include subnets for agent delegation and private endpoints. + +**Private Endpoints** + +Private endpoints ensure secure, internal-only connectivity. Private endpoints are created for the following: +- Microsoft Foundry +- Azure AI Search +- Azure Storage +- Azure Cosmos DB + +**Private DNS Zones** + +| Private Link Resource Type | Sub Resource | Private DNS Zone Name | Public DNS Zone Forwarders | +|----------------------------|--------------|------------------------|-----------------------------| +| **Microsoft Foundry** | account | `privatelink.cognitiveservices.azure.com`
`privatelink.openai.azure.com`
`privatelink.services.ai.azure.com` | `cognitiveservices.azure.com`
`openai.azure.com`
`services.ai.azure.com` | +| **Azure AI Search** | searchService| `privatelink.search.windows.net` | `search.windows.net` | +| **Azure Cosmos DB** | Sql | `privatelink.documents.azure.com` | `documents.azure.com` | +| **Azure Storage** | blob | `privatelink.blob.core.windows.net` | `blob.core.windows.net` | --- @@ -241,14 +421,15 @@ The deployment creates an isolated network environment: - **Cosmos DB for NoSQL** - Cosmos DB Operator (`230815da-be43-4aae-9cb4-875f7bd000aa`) - Cosmos DB Built-in Data Contributor - - Cosmos DB for NoSQL container: `<${projectWorkspaceId}>-thread-message-store` - - Cosmos DB for NoSQL container: `<${projectWorkspaceId}>-agent-entity-store` + - Three containers will automatically be provisioned during the create capability host process: + - Cosmos DB for NoSQL container: `<${projectWorkspaceId}>-thread-message-store` + - Cosmos DB for NoSQL container: `<${projectWorkspaceId}>-system-thread-message-store` + - Cosmos DB for NoSQL container: `<${projectWorkspaceId}>-agent-entity-store` ### Network Security - Public network access disabled - Private endpoints for all services -- Service endpoints for Azure services - Network ACLs with deny by default --- diff --git a/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network/README.md b/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network/README.md new file mode 100644 index 000000000..ddc6dff2b --- /dev/null +++ b/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network/README.md @@ -0,0 +1,185 @@ +--- +description: This template demonstrates how to set up Microsoft Foundry with managed virtual network isolation using Terraform and AzAPI. +page_type: sample +products: +- azure +- azure-resource-manager +urlFragment: managed-network-secured-agent +languages: +- hcl +--- + +# Microsoft Foundry: Standard Agent Setup with Managed Virtual Network (Terraform) + +This folder provides a Terraform implementation for deploying Microsoft Foundry with managed virtual network isolation and private connectivity to supporting resources. + +## Overview + +This infrastructure-as-code (IaC) template provisions a Foundry account and project using preview APIs, then configures a managed network with outbound rules to private endpoints for supporting resources. + +The template is designed for scenarios where you want: +- Managed virtual network isolation (Microsoft-managed network boundary) +- Foundry project setup for agent workloads +- Customer-managed storage/search/thread resources (BYO resources created by this template) +- Private endpoint + Private DNS based connectivity + +## What This Template Deploys + +Core resources: +- Resource group (name is suffixed with a random ID) +- Microsoft Foundry account (`Microsoft.CognitiveServices/accounts`, kind `AIServices`) +- Foundry project (`Microsoft.CognitiveServices/accounts/projects`) +- Managed network configuration on the Foundry account +- Managed network outbound rules for enabled backend resources + +Optional backend resources (feature-flag driven): +- Azure Storage account +- Azure AI Search service +- Azure Cosmos DB for NoSQL account +- Virtual network and subnets +- Private endpoints +- Private DNS zones and VNet links +- Optional Windows VM + Bastion + Key Vault for jump-box access workflows + +Project wiring and access setup: +- Project connections for Storage, AI Search, and Cosmos DB +- Project capability host (when Storage + Search + Cosmos are all enabled) +- Required RBAC role assignments for Foundry account and project identities + +## Feature Flags + +The deployment is controlled by boolean feature flags: + +- `enable_networking` +- `enable_storage` +- `enable_aisearch` +- `enable_cosmos` +- `enable_vm` +- `enable_dns` + +For a full managed-network private topology, set these to `true`: +- `enable_networking` +- `enable_storage` +- `enable_aisearch` +- `enable_cosmos` +- `enable_dns` + +## Prerequisites + +1. Active Azure subscription with permissions to create resources and assign RBAC roles. +1. Azure CLI authenticated to the target subscription. +1. Terraform CLI (1.x). +1. Access to preview API operations used by this template through AzAPI. +1. Resource providers registered in the target subscription: + +```bash +az provider register --namespace "Microsoft.CognitiveServices" +az provider register --namespace "Microsoft.Network" +az provider register --namespace "Microsoft.Storage" +az provider register --namespace "Microsoft.Search" +az provider register --namespace "Microsoft.DocumentDB" +az provider register --namespace "Microsoft.KeyVault" +az provider register --namespace "Microsoft.ContainerService" +az provider register --namespace "Microsoft.App" +``` + +Recommended project-level post-deployment access: +- Assign developers the `Foundry User` role on the Foundry project scope. + +## Variables + +Required: + +| Variable | Description | +|---|---| +| `subscription_id` | Azure subscription ID used by the `azurerm` provider | +| `resource_group_name` | Base resource group name (template appends a random suffix) | + +Conditionally required: + +| Variable | Required when | Description | +|---|---|---| +| `vm_admin_username` | `enable_vm = true` | Admin username for the optional Windows VM | + +Common optional variables: + +| Variable | Default | Description | +|---|---|---| +| `location` | `uaenorth` | Azure region for resource deployment | +| `foundry_identifier` | `foundry` | Prefix used to build Foundry account name | +| `tags` | `{}` | Tags applied to resources | +| `allowed_public_ips` | `[]` | Allowed CIDRs for Key Vault network ACLs (VM scenario) | +| `vnet_name` | `vnet-aifoundry` | VNet name | +| `vnet_address_prefix` | `10.0.0.0/16` | VNet CIDR | +| `private_endpoints_subnet_name` | `snet-privateendpoints` | Private endpoint subnet name | +| `private_endpoints_subnet_prefix` | `10.0.1.0/24` | Private endpoint subnet CIDR | +| `vm_subnet_name` | `snet-vms` | VM subnet name | +| `vm_subnet_prefix` | `10.0.2.0/24` | VM subnet CIDR | +| `bastion_subnet_prefix` | `10.0.3.0/26` | Azure Bastion subnet CIDR | +| `bastion_name` | `bastion-aifoundry` | Bastion name | +| `vm_name` | `vm-win2025` | Windows VM name | + +Use [terraform.tfvars.example](terraform.tfvars.example) as a starting point. + +## Deploy + +1. Create a tfvars file from the example: + +```bash +cp terraform.tfvars.example terraform.tfvars +``` + +2. Update at least: +- `subscription_id` +- `resource_group_name` +- feature flags for your scenario +- `vm_admin_username` if `enable_vm = true` + +3. Initialize and deploy: + +```bash +terraform init +terraform plan +terraform apply +``` + +## Important Behavior and Limitations + +1. This template uses preview API versions via AzAPI for Foundry account/project/managed network resources. +1. The managed network is configured with: + - `managedNetworkKind = "V2"` + - `isolationMode = "AllowInternetOutbound"` +1. Project capability host creation is gated behind all three backend services being enabled: + - `enable_storage = true` + - `enable_aisearch = true` + - `enable_cosmos = true` +1. Several explicit waits (`time_sleep`) are used to reduce transient failures from RBAC and outbound-rule propagation. +1. Resource names are suffixed with a random ID to reduce naming collisions. + +## Outputs + +The template exposes outputs for key resources, including: +- Resource group +- VNet/subnets (when enabled) +- Storage account (when enabled) +- Cosmos DB account (when enabled) +- AI Search service (when enabled) +- Foundry account name/ID/endpoint +- Private DNS zone IDs map (when DNS is enabled) +- VM/Bastion/Key Vault outputs (when VM is enabled) + +See [outputs.tf](outputs.tf) for the full list. + +## Cleanup + +```bash +terraform destroy +``` + +If you plan to reuse network/subnet resources for another Foundry deployment, allow time for managed dependencies to fully unlink after deletion. + +## References + +- [Configure managed network in Microsoft Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/) +- [AzAPI provider documentation](https://registry.terraform.io/providers/Azure/azapi/latest/docs) +- [AzureRM provider documentation](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs) \ No newline at end of file diff --git a/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network-preview/ai-foundry.tf b/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network/ai-foundry.tf similarity index 97% rename from infrastructure/infrastructure-setup-terraform/18-managed-virtual-network-preview/ai-foundry.tf rename to infrastructure/infrastructure-setup-terraform/18-managed-virtual-network/ai-foundry.tf index 52e02d972..3bfe8f6bc 100644 --- a/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network-preview/ai-foundry.tf +++ b/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network/ai-foundry.tf @@ -1,469 +1,469 @@ -# AI Foundry / Cognitive Services Account using AzAPI for preview features -resource "azapi_resource" "cognitive_account" { - type = "Microsoft.CognitiveServices/accounts@2025-10-01-preview" - name = local.foundry_name - location = azurerm_resource_group.main.location - parent_id = azurerm_resource_group.main.id - - schema_validation_enabled = false - - identity { - type = "SystemAssigned" - } - - body = { - sku = { - name = "S0" - } - kind = "AIServices" - properties = merge( - { - allowProjectManagement = true - apiProperties = {} - customSubDomainName = local.foundry_name - disableLocalAuth = true - networkAcls = { - defaultAction = "Deny" - virtualNetworkRules = [] - ipRules = [] - } - networkInjections = [ - { - scenario = "agent" - subnetArmId = "" - useMicrosoftManagedNetwork = true - } - ] - publicNetworkAccess = "Disabled" - }, - var.enable_storage ? { - userOwnedStorage = [ - { - resourceId = azurerm_storage_account.main[0].id - } - ] - } : {}, - var.enable_cosmos ? { - userOwnedCosmosDB = [ - { - resourceId = azurerm_cosmosdb_account.main[0].id - } - ] - } : {}, - var.enable_aisearch ? { - userOwnedSearch = [ - { - resourceId = azurerm_search_service.main[0].id - } - ] - } : {} - ) - } - - tags = merge( - var.tags, - { - environment = "lab" - } - ) - - lifecycle { - ignore_changes = [ - body["properties"]["restore"], - output - ] - } - - depends_on = [ - azurerm_storage_account.main, - azurerm_cosmosdb_account.main, - azurerm_search_service.main - ] -} - -# Role Assignment: Network Connection Approver for AI Foundry Account Identity -# This role is required for the AI Foundry account to approve managed network private endpoint connections -resource "azurerm_role_assignment" "foundry_network_connection_approver" { - scope = azurerm_resource_group.main.id - role_definition_name = "Azure AI Enterprise Network Connection Approver" - principal_id = azapi_resource.cognitive_account.identity[0].principal_id -} - -# Role Assignment: Storage Blob Data Contributor -resource "azurerm_role_assignment" "foundry_storage_blob" { - count = var.enable_storage ? 1 : 0 - scope = azurerm_storage_account.main[0].id - role_definition_name = "Storage Blob Data Contributor" - principal_id = azapi_resource.cognitive_account.identity[0].principal_id -} - -# Role Assignment: Contributor on Storage Account -resource "azurerm_role_assignment" "foundry_storage_contributor" { - count = var.enable_storage ? 1 : 0 - scope = azurerm_storage_account.main[0].id - role_definition_name = "Contributor" - principal_id = azapi_resource.cognitive_account.identity[0].principal_id -} - -# Private Endpoint for AI Foundry -resource "azurerm_private_endpoint" "cognitive_services" { - count = var.enable_networking ? 1 : 0 - name = "${local.foundry_name}-pe" - resource_group_name = azurerm_resource_group.main.name - location = azurerm_resource_group.main.location - subnet_id = azurerm_subnet.private_endpoints[0].id - - private_service_connection { - name = "${local.foundry_name}-psc" - private_connection_resource_id = azapi_resource.cognitive_account.id - is_manual_connection = false - subresource_names = ["account"] - } - - dynamic "private_dns_zone_group" { - for_each = var.enable_dns ? [1] : [] - content { - name = "cognitive-services-dns-zone-group" - private_dns_zone_ids = [ - azurerm_private_dns_zone.cognitive_services[0].id, - azurerm_private_dns_zone.openai[0].id, - azurerm_private_dns_zone.aifoundry_api[0].id, - azurerm_private_dns_zone.aifoundry_notebooks[0].id, - azurerm_private_dns_zone.aifoundry_services[0].id - ] - } - } -} - -# Managed Network Configuration -resource "azapi_resource" "managed_network" { - type = "Microsoft.CognitiveServices/accounts/managedNetworks@2025-10-01-preview" - name = "default" - parent_id = azapi_resource.cognitive_account.id - - schema_validation_enabled = false - - body = { - properties = { - managedNetwork = { - isolationMode = "AllowInternetOutbound" - managedNetworkKind = "V2" - provisionNetworkNow = true - } - } - } -} - -# Wait for Storage Account to be fully created before creating outbound rule -resource "time_sleep" "wait_storage" { - count = var.enable_storage ? 1 : 0 - create_duration = "10m" - - depends_on = [ - azurerm_storage_account.main, - azurerm_private_endpoint.storage_blob - ] -} - -# Managed Network Outbound Rule for Storage Account -resource "azapi_resource" "storage_outbound_rule" { - count = var.enable_storage ? 1 : 0 - type = "Microsoft.CognitiveServices/accounts/managedNetworks/outboundRules@2025-10-01-preview" - name = "storage-blob-rule" - parent_id = azapi_resource.managed_network.id - - schema_validation_enabled = false - - body = { - properties = { - type = "PrivateEndpoint" - destination = { - serviceResourceId = azurerm_storage_account.main[0].id - subresourceTarget = "blob" - } - category = "UserDefined" - } - } - - depends_on = [ - time_sleep.wait_storage, - azurerm_role_assignment.foundry_network_connection_approver, - azurerm_role_assignment.foundry_storage_blob, - azurerm_role_assignment.foundry_storage_contributor - ] -} - -# AI Foundry Project -resource "azapi_resource" "ai_foundry_project" { - type = "Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview" - name = "firstProject" - location = var.location - parent_id = azapi_resource.cognitive_account.id - - schema_validation_enabled = false - - identity { - type = "SystemAssigned" - } - - body = { - properties = { - description = "AI Foundry Project" - } - } - - depends_on = [ - azapi_resource.cognitive_account - ] -} - -# Wait for RBAC propagation -resource "time_sleep" "wait_rbac" { - create_duration = "60s" - - depends_on = [ - azurerm_role_assignment.foundry_storage_blob, - azurerm_role_assignment.foundry_storage_contributor - ] -} - -# Role Assignment: Project Identity - Storage Blob Data Contributor -resource "azurerm_role_assignment" "project_storage_blob" { - count = var.enable_storage ? 1 : 0 - scope = azurerm_storage_account.main[0].id - role_definition_name = "Storage Blob Data Contributor" - principal_id = azapi_resource.ai_foundry_project.identity[0].principal_id -} - -# Role Assignment: Project Identity - AI Search Index Data Contributor -resource "azurerm_role_assignment" "project_search_index" { - count = var.enable_aisearch ? 1 : 0 - scope = azurerm_search_service.main[0].id - role_definition_name = "Search Index Data Contributor" - principal_id = azapi_resource.ai_foundry_project.identity[0].principal_id -} - -# Role Assignment: Project Identity - AI Search Service Contributor -resource "azurerm_role_assignment" "project_search_contributor" { - count = var.enable_aisearch ? 1 : 0 - scope = azurerm_search_service.main[0].id - role_definition_name = "Search Service Contributor" - principal_id = azapi_resource.ai_foundry_project.identity[0].principal_id -} - -# Role Assignment: Project Identity - Cosmos DB Account Reader -resource "azurerm_role_assignment" "project_cosmos_reader" { - count = var.enable_cosmos ? 1 : 0 - scope = azurerm_cosmosdb_account.main[0].id - role_definition_name = "Cosmos DB Account Reader Role" - principal_id = azapi_resource.ai_foundry_project.identity[0].principal_id -} - -# Role Assignment: Project Identity - Cosmos DB Operator (required before capability host) -resource "azurerm_role_assignment" "project_cosmos_operator" { - count = var.enable_cosmos ? 1 : 0 - scope = azurerm_cosmosdb_account.main[0].id - role_definition_name = "Cosmos DB Operator" - principal_id = azapi_resource.ai_foundry_project.identity[0].principal_id -} - -# Wait for project-level RBAC propagation before creating capability host -# This prevents capability host creation failures due to permissions not being ready -resource "time_sleep" "wait_project_rbac" { - create_duration = "90s" - - depends_on = [ - azurerm_role_assignment.project_storage_blob, - azurerm_role_assignment.project_search_index, - azurerm_role_assignment.project_search_contributor, - azurerm_role_assignment.project_cosmos_reader, - azurerm_role_assignment.project_cosmos_operator - ] -} - -# Wait for managed network outbound rules to fully provision -# Outbound rules need additional time beyond creation to be in Succeeded state -# Azure managed network provisioning can take several minutes -resource "time_sleep" "wait_outbound_rules" { - create_duration = "600s" - - depends_on = [ - azapi_resource.storage_outbound_rule, - azapi_resource.cosmos_outbound_rule, - azapi_resource.aisearch_outbound_rule - ] -} - -# AI Foundry Project Capability Host (matches Bicep implementation) -# This configures the capability host at the project level with connection references -resource "azapi_resource" "project_capability_host" { - count = var.enable_storage && var.enable_cosmos && var.enable_aisearch ? 1 : 0 - - type = "Microsoft.CognitiveServices/accounts/projects/capabilityHosts@2025-04-01-preview" - name = "caphostproj" - parent_id = azapi_resource.ai_foundry_project.id - - schema_validation_enabled = false - - body = { - properties = { - capabilityHostKind = "Agents" - vectorStoreConnections = [azurerm_search_service.main[0].name] - storageConnections = [azurerm_storage_account.main[0].name] - threadStorageConnections = [azurerm_cosmosdb_account.main[0].name] - } - } - - depends_on = [ - # Core resources must exist - azapi_resource.ai_foundry_project, - azapi_resource.conn_aisearch, - azapi_resource.conn_cosmosdb, - azapi_resource.conn_storage, - # Project role assignments must be complete (matching Bicep dependencies) - azurerm_role_assignment.project_cosmos_reader, - azurerm_role_assignment.project_cosmos_operator, - azurerm_role_assignment.project_storage_blob, - azurerm_role_assignment.project_search_index, - azurerm_role_assignment.project_search_contributor, - # Wait for RBAC propagation - time_sleep.wait_project_rbac, - # CRITICAL: All outbound rules must be created AND provisioned before capability host - # The capability host validates that outbound rules exist and are in Succeeded state - azapi_resource.storage_outbound_rule, - azapi_resource.cosmos_outbound_rule, - azapi_resource.aisearch_outbound_rule, - time_sleep.wait_outbound_rules - ] -} - -# Connection: AI Search -resource "azapi_resource" "conn_aisearch" { - count = var.enable_aisearch ? 1 : 0 - type = "Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview" - name = azurerm_search_service.main[0].name - parent_id = azapi_resource.ai_foundry_project.id - - schema_validation_enabled = false - - body = { - properties = { - category = "CognitiveSearch" - target = "https://${azurerm_search_service.main[0].name}.search.windows.net" - authType = "AAD" - metadata = { - ApiType = "Azure" - ResourceId = azurerm_search_service.main[0].id - location = azurerm_search_service.main[0].location - } - } - } -} - -# Connection: Cosmos DB -resource "azapi_resource" "conn_cosmosdb" { - count = var.enable_cosmos ? 1 : 0 - type = "Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview" - name = azurerm_cosmosdb_account.main[0].name - parent_id = azapi_resource.ai_foundry_project.id - - schema_validation_enabled = false - - body = { - properties = { - category = "CosmosDb" - target = azurerm_cosmosdb_account.main[0].endpoint - authType = "AAD" - metadata = { - ApiType = "Azure" - ResourceId = azurerm_cosmosdb_account.main[0].id - location = azurerm_cosmosdb_account.main[0].location - } - } - } - - depends_on = [ - azapi_resource.conn_aisearch - ] -} - -# Connection: Storage Account -resource "azapi_resource" "conn_storage" { - count = var.enable_storage ? 1 : 0 - type = "Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview" - name = azurerm_storage_account.main[0].name - parent_id = azapi_resource.ai_foundry_project.id - - schema_validation_enabled = false - - body = { - properties = { - category = "AzureStorageAccount" - target = azurerm_storage_account.main[0].primary_blob_endpoint - authType = "AAD" - metadata = { - ApiType = "Azure" - ResourceId = azurerm_storage_account.main[0].id - location = azurerm_storage_account.main[0].location - } - } - } - - depends_on = [ - azapi_resource.conn_cosmosdb - ] -} - -# Local variable to format project workspace ID as GUID -# The project.properties.internalId comes back as a 32-char hex string -# We need to format it as 8-4-4-4-12 GUID format -locals { - # Extract the workspace ID from the project output - project_workspace_id_raw = try(jsondecode(azapi_resource.ai_foundry_project.output).properties.internalId, "") - - # Format as GUID if we have a valid 32-character string - project_workspace_id_guid = length(local.project_workspace_id_raw) == 32 ? format( - "%s-%s-%s-%s-%s", - substr(local.project_workspace_id_raw, 0, 8), - substr(local.project_workspace_id_raw, 8, 4), - substr(local.project_workspace_id_raw, 12, 4), - substr(local.project_workspace_id_raw, 16, 4), - substr(local.project_workspace_id_raw, 20, 12) - ) : "" -} - -# Role Assignment: Storage Blob Data Owner with ABAC condition -# This must be assigned AFTER the capability host is created -# The condition restricts access to containers starting with workspace ID and ending with -azureml-agent -resource "azurerm_role_assignment" "project_storage_blob_owner_containers" { - count = var.enable_storage ? 1 : 0 - scope = azurerm_storage_account.main[0].id - role_definition_name = "Storage Blob Data Owner" - principal_id = azapi_resource.ai_foundry_project.identity[0].principal_id - - # ABAC condition matching Bicep template - condition = "((!(ActionMatches{'Microsoft.Storage/storageAccounts/blobServices/containers/blobs/tags/read'}) AND !(ActionMatches{'Microsoft.Storage/storageAccounts/blobServices/containers/blobs/filter/action'}) AND !(ActionMatches{'Microsoft.Storage/storageAccounts/blobServices/containers/blobs/tags/write'})) OR (@Resource[Microsoft.Storage/storageAccounts/blobServices/containers:name] StringStartsWithIgnoreCase '${local.project_workspace_id_guid}' AND @Resource[Microsoft.Storage/storageAccounts/blobServices/containers:name] StringLikeIgnoreCase '*-azureml-agent'))" - condition_version = "2.0" - - depends_on = [ - azapi_resource.project_capability_host - ] -} - -# Role Assignment: Cosmos DB Built-in Data Contributor -# This must be assigned AFTER the capability host is created -resource "azurerm_cosmosdb_sql_role_assignment" "project_cosmos_builtin_contributor" { - count = var.enable_cosmos ? 1 : 0 - resource_group_name = azurerm_resource_group.main.name - account_name = azurerm_cosmosdb_account.main[0].name - - # Cosmos DB Built-in Data Contributor role - role_definition_id = "${azurerm_cosmosdb_account.main[0].id}/sqlRoleDefinitions/00000000-0000-0000-0000-000000000002" - - principal_id = azapi_resource.ai_foundry_project.identity[0].principal_id - scope = azurerm_cosmosdb_account.main[0].id - - depends_on = [ - azapi_resource.project_capability_host - ] -} +# AI Foundry / Cognitive Services Account using AzAPI for preview features +resource "azapi_resource" "cognitive_account" { + type = "Microsoft.CognitiveServices/accounts@2025-10-01-preview" + name = local.foundry_name + location = azurerm_resource_group.main.location + parent_id = azurerm_resource_group.main.id + + schema_validation_enabled = false + + identity { + type = "SystemAssigned" + } + + body = { + sku = { + name = "S0" + } + kind = "AIServices" + properties = merge( + { + allowProjectManagement = true + apiProperties = {} + customSubDomainName = local.foundry_name + disableLocalAuth = true + networkAcls = { + defaultAction = "Deny" + virtualNetworkRules = [] + ipRules = [] + } + networkInjections = [ + { + scenario = "agent" + subnetArmId = "" + useMicrosoftManagedNetwork = true + } + ] + publicNetworkAccess = "Disabled" + }, + var.enable_storage ? { + userOwnedStorage = [ + { + resourceId = azurerm_storage_account.main[0].id + } + ] + } : {}, + var.enable_cosmos ? { + userOwnedCosmosDB = [ + { + resourceId = azurerm_cosmosdb_account.main[0].id + } + ] + } : {}, + var.enable_aisearch ? { + userOwnedSearch = [ + { + resourceId = azurerm_search_service.main[0].id + } + ] + } : {} + ) + } + + tags = merge( + var.tags, + { + environment = "lab" + } + ) + + lifecycle { + ignore_changes = [ + body["properties"]["restore"], + output + ] + } + + depends_on = [ + azurerm_storage_account.main, + azurerm_cosmosdb_account.main, + azurerm_search_service.main + ] +} + +# Role Assignment: Network Connection Approver for AI Foundry Account Identity +# This role is required for the AI Foundry account to approve managed network private endpoint connections +resource "azurerm_role_assignment" "foundry_network_connection_approver" { + scope = azurerm_resource_group.main.id + role_definition_name = "Azure AI Enterprise Network Connection Approver" + principal_id = azapi_resource.cognitive_account.identity[0].principal_id +} + +# Role Assignment: Storage Blob Data Contributor +resource "azurerm_role_assignment" "foundry_storage_blob" { + count = var.enable_storage ? 1 : 0 + scope = azurerm_storage_account.main[0].id + role_definition_name = "Storage Blob Data Contributor" + principal_id = azapi_resource.cognitive_account.identity[0].principal_id +} + +# Role Assignment: Contributor on Storage Account +resource "azurerm_role_assignment" "foundry_storage_contributor" { + count = var.enable_storage ? 1 : 0 + scope = azurerm_storage_account.main[0].id + role_definition_name = "Contributor" + principal_id = azapi_resource.cognitive_account.identity[0].principal_id +} + +# Private Endpoint for AI Foundry +resource "azurerm_private_endpoint" "cognitive_services" { + count = var.enable_networking ? 1 : 0 + name = "${local.foundry_name}-pe" + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + subnet_id = azurerm_subnet.private_endpoints[0].id + + private_service_connection { + name = "${local.foundry_name}-psc" + private_connection_resource_id = azapi_resource.cognitive_account.id + is_manual_connection = false + subresource_names = ["account"] + } + + dynamic "private_dns_zone_group" { + for_each = var.enable_dns ? [1] : [] + content { + name = "cognitive-services-dns-zone-group" + private_dns_zone_ids = [ + azurerm_private_dns_zone.cognitive_services[0].id, + azurerm_private_dns_zone.openai[0].id, + azurerm_private_dns_zone.aifoundry_api[0].id, + azurerm_private_dns_zone.aifoundry_notebooks[0].id, + azurerm_private_dns_zone.aifoundry_services[0].id + ] + } + } +} + +# Managed Network Configuration +resource "azapi_resource" "managed_network" { + type = "Microsoft.CognitiveServices/accounts/managedNetworks@2025-10-01-preview" + name = "default" + parent_id = azapi_resource.cognitive_account.id + + schema_validation_enabled = false + + body = { + properties = { + managedNetwork = { + isolationMode = "AllowInternetOutbound" + managedNetworkKind = "V2" + provisionNetworkNow = true + } + } + } +} + +# Wait for Storage Account to be fully created before creating outbound rule +resource "time_sleep" "wait_storage" { + count = var.enable_storage ? 1 : 0 + create_duration = "10m" + + depends_on = [ + azurerm_storage_account.main, + azurerm_private_endpoint.storage_blob + ] +} + +# Managed Network Outbound Rule for Storage Account +resource "azapi_resource" "storage_outbound_rule" { + count = var.enable_storage ? 1 : 0 + type = "Microsoft.CognitiveServices/accounts/managedNetworks/outboundRules@2025-10-01-preview" + name = "storage-blob-rule" + parent_id = azapi_resource.managed_network.id + + schema_validation_enabled = false + + body = { + properties = { + type = "PrivateEndpoint" + destination = { + serviceResourceId = azurerm_storage_account.main[0].id + subresourceTarget = "blob" + } + category = "UserDefined" + } + } + + depends_on = [ + time_sleep.wait_storage, + azurerm_role_assignment.foundry_network_connection_approver, + azurerm_role_assignment.foundry_storage_blob, + azurerm_role_assignment.foundry_storage_contributor + ] +} + +# AI Foundry Project +resource "azapi_resource" "ai_foundry_project" { + type = "Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview" + name = "firstProject" + location = var.location + parent_id = azapi_resource.cognitive_account.id + + schema_validation_enabled = false + + identity { + type = "SystemAssigned" + } + + body = { + properties = { + description = "AI Foundry Project" + } + } + + depends_on = [ + azapi_resource.cognitive_account + ] +} + +# Wait for RBAC propagation +resource "time_sleep" "wait_rbac" { + create_duration = "60s" + + depends_on = [ + azurerm_role_assignment.foundry_storage_blob, + azurerm_role_assignment.foundry_storage_contributor + ] +} + +# Role Assignment: Project Identity - Storage Blob Data Contributor +resource "azurerm_role_assignment" "project_storage_blob" { + count = var.enable_storage ? 1 : 0 + scope = azurerm_storage_account.main[0].id + role_definition_name = "Storage Blob Data Contributor" + principal_id = azapi_resource.ai_foundry_project.identity[0].principal_id +} + +# Role Assignment: Project Identity - AI Search Index Data Contributor +resource "azurerm_role_assignment" "project_search_index" { + count = var.enable_aisearch ? 1 : 0 + scope = azurerm_search_service.main[0].id + role_definition_name = "Search Index Data Contributor" + principal_id = azapi_resource.ai_foundry_project.identity[0].principal_id +} + +# Role Assignment: Project Identity - AI Search Service Contributor +resource "azurerm_role_assignment" "project_search_contributor" { + count = var.enable_aisearch ? 1 : 0 + scope = azurerm_search_service.main[0].id + role_definition_name = "Search Service Contributor" + principal_id = azapi_resource.ai_foundry_project.identity[0].principal_id +} + +# Role Assignment: Project Identity - Cosmos DB Account Reader +resource "azurerm_role_assignment" "project_cosmos_reader" { + count = var.enable_cosmos ? 1 : 0 + scope = azurerm_cosmosdb_account.main[0].id + role_definition_name = "Cosmos DB Account Reader Role" + principal_id = azapi_resource.ai_foundry_project.identity[0].principal_id +} + +# Role Assignment: Project Identity - Cosmos DB Operator (required before capability host) +resource "azurerm_role_assignment" "project_cosmos_operator" { + count = var.enable_cosmos ? 1 : 0 + scope = azurerm_cosmosdb_account.main[0].id + role_definition_name = "Cosmos DB Operator" + principal_id = azapi_resource.ai_foundry_project.identity[0].principal_id +} + +# Wait for project-level RBAC propagation before creating capability host +# This prevents capability host creation failures due to permissions not being ready +resource "time_sleep" "wait_project_rbac" { + create_duration = "90s" + + depends_on = [ + azurerm_role_assignment.project_storage_blob, + azurerm_role_assignment.project_search_index, + azurerm_role_assignment.project_search_contributor, + azurerm_role_assignment.project_cosmos_reader, + azurerm_role_assignment.project_cosmos_operator + ] +} + +# Wait for managed network outbound rules to fully provision +# Outbound rules need additional time beyond creation to be in Succeeded state +# Azure managed network provisioning can take several minutes +resource "time_sleep" "wait_outbound_rules" { + create_duration = "600s" + + depends_on = [ + azapi_resource.storage_outbound_rule, + azapi_resource.cosmos_outbound_rule, + azapi_resource.aisearch_outbound_rule + ] +} + +# AI Foundry Project Capability Host (matches Bicep implementation) +# This configures the capability host at the project level with connection references +resource "azapi_resource" "project_capability_host" { + count = var.enable_storage && var.enable_cosmos && var.enable_aisearch ? 1 : 0 + + type = "Microsoft.CognitiveServices/accounts/projects/capabilityHosts@2025-04-01-preview" + name = "caphostproj" + parent_id = azapi_resource.ai_foundry_project.id + + schema_validation_enabled = false + + body = { + properties = { + capabilityHostKind = "Agents" + vectorStoreConnections = [azurerm_search_service.main[0].name] + storageConnections = [azurerm_storage_account.main[0].name] + threadStorageConnections = [azurerm_cosmosdb_account.main[0].name] + } + } + + depends_on = [ + # Core resources must exist + azapi_resource.ai_foundry_project, + azapi_resource.conn_aisearch, + azapi_resource.conn_cosmosdb, + azapi_resource.conn_storage, + # Project role assignments must be complete (matching Bicep dependencies) + azurerm_role_assignment.project_cosmos_reader, + azurerm_role_assignment.project_cosmos_operator, + azurerm_role_assignment.project_storage_blob, + azurerm_role_assignment.project_search_index, + azurerm_role_assignment.project_search_contributor, + # Wait for RBAC propagation + time_sleep.wait_project_rbac, + # CRITICAL: All outbound rules must be created AND provisioned before capability host + # The capability host validates that outbound rules exist and are in Succeeded state + azapi_resource.storage_outbound_rule, + azapi_resource.cosmos_outbound_rule, + azapi_resource.aisearch_outbound_rule, + time_sleep.wait_outbound_rules + ] +} + +# Connection: AI Search +resource "azapi_resource" "conn_aisearch" { + count = var.enable_aisearch ? 1 : 0 + type = "Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview" + name = azurerm_search_service.main[0].name + parent_id = azapi_resource.ai_foundry_project.id + + schema_validation_enabled = false + + body = { + properties = { + category = "CognitiveSearch" + target = "https://${azurerm_search_service.main[0].name}.search.windows.net" + authType = "AAD" + metadata = { + ApiType = "Azure" + ResourceId = azurerm_search_service.main[0].id + location = azurerm_search_service.main[0].location + } + } + } +} + +# Connection: Cosmos DB +resource "azapi_resource" "conn_cosmosdb" { + count = var.enable_cosmos ? 1 : 0 + type = "Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview" + name = azurerm_cosmosdb_account.main[0].name + parent_id = azapi_resource.ai_foundry_project.id + + schema_validation_enabled = false + + body = { + properties = { + category = "CosmosDb" + target = azurerm_cosmosdb_account.main[0].endpoint + authType = "AAD" + metadata = { + ApiType = "Azure" + ResourceId = azurerm_cosmosdb_account.main[0].id + location = azurerm_cosmosdb_account.main[0].location + } + } + } + + depends_on = [ + azapi_resource.conn_aisearch + ] +} + +# Connection: Storage Account +resource "azapi_resource" "conn_storage" { + count = var.enable_storage ? 1 : 0 + type = "Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview" + name = azurerm_storage_account.main[0].name + parent_id = azapi_resource.ai_foundry_project.id + + schema_validation_enabled = false + + body = { + properties = { + category = "AzureStorageAccount" + target = azurerm_storage_account.main[0].primary_blob_endpoint + authType = "AAD" + metadata = { + ApiType = "Azure" + ResourceId = azurerm_storage_account.main[0].id + location = azurerm_storage_account.main[0].location + } + } + } + + depends_on = [ + azapi_resource.conn_cosmosdb + ] +} + +# Local variable to format project workspace ID as GUID +# The project.properties.internalId comes back as a 32-char hex string +# We need to format it as 8-4-4-4-12 GUID format +locals { + # Extract the workspace ID from the project output + project_workspace_id_raw = try(jsondecode(azapi_resource.ai_foundry_project.output).properties.internalId, "") + + # Format as GUID if we have a valid 32-character string + project_workspace_id_guid = length(local.project_workspace_id_raw) == 32 ? format( + "%s-%s-%s-%s-%s", + substr(local.project_workspace_id_raw, 0, 8), + substr(local.project_workspace_id_raw, 8, 4), + substr(local.project_workspace_id_raw, 12, 4), + substr(local.project_workspace_id_raw, 16, 4), + substr(local.project_workspace_id_raw, 20, 12) + ) : "" +} + +# Role Assignment: Storage Blob Data Owner with ABAC condition +# This must be assigned AFTER the capability host is created +# The condition restricts access to containers starting with workspace ID and ending with -azureml-agent +resource "azurerm_role_assignment" "project_storage_blob_owner_containers" { + count = var.enable_storage ? 1 : 0 + scope = azurerm_storage_account.main[0].id + role_definition_name = "Storage Blob Data Owner" + principal_id = azapi_resource.ai_foundry_project.identity[0].principal_id + + # ABAC condition matching Bicep template + condition = "((!(ActionMatches{'Microsoft.Storage/storageAccounts/blobServices/containers/blobs/tags/read'}) AND !(ActionMatches{'Microsoft.Storage/storageAccounts/blobServices/containers/blobs/filter/action'}) AND !(ActionMatches{'Microsoft.Storage/storageAccounts/blobServices/containers/blobs/tags/write'})) OR (@Resource[Microsoft.Storage/storageAccounts/blobServices/containers:name] StringStartsWithIgnoreCase '${local.project_workspace_id_guid}' AND @Resource[Microsoft.Storage/storageAccounts/blobServices/containers:name] StringLikeIgnoreCase '*-azureml-agent'))" + condition_version = "2.0" + + depends_on = [ + azapi_resource.project_capability_host + ] +} + +# Role Assignment: Cosmos DB Built-in Data Contributor +# This must be assigned AFTER the capability host is created +resource "azurerm_cosmosdb_sql_role_assignment" "project_cosmos_builtin_contributor" { + count = var.enable_cosmos ? 1 : 0 + resource_group_name = azurerm_resource_group.main.name + account_name = azurerm_cosmosdb_account.main[0].name + + # Cosmos DB Built-in Data Contributor role + role_definition_id = "${azurerm_cosmosdb_account.main[0].id}/sqlRoleDefinitions/00000000-0000-0000-0000-000000000002" + + principal_id = azapi_resource.ai_foundry_project.identity[0].principal_id + scope = azurerm_cosmosdb_account.main[0].id + + depends_on = [ + azapi_resource.project_capability_host + ] +} diff --git a/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network-preview/aisearch.tf b/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network/aisearch.tf similarity index 96% rename from infrastructure/infrastructure-setup-terraform/18-managed-virtual-network-preview/aisearch.tf rename to infrastructure/infrastructure-setup-terraform/18-managed-virtual-network/aisearch.tf index 1380f4134..709e9fda3 100644 --- a/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network-preview/aisearch.tf +++ b/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network/aisearch.tf @@ -1,109 +1,109 @@ -# AI Search Service -resource "azurerm_search_service" "main" { - count = var.enable_aisearch ? 1 : 0 - name = local.aisearch_name - resource_group_name = azurerm_resource_group.main.name - location = azurerm_resource_group.main.location - sku = "standard" - replica_count = 1 - partition_count = 1 - - public_network_access_enabled = true - - identity { - type = "SystemAssigned" - } - - tags = merge( - var.tags, - { - environment = "lab" - } - ) -} - -# Private Endpoint for AI Search -resource "azurerm_private_endpoint" "aisearch" { - count = var.enable_aisearch && var.enable_networking ? 1 : 0 - name = "${azurerm_search_service.main[0].name}-pe" - resource_group_name = azurerm_resource_group.main.name - location = azurerm_resource_group.main.location - subnet_id = azurerm_subnet.private_endpoints[0].id - - private_service_connection { - name = "${azurerm_search_service.main[0].name}-psc" - private_connection_resource_id = azurerm_search_service.main[0].id - is_manual_connection = false - subresource_names = ["searchService"] - } - - private_dns_zone_group { - name = "aisearch-dns-zone-group" - private_dns_zone_ids = [azurerm_private_dns_zone.aisearch[0].id] - } - - tags = merge( - var.tags, - { - environment = "lab" - } - ) -} - -# Wait for AI Search to be fully created before creating outbound rule - -# Wait for AI Search to be fully created before creating outbound rule -resource "time_sleep" "wait_aisearch" { - count = var.enable_aisearch ? 1 : 0 - create_duration = "10m" - - depends_on = [ - azurerm_search_service.main, - azurerm_private_endpoint.aisearch - ] -} - -# Managed Network Outbound Rule for AI Search Service -resource "azapi_resource" "aisearch_outbound_rule" { - count = var.enable_aisearch ? 1 : 0 - type = "Microsoft.CognitiveServices/accounts/managedNetworks/outboundRules@2025-10-01-preview" - name = "aisearch-rule" - parent_id = azapi_resource.managed_network.id - - schema_validation_enabled = false - - body = { - properties = { - type = "PrivateEndpoint" - destination = { - serviceResourceId = azurerm_search_service.main[0].id - subresourceTarget = "searchService" - } - category = "UserDefined" - } - } - - depends_on = [ - time_sleep.wait_aisearch, - azurerm_role_assignment.foundry_network_connection_approver, - azurerm_role_assignment.project_search_index, - azurerm_role_assignment.project_search_contributor - ] -} - -# Role Assignment: Current user needs Search Service Contributor -resource "azurerm_role_assignment" "current_user_search_contributor" { - count = var.enable_aisearch ? 1 : 0 - scope = azurerm_search_service.main[0].id - role_definition_name = "Search Service Contributor" - principal_id = data.azurerm_client_config.current.object_id -} - -# Role Assignment: Current user needs Search Index Data Contributor -resource "azurerm_role_assignment" "current_user_search_index" { - count = var.enable_aisearch ? 1 : 0 - scope = azurerm_search_service.main[0].id - role_definition_name = "Search Index Data Contributor" - principal_id = data.azurerm_client_config.current.object_id -} - +# AI Search Service +resource "azurerm_search_service" "main" { + count = var.enable_aisearch ? 1 : 0 + name = local.aisearch_name + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + sku = "standard" + replica_count = 1 + partition_count = 1 + + public_network_access_enabled = true + + identity { + type = "SystemAssigned" + } + + tags = merge( + var.tags, + { + environment = "lab" + } + ) +} + +# Private Endpoint for AI Search +resource "azurerm_private_endpoint" "aisearch" { + count = var.enable_aisearch && var.enable_networking ? 1 : 0 + name = "${azurerm_search_service.main[0].name}-pe" + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + subnet_id = azurerm_subnet.private_endpoints[0].id + + private_service_connection { + name = "${azurerm_search_service.main[0].name}-psc" + private_connection_resource_id = azurerm_search_service.main[0].id + is_manual_connection = false + subresource_names = ["searchService"] + } + + private_dns_zone_group { + name = "aisearch-dns-zone-group" + private_dns_zone_ids = [azurerm_private_dns_zone.aisearch[0].id] + } + + tags = merge( + var.tags, + { + environment = "lab" + } + ) +} + +# Wait for AI Search to be fully created before creating outbound rule + +# Wait for AI Search to be fully created before creating outbound rule +resource "time_sleep" "wait_aisearch" { + count = var.enable_aisearch ? 1 : 0 + create_duration = "10m" + + depends_on = [ + azurerm_search_service.main, + azurerm_private_endpoint.aisearch + ] +} + +# Managed Network Outbound Rule for AI Search Service +resource "azapi_resource" "aisearch_outbound_rule" { + count = var.enable_aisearch ? 1 : 0 + type = "Microsoft.CognitiveServices/accounts/managedNetworks/outboundRules@2025-10-01-preview" + name = "aisearch-rule" + parent_id = azapi_resource.managed_network.id + + schema_validation_enabled = false + + body = { + properties = { + type = "PrivateEndpoint" + destination = { + serviceResourceId = azurerm_search_service.main[0].id + subresourceTarget = "searchService" + } + category = "UserDefined" + } + } + + depends_on = [ + time_sleep.wait_aisearch, + azurerm_role_assignment.foundry_network_connection_approver, + azurerm_role_assignment.project_search_index, + azurerm_role_assignment.project_search_contributor + ] +} + +# Role Assignment: Current user needs Search Service Contributor +resource "azurerm_role_assignment" "current_user_search_contributor" { + count = var.enable_aisearch ? 1 : 0 + scope = azurerm_search_service.main[0].id + role_definition_name = "Search Service Contributor" + principal_id = data.azurerm_client_config.current.object_id +} + +# Role Assignment: Current user needs Search Index Data Contributor +resource "azurerm_role_assignment" "current_user_search_index" { + count = var.enable_aisearch ? 1 : 0 + scope = azurerm_search_service.main[0].id + role_definition_name = "Search Index Data Contributor" + principal_id = data.azurerm_client_config.current.object_id +} + diff --git a/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network-preview/cosmos.tf b/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network/cosmos.tf similarity index 97% rename from infrastructure/infrastructure-setup-terraform/18-managed-virtual-network-preview/cosmos.tf rename to infrastructure/infrastructure-setup-terraform/18-managed-virtual-network/cosmos.tf index fd6c8d210..a99841ac3 100644 --- a/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network-preview/cosmos.tf +++ b/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network/cosmos.tf @@ -1,117 +1,117 @@ -# Cosmos DB Account -resource "azurerm_cosmosdb_account" "main" { - count = var.enable_cosmos ? 1 : 0 - name = local.cosmos_name - resource_group_name = azurerm_resource_group.main.name - location = azurerm_resource_group.main.location - offer_type = "Standard" - kind = "GlobalDocumentDB" - - consistency_policy { - consistency_level = "Session" - max_interval_in_seconds = 5 - max_staleness_prefix = 100 - } - - geo_location { - location = azurerm_resource_group.main.location - failover_priority = 0 - } - - public_network_access_enabled = false - network_acl_bypass_for_azure_services = false - local_authentication_disabled = true - - tags = merge( - var.tags, - { - environment = "lab" - } - ) -} - -# Private Endpoint for Cosmos DB -resource "azurerm_private_endpoint" "cosmos" { - count = var.enable_cosmos && var.enable_networking ? 1 : 0 - name = "${azurerm_cosmosdb_account.main[0].name}-pe" - resource_group_name = azurerm_resource_group.main.name - location = azurerm_resource_group.main.location - subnet_id = azurerm_subnet.private_endpoints[0].id - - private_service_connection { - name = "${azurerm_cosmosdb_account.main[0].name}-psc" - private_connection_resource_id = azurerm_cosmosdb_account.main[0].id - is_manual_connection = false - subresource_names = ["Sql"] - } - - private_dns_zone_group { - name = "cosmos-dns-zone-group" - private_dns_zone_ids = [azurerm_private_dns_zone.cosmos[0].id] - } - - tags = merge( - var.tags, - { - environment = "lab" - } - ) -} - -# Role Assignment: AI Foundry Account Identity - Contributor on Cosmos DB -resource "azurerm_role_assignment" "foundry_cosmos_contributor" { - count = var.enable_cosmos ? 1 : 0 - scope = azurerm_cosmosdb_account.main[0].id - role_definition_name = "Contributor" - principal_id = azapi_resource.cognitive_account.identity[0].principal_id -} - -# Wait for Cosmos DB to be fully created before creating outbound rule -resource "time_sleep" "wait_cosmos" { - count = var.enable_cosmos ? 1 : 0 - create_duration = "10m" - - depends_on = [ - azurerm_cosmosdb_account.main, - azurerm_private_endpoint.cosmos - ] -} - -# Managed Network Outbound Rule for Cosmos DB Account -resource "azapi_resource" "cosmos_outbound_rule" { - count = var.enable_cosmos ? 1 : 0 - type = "Microsoft.CognitiveServices/accounts/managedNetworks/outboundRules@2025-10-01-preview" - name = "cosmos-sql-rule" - parent_id = azapi_resource.managed_network.id - - schema_validation_enabled = false - - body = { - properties = { - type = "PrivateEndpoint" - destination = { - serviceResourceId = azurerm_cosmosdb_account.main[0].id - subresourceTarget = "Sql" - } - category = "UserDefined" - } - } - - depends_on = [ - time_sleep.wait_cosmos, - azurerm_role_assignment.foundry_network_connection_approver, - azurerm_role_assignment.foundry_cosmos_contributor, - azurerm_role_assignment.project_cosmos_reader, - azurerm_role_assignment.project_cosmos_operator - ] -} - -# Role Assignment: Current user needs Cosmos DB Built-in Data Contributor -resource "azurerm_cosmosdb_sql_role_assignment" "current_user" { - count = var.enable_cosmos ? 1 : 0 - resource_group_name = azurerm_resource_group.main.name - account_name = azurerm_cosmosdb_account.main[0].name - role_definition_id = "${azurerm_cosmosdb_account.main[0].id}/sqlRoleDefinitions/00000000-0000-0000-0000-000000000002" - principal_id = data.azurerm_client_config.current.object_id - scope = azurerm_cosmosdb_account.main[0].id -} +# Cosmos DB Account +resource "azurerm_cosmosdb_account" "main" { + count = var.enable_cosmos ? 1 : 0 + name = local.cosmos_name + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + offer_type = "Standard" + kind = "GlobalDocumentDB" + + consistency_policy { + consistency_level = "Session" + max_interval_in_seconds = 5 + max_staleness_prefix = 100 + } + + geo_location { + location = azurerm_resource_group.main.location + failover_priority = 0 + } + + public_network_access_enabled = false + network_acl_bypass_for_azure_services = false + local_authentication_disabled = true + + tags = merge( + var.tags, + { + environment = "lab" + } + ) +} + +# Private Endpoint for Cosmos DB +resource "azurerm_private_endpoint" "cosmos" { + count = var.enable_cosmos && var.enable_networking ? 1 : 0 + name = "${azurerm_cosmosdb_account.main[0].name}-pe" + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + subnet_id = azurerm_subnet.private_endpoints[0].id + + private_service_connection { + name = "${azurerm_cosmosdb_account.main[0].name}-psc" + private_connection_resource_id = azurerm_cosmosdb_account.main[0].id + is_manual_connection = false + subresource_names = ["Sql"] + } + + private_dns_zone_group { + name = "cosmos-dns-zone-group" + private_dns_zone_ids = [azurerm_private_dns_zone.cosmos[0].id] + } + + tags = merge( + var.tags, + { + environment = "lab" + } + ) +} + +# Role Assignment: AI Foundry Account Identity - Contributor on Cosmos DB +resource "azurerm_role_assignment" "foundry_cosmos_contributor" { + count = var.enable_cosmos ? 1 : 0 + scope = azurerm_cosmosdb_account.main[0].id + role_definition_name = "Contributor" + principal_id = azapi_resource.cognitive_account.identity[0].principal_id +} + +# Wait for Cosmos DB to be fully created before creating outbound rule +resource "time_sleep" "wait_cosmos" { + count = var.enable_cosmos ? 1 : 0 + create_duration = "10m" + + depends_on = [ + azurerm_cosmosdb_account.main, + azurerm_private_endpoint.cosmos + ] +} + +# Managed Network Outbound Rule for Cosmos DB Account +resource "azapi_resource" "cosmos_outbound_rule" { + count = var.enable_cosmos ? 1 : 0 + type = "Microsoft.CognitiveServices/accounts/managedNetworks/outboundRules@2025-10-01-preview" + name = "cosmos-sql-rule" + parent_id = azapi_resource.managed_network.id + + schema_validation_enabled = false + + body = { + properties = { + type = "PrivateEndpoint" + destination = { + serviceResourceId = azurerm_cosmosdb_account.main[0].id + subresourceTarget = "Sql" + } + category = "UserDefined" + } + } + + depends_on = [ + time_sleep.wait_cosmos, + azurerm_role_assignment.foundry_network_connection_approver, + azurerm_role_assignment.foundry_cosmos_contributor, + azurerm_role_assignment.project_cosmos_reader, + azurerm_role_assignment.project_cosmos_operator + ] +} + +# Role Assignment: Current user needs Cosmos DB Built-in Data Contributor +resource "azurerm_cosmosdb_sql_role_assignment" "current_user" { + count = var.enable_cosmos ? 1 : 0 + resource_group_name = azurerm_resource_group.main.name + account_name = azurerm_cosmosdb_account.main[0].name + role_definition_id = "${azurerm_cosmosdb_account.main[0].id}/sqlRoleDefinitions/00000000-0000-0000-0000-000000000002" + principal_id = data.azurerm_client_config.current.object_id + scope = azurerm_cosmosdb_account.main[0].id +} diff --git a/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network-preview/dns.tf b/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network/dns.tf similarity index 97% rename from infrastructure/infrastructure-setup-terraform/18-managed-virtual-network-preview/dns.tf rename to infrastructure/infrastructure-setup-terraform/18-managed-virtual-network/dns.tf index dbc0661c0..65c8201ee 100644 --- a/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network-preview/dns.tf +++ b/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network/dns.tf @@ -1,183 +1,183 @@ -# Private DNS Zones -resource "azurerm_private_dns_zone" "cognitive_services" { - count = var.enable_dns ? 1 : 0 - name = "privatelink.cognitiveservices.azure.com" - resource_group_name = azurerm_resource_group.main.name -} - -resource "azurerm_private_dns_zone" "storage_blob" { - count = var.enable_dns ? 1 : 0 - name = "privatelink.blob.core.windows.net" - resource_group_name = azurerm_resource_group.main.name -} - -resource "azurerm_private_dns_zone" "storage_file" { - count = var.enable_dns ? 1 : 0 - name = "privatelink.file.core.windows.net" - resource_group_name = azurerm_resource_group.main.name -} - -resource "azurerm_private_dns_zone" "storage_table" { - count = var.enable_dns ? 1 : 0 - name = "privatelink.table.core.windows.net" - resource_group_name = azurerm_resource_group.main.name -} - -resource "azurerm_private_dns_zone" "storage_queue" { - count = var.enable_dns ? 1 : 0 - name = "privatelink.queue.core.windows.net" - resource_group_name = azurerm_resource_group.main.name -} - -resource "azurerm_private_dns_zone" "key_vault" { - count = var.enable_dns ? 1 : 0 - name = "privatelink.vaultcore.azure.net" - resource_group_name = azurerm_resource_group.main.name -} - -resource "azurerm_private_dns_zone" "container_registry" { - count = var.enable_dns ? 1 : 0 - name = "privatelink.azurecr.io" - resource_group_name = azurerm_resource_group.main.name -} - -resource "azurerm_private_dns_zone" "openai" { - count = var.enable_dns ? 1 : 0 - name = "privatelink.openai.azure.com" - resource_group_name = azurerm_resource_group.main.name -} - -resource "azurerm_private_dns_zone" "aifoundry_api" { - count = var.enable_dns ? 1 : 0 - name = "privatelink.api.azureml.ms" - resource_group_name = azurerm_resource_group.main.name -} - -resource "azurerm_private_dns_zone" "aifoundry_notebooks" { - count = var.enable_dns ? 1 : 0 - name = "privatelink.notebooks.azure.net" - resource_group_name = azurerm_resource_group.main.name -} - -resource "azurerm_private_dns_zone" "aifoundry_services" { - count = var.enable_dns ? 1 : 0 - name = "privatelink.services.ai.azure.com" - resource_group_name = azurerm_resource_group.main.name -} - -resource "azurerm_private_dns_zone" "cosmos" { - count = var.enable_dns ? 1 : 0 - name = "privatelink.documents.azure.com" - resource_group_name = azurerm_resource_group.main.name -} - -resource "azurerm_private_dns_zone" "aisearch" { - count = var.enable_dns ? 1 : 0 - name = "privatelink.search.windows.net" - resource_group_name = azurerm_resource_group.main.name -} - -# VNet Links -resource "azurerm_private_dns_zone_virtual_network_link" "cognitive_services" { - count = var.enable_dns && var.enable_networking ? 1 : 0 - name = "${var.vnet_name}-link" - resource_group_name = azurerm_resource_group.main.name - private_dns_zone_name = azurerm_private_dns_zone.cognitive_services[0].name - virtual_network_id = azurerm_virtual_network.main[0].id -} - -resource "azurerm_private_dns_zone_virtual_network_link" "storage_blob" { - count = var.enable_dns && var.enable_networking ? 1 : 0 - name = "${var.vnet_name}-blob-link" - resource_group_name = azurerm_resource_group.main.name - private_dns_zone_name = azurerm_private_dns_zone.storage_blob[0].name - virtual_network_id = azurerm_virtual_network.main[0].id -} - -resource "azurerm_private_dns_zone_virtual_network_link" "storage_file" { - count = var.enable_dns && var.enable_networking ? 1 : 0 - name = "${var.vnet_name}-file-link" - resource_group_name = azurerm_resource_group.main.name - private_dns_zone_name = azurerm_private_dns_zone.storage_file[0].name - virtual_network_id = azurerm_virtual_network.main[0].id -} - -resource "azurerm_private_dns_zone_virtual_network_link" "storage_table" { - count = var.enable_dns && var.enable_networking ? 1 : 0 - name = "${var.vnet_name}-table-link" - resource_group_name = azurerm_resource_group.main.name - private_dns_zone_name = azurerm_private_dns_zone.storage_table[0].name - virtual_network_id = azurerm_virtual_network.main[0].id -} - -resource "azurerm_private_dns_zone_virtual_network_link" "storage_queue" { - count = var.enable_dns && var.enable_networking ? 1 : 0 - name = "${var.vnet_name}-queue-link" - resource_group_name = azurerm_resource_group.main.name - private_dns_zone_name = azurerm_private_dns_zone.storage_queue[0].name - virtual_network_id = azurerm_virtual_network.main[0].id -} - -resource "azurerm_private_dns_zone_virtual_network_link" "key_vault" { - count = var.enable_dns && var.enable_networking ? 1 : 0 - name = "${var.vnet_name}-keyvault-link" - resource_group_name = azurerm_resource_group.main.name - private_dns_zone_name = azurerm_private_dns_zone.key_vault[0].name - virtual_network_id = azurerm_virtual_network.main[0].id -} - -resource "azurerm_private_dns_zone_virtual_network_link" "container_registry" { - count = var.enable_dns && var.enable_networking ? 1 : 0 - name = "${var.vnet_name}-acr-link" - resource_group_name = azurerm_resource_group.main.name - private_dns_zone_name = azurerm_private_dns_zone.container_registry[0].name - virtual_network_id = azurerm_virtual_network.main[0].id -} - -resource "azurerm_private_dns_zone_virtual_network_link" "openai" { - count = var.enable_dns && var.enable_networking ? 1 : 0 - name = "${var.vnet_name}-openai-link" - resource_group_name = azurerm_resource_group.main.name - private_dns_zone_name = azurerm_private_dns_zone.openai[0].name - virtual_network_id = azurerm_virtual_network.main[0].id -} - -resource "azurerm_private_dns_zone_virtual_network_link" "aifoundry_api" { - count = var.enable_dns && var.enable_networking ? 1 : 0 - name = "${var.vnet_name}-aifoundryapi-link" - resource_group_name = azurerm_resource_group.main.name - private_dns_zone_name = azurerm_private_dns_zone.aifoundry_api[0].name - virtual_network_id = azurerm_virtual_network.main[0].id -} - -resource "azurerm_private_dns_zone_virtual_network_link" "aifoundry_notebooks" { - count = var.enable_dns && var.enable_networking ? 1 : 0 - name = "${var.vnet_name}-aifoundrynb-link" - resource_group_name = azurerm_resource_group.main.name - private_dns_zone_name = azurerm_private_dns_zone.aifoundry_notebooks[0].name - virtual_network_id = azurerm_virtual_network.main[0].id -} - -resource "azurerm_private_dns_zone_virtual_network_link" "aifoundry_services" { - count = var.enable_dns && var.enable_networking ? 1 : 0 - name = "${var.vnet_name}-aifoundrysvc-link" - resource_group_name = azurerm_resource_group.main.name - private_dns_zone_name = azurerm_private_dns_zone.aifoundry_services[0].name - virtual_network_id = azurerm_virtual_network.main[0].id -} - -resource "azurerm_private_dns_zone_virtual_network_link" "cosmos" { - count = var.enable_dns && var.enable_networking ? 1 : 0 - name = "${var.vnet_name}-cosmos-link" - resource_group_name = azurerm_resource_group.main.name - private_dns_zone_name = azurerm_private_dns_zone.cosmos[0].name - virtual_network_id = azurerm_virtual_network.main[0].id -} - -resource "azurerm_private_dns_zone_virtual_network_link" "aisearch" { - count = var.enable_dns && var.enable_networking ? 1 : 0 - name = "${var.vnet_name}-aisearch-link" - resource_group_name = azurerm_resource_group.main.name - private_dns_zone_name = azurerm_private_dns_zone.aisearch[0].name - virtual_network_id = azurerm_virtual_network.main[0].id -} +# Private DNS Zones +resource "azurerm_private_dns_zone" "cognitive_services" { + count = var.enable_dns ? 1 : 0 + name = "privatelink.cognitiveservices.azure.com" + resource_group_name = azurerm_resource_group.main.name +} + +resource "azurerm_private_dns_zone" "storage_blob" { + count = var.enable_dns ? 1 : 0 + name = "privatelink.blob.core.windows.net" + resource_group_name = azurerm_resource_group.main.name +} + +resource "azurerm_private_dns_zone" "storage_file" { + count = var.enable_dns ? 1 : 0 + name = "privatelink.file.core.windows.net" + resource_group_name = azurerm_resource_group.main.name +} + +resource "azurerm_private_dns_zone" "storage_table" { + count = var.enable_dns ? 1 : 0 + name = "privatelink.table.core.windows.net" + resource_group_name = azurerm_resource_group.main.name +} + +resource "azurerm_private_dns_zone" "storage_queue" { + count = var.enable_dns ? 1 : 0 + name = "privatelink.queue.core.windows.net" + resource_group_name = azurerm_resource_group.main.name +} + +resource "azurerm_private_dns_zone" "key_vault" { + count = var.enable_dns ? 1 : 0 + name = "privatelink.vaultcore.azure.net" + resource_group_name = azurerm_resource_group.main.name +} + +resource "azurerm_private_dns_zone" "container_registry" { + count = var.enable_dns ? 1 : 0 + name = "privatelink.azurecr.io" + resource_group_name = azurerm_resource_group.main.name +} + +resource "azurerm_private_dns_zone" "openai" { + count = var.enable_dns ? 1 : 0 + name = "privatelink.openai.azure.com" + resource_group_name = azurerm_resource_group.main.name +} + +resource "azurerm_private_dns_zone" "aifoundry_api" { + count = var.enable_dns ? 1 : 0 + name = "privatelink.api.azureml.ms" + resource_group_name = azurerm_resource_group.main.name +} + +resource "azurerm_private_dns_zone" "aifoundry_notebooks" { + count = var.enable_dns ? 1 : 0 + name = "privatelink.notebooks.azure.net" + resource_group_name = azurerm_resource_group.main.name +} + +resource "azurerm_private_dns_zone" "aifoundry_services" { + count = var.enable_dns ? 1 : 0 + name = "privatelink.services.ai.azure.com" + resource_group_name = azurerm_resource_group.main.name +} + +resource "azurerm_private_dns_zone" "cosmos" { + count = var.enable_dns ? 1 : 0 + name = "privatelink.documents.azure.com" + resource_group_name = azurerm_resource_group.main.name +} + +resource "azurerm_private_dns_zone" "aisearch" { + count = var.enable_dns ? 1 : 0 + name = "privatelink.search.windows.net" + resource_group_name = azurerm_resource_group.main.name +} + +# VNet Links +resource "azurerm_private_dns_zone_virtual_network_link" "cognitive_services" { + count = var.enable_dns && var.enable_networking ? 1 : 0 + name = "${var.vnet_name}-link" + resource_group_name = azurerm_resource_group.main.name + private_dns_zone_name = azurerm_private_dns_zone.cognitive_services[0].name + virtual_network_id = azurerm_virtual_network.main[0].id +} + +resource "azurerm_private_dns_zone_virtual_network_link" "storage_blob" { + count = var.enable_dns && var.enable_networking ? 1 : 0 + name = "${var.vnet_name}-blob-link" + resource_group_name = azurerm_resource_group.main.name + private_dns_zone_name = azurerm_private_dns_zone.storage_blob[0].name + virtual_network_id = azurerm_virtual_network.main[0].id +} + +resource "azurerm_private_dns_zone_virtual_network_link" "storage_file" { + count = var.enable_dns && var.enable_networking ? 1 : 0 + name = "${var.vnet_name}-file-link" + resource_group_name = azurerm_resource_group.main.name + private_dns_zone_name = azurerm_private_dns_zone.storage_file[0].name + virtual_network_id = azurerm_virtual_network.main[0].id +} + +resource "azurerm_private_dns_zone_virtual_network_link" "storage_table" { + count = var.enable_dns && var.enable_networking ? 1 : 0 + name = "${var.vnet_name}-table-link" + resource_group_name = azurerm_resource_group.main.name + private_dns_zone_name = azurerm_private_dns_zone.storage_table[0].name + virtual_network_id = azurerm_virtual_network.main[0].id +} + +resource "azurerm_private_dns_zone_virtual_network_link" "storage_queue" { + count = var.enable_dns && var.enable_networking ? 1 : 0 + name = "${var.vnet_name}-queue-link" + resource_group_name = azurerm_resource_group.main.name + private_dns_zone_name = azurerm_private_dns_zone.storage_queue[0].name + virtual_network_id = azurerm_virtual_network.main[0].id +} + +resource "azurerm_private_dns_zone_virtual_network_link" "key_vault" { + count = var.enable_dns && var.enable_networking ? 1 : 0 + name = "${var.vnet_name}-keyvault-link" + resource_group_name = azurerm_resource_group.main.name + private_dns_zone_name = azurerm_private_dns_zone.key_vault[0].name + virtual_network_id = azurerm_virtual_network.main[0].id +} + +resource "azurerm_private_dns_zone_virtual_network_link" "container_registry" { + count = var.enable_dns && var.enable_networking ? 1 : 0 + name = "${var.vnet_name}-acr-link" + resource_group_name = azurerm_resource_group.main.name + private_dns_zone_name = azurerm_private_dns_zone.container_registry[0].name + virtual_network_id = azurerm_virtual_network.main[0].id +} + +resource "azurerm_private_dns_zone_virtual_network_link" "openai" { + count = var.enable_dns && var.enable_networking ? 1 : 0 + name = "${var.vnet_name}-openai-link" + resource_group_name = azurerm_resource_group.main.name + private_dns_zone_name = azurerm_private_dns_zone.openai[0].name + virtual_network_id = azurerm_virtual_network.main[0].id +} + +resource "azurerm_private_dns_zone_virtual_network_link" "aifoundry_api" { + count = var.enable_dns && var.enable_networking ? 1 : 0 + name = "${var.vnet_name}-aifoundryapi-link" + resource_group_name = azurerm_resource_group.main.name + private_dns_zone_name = azurerm_private_dns_zone.aifoundry_api[0].name + virtual_network_id = azurerm_virtual_network.main[0].id +} + +resource "azurerm_private_dns_zone_virtual_network_link" "aifoundry_notebooks" { + count = var.enable_dns && var.enable_networking ? 1 : 0 + name = "${var.vnet_name}-aifoundrynb-link" + resource_group_name = azurerm_resource_group.main.name + private_dns_zone_name = azurerm_private_dns_zone.aifoundry_notebooks[0].name + virtual_network_id = azurerm_virtual_network.main[0].id +} + +resource "azurerm_private_dns_zone_virtual_network_link" "aifoundry_services" { + count = var.enable_dns && var.enable_networking ? 1 : 0 + name = "${var.vnet_name}-aifoundrysvc-link" + resource_group_name = azurerm_resource_group.main.name + private_dns_zone_name = azurerm_private_dns_zone.aifoundry_services[0].name + virtual_network_id = azurerm_virtual_network.main[0].id +} + +resource "azurerm_private_dns_zone_virtual_network_link" "cosmos" { + count = var.enable_dns && var.enable_networking ? 1 : 0 + name = "${var.vnet_name}-cosmos-link" + resource_group_name = azurerm_resource_group.main.name + private_dns_zone_name = azurerm_private_dns_zone.cosmos[0].name + virtual_network_id = azurerm_virtual_network.main[0].id +} + +resource "azurerm_private_dns_zone_virtual_network_link" "aisearch" { + count = var.enable_dns && var.enable_networking ? 1 : 0 + name = "${var.vnet_name}-aisearch-link" + resource_group_name = azurerm_resource_group.main.name + private_dns_zone_name = azurerm_private_dns_zone.aisearch[0].name + virtual_network_id = azurerm_virtual_network.main[0].id +} diff --git a/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network-preview/keyvault.tf b/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network/keyvault.tf similarity index 96% rename from infrastructure/infrastructure-setup-terraform/18-managed-virtual-network-preview/keyvault.tf rename to infrastructure/infrastructure-setup-terraform/18-managed-virtual-network/keyvault.tf index 164ca781c..a4b7ba51f 100644 --- a/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network-preview/keyvault.tf +++ b/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network/keyvault.tf @@ -1,135 +1,135 @@ -# Generate a random password for VM admin -resource "random_password" "vm_admin" { - count = var.enable_vm ? 1 : 0 - length = 24 - special = true - override_special = "!@#$%^&*()-_=+[]{}:?" - min_upper = 2 - min_lower = 2 - min_numeric = 2 - min_special = 2 -} - -# Key Vault -resource "azurerm_key_vault" "main" { - count = var.enable_vm ? 1 : 0 - name = local.keyvault_name - location = azurerm_resource_group.main.location - resource_group_name = azurerm_resource_group.main.name - tenant_id = data.azurerm_client_config.current.tenant_id - sku_name = "standard" - soft_delete_retention_days = 7 - purge_protection_enabled = false - - # Enable public network access with network rules to allow specific IPs - public_network_access_enabled = length(var.allowed_public_ips) > 0 ? true : false - - # Network ACLs to restrict access - network_acls { - bypass = "AzureServices" - default_action = length(var.allowed_public_ips) > 0 ? "Deny" : "Allow" - ip_rules = var.allowed_public_ips - } - - # Enable RBAC for access control - rbac_authorization_enabled = true - - tags = { - environment = "lab" - } -} - -# Private Endpoint for Key Vault -resource "azurerm_private_endpoint" "keyvault" { - count = var.enable_vm && var.enable_networking ? 1 : 0 - name = "${azurerm_key_vault.main[0].name}-pe" - resource_group_name = azurerm_resource_group.main.name - location = azurerm_resource_group.main.location - subnet_id = azurerm_subnet.private_endpoints[0].id - - private_service_connection { - name = "${azurerm_key_vault.main[0].name}-psc" - private_connection_resource_id = azurerm_key_vault.main[0].id - is_manual_connection = false - subresource_names = ["vault"] - } - - private_dns_zone_group { - name = "keyvault-dns-zone-group" - private_dns_zone_ids = [azurerm_private_dns_zone.key_vault[0].id] - } - - tags = merge( - var.tags, - { - environment = "lab" - } - ) -} - -# Role Assignment: Key Vault Administrator for current user -resource "azurerm_role_assignment" "current_user_keyvault_admin" { - count = var.enable_vm ? 1 : 0 - scope = azurerm_key_vault.main[0].id - role_definition_name = "Key Vault Administrator" - principal_id = data.azurerm_client_config.current.object_id -} - -# Role Assignment: Key Vault Secrets User for VM managed identity -# This allows the VM to read secrets (useful for future automation scenarios) -resource "azurerm_role_assignment" "vm_keyvault_secrets_user" { - count = var.enable_vm ? 1 : 0 - scope = azurerm_key_vault.main[0].id - role_definition_name = "Key Vault Secrets User" - principal_id = azurerm_windows_virtual_machine.main[0].identity[0].principal_id -} - -# Wait for RBAC propagation for Key Vault -resource "time_sleep" "wait_keyvault_rbac" { - count = var.enable_vm ? 1 : 0 - create_duration = "30s" - - depends_on = [ - azurerm_role_assignment.current_user_keyvault_admin - ] -} - -# Store VM admin password in Key Vault -resource "azurerm_key_vault_secret" "vm_admin_password" { - count = var.enable_vm ? 1 : 0 - name = "vm-admin-password" - value = random_password.vm_admin[0].result - key_vault_id = azurerm_key_vault.main[0].id - - depends_on = [ - time_sleep.wait_keyvault_rbac - ] - - tags = merge( - var.tags, - { - environment = "lab" - purpose = "VM administrator password" - } - ) -} - -# Store VM admin username in Key Vault for reference -resource "azurerm_key_vault_secret" "vm_admin_username" { - count = var.enable_vm ? 1 : 0 - name = "vm-admin-username" - value = var.vm_admin_username - key_vault_id = azurerm_key_vault.main[0].id - - depends_on = [ - time_sleep.wait_keyvault_rbac - ] - - tags = merge( - var.tags, - { - environment = "lab" - purpose = "VM administrator username" - } - ) -} +# Generate a random password for VM admin +resource "random_password" "vm_admin" { + count = var.enable_vm ? 1 : 0 + length = 24 + special = true + override_special = "!@#$%^&*()-_=+[]{}:?" + min_upper = 2 + min_lower = 2 + min_numeric = 2 + min_special = 2 +} + +# Key Vault +resource "azurerm_key_vault" "main" { + count = var.enable_vm ? 1 : 0 + name = local.keyvault_name + location = azurerm_resource_group.main.location + resource_group_name = azurerm_resource_group.main.name + tenant_id = data.azurerm_client_config.current.tenant_id + sku_name = "standard" + soft_delete_retention_days = 7 + purge_protection_enabled = false + + # Enable public network access with network rules to allow specific IPs + public_network_access_enabled = length(var.allowed_public_ips) > 0 ? true : false + + # Network ACLs to restrict access + network_acls { + bypass = "AzureServices" + default_action = length(var.allowed_public_ips) > 0 ? "Deny" : "Allow" + ip_rules = var.allowed_public_ips + } + + # Enable RBAC for access control + rbac_authorization_enabled = true + + tags = { + environment = "lab" + } +} + +# Private Endpoint for Key Vault +resource "azurerm_private_endpoint" "keyvault" { + count = var.enable_vm && var.enable_networking ? 1 : 0 + name = "${azurerm_key_vault.main[0].name}-pe" + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + subnet_id = azurerm_subnet.private_endpoints[0].id + + private_service_connection { + name = "${azurerm_key_vault.main[0].name}-psc" + private_connection_resource_id = azurerm_key_vault.main[0].id + is_manual_connection = false + subresource_names = ["vault"] + } + + private_dns_zone_group { + name = "keyvault-dns-zone-group" + private_dns_zone_ids = [azurerm_private_dns_zone.key_vault[0].id] + } + + tags = merge( + var.tags, + { + environment = "lab" + } + ) +} + +# Role Assignment: Key Vault Administrator for current user +resource "azurerm_role_assignment" "current_user_keyvault_admin" { + count = var.enable_vm ? 1 : 0 + scope = azurerm_key_vault.main[0].id + role_definition_name = "Key Vault Administrator" + principal_id = data.azurerm_client_config.current.object_id +} + +# Role Assignment: Key Vault Secrets User for VM managed identity +# This allows the VM to read secrets (useful for future automation scenarios) +resource "azurerm_role_assignment" "vm_keyvault_secrets_user" { + count = var.enable_vm ? 1 : 0 + scope = azurerm_key_vault.main[0].id + role_definition_name = "Key Vault Secrets User" + principal_id = azurerm_windows_virtual_machine.main[0].identity[0].principal_id +} + +# Wait for RBAC propagation for Key Vault +resource "time_sleep" "wait_keyvault_rbac" { + count = var.enable_vm ? 1 : 0 + create_duration = "30s" + + depends_on = [ + azurerm_role_assignment.current_user_keyvault_admin + ] +} + +# Store VM admin password in Key Vault +resource "azurerm_key_vault_secret" "vm_admin_password" { + count = var.enable_vm ? 1 : 0 + name = "vm-admin-password" + value = random_password.vm_admin[0].result + key_vault_id = azurerm_key_vault.main[0].id + + depends_on = [ + time_sleep.wait_keyvault_rbac + ] + + tags = merge( + var.tags, + { + environment = "lab" + purpose = "VM administrator password" + } + ) +} + +# Store VM admin username in Key Vault for reference +resource "azurerm_key_vault_secret" "vm_admin_username" { + count = var.enable_vm ? 1 : 0 + name = "vm-admin-username" + value = var.vm_admin_username + key_vault_id = azurerm_key_vault.main[0].id + + depends_on = [ + time_sleep.wait_keyvault_rbac + ] + + tags = merge( + var.tags, + { + environment = "lab" + purpose = "VM administrator username" + } + ) +} diff --git a/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network-preview/main.tf b/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network/main.tf similarity index 96% rename from infrastructure/infrastructure-setup-terraform/18-managed-virtual-network-preview/main.tf rename to infrastructure/infrastructure-setup-terraform/18-managed-virtual-network/main.tf index ffa77dde4..cc6178152 100644 --- a/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network-preview/main.tf +++ b/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network/main.tf @@ -1,27 +1,27 @@ -resource "random_id" "suffix" { - byte_length = 4 -} - -data "azurerm_client_config" "current" {} - -locals { - resource_suffix = random_id.suffix.hex - rg_name = "${var.resource_group_name}-${local.resource_suffix}" - foundry_name = "${var.foundry_identifier}-${local.resource_suffix}" - storage_name = "st${local.resource_suffix}" - aisearch_name = "srch-${local.resource_suffix}" - cosmos_name = "cosmos-${local.resource_suffix}" - keyvault_name = "kv-${local.resource_suffix}" -} - -resource "azurerm_resource_group" "main" { - name = local.rg_name - location = var.location - - tags = merge( - var.tags, - { - environment = "lab" - } - ) -} +resource "random_id" "suffix" { + byte_length = 4 +} + +data "azurerm_client_config" "current" {} + +locals { + resource_suffix = random_id.suffix.hex + rg_name = "${var.resource_group_name}-${local.resource_suffix}" + foundry_name = "${var.foundry_identifier}-${local.resource_suffix}" + storage_name = "st${local.resource_suffix}" + aisearch_name = "srch-${local.resource_suffix}" + cosmos_name = "cosmos-${local.resource_suffix}" + keyvault_name = "kv-${local.resource_suffix}" +} + +resource "azurerm_resource_group" "main" { + name = local.rg_name + location = var.location + + tags = merge( + var.tags, + { + environment = "lab" + } + ) +} diff --git a/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network-preview/network.tf b/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network/network.tf similarity index 97% rename from infrastructure/infrastructure-setup-terraform/18-managed-virtual-network-preview/network.tf rename to infrastructure/infrastructure-setup-terraform/18-managed-virtual-network/network.tf index 1d9ca355b..5e4bec2a8 100644 --- a/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network-preview/network.tf +++ b/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network/network.tf @@ -1,155 +1,155 @@ -# Virtual Network -resource "azurerm_virtual_network" "main" { - count = var.enable_networking ? 1 : 0 - name = var.vnet_name - location = var.location - resource_group_name = azurerm_resource_group.main.name - address_space = [var.vnet_address_prefix] -} - -# Subnets -resource "azurerm_subnet" "private_endpoints" { - count = var.enable_networking ? 1 : 0 - name = var.private_endpoints_subnet_name - resource_group_name = azurerm_resource_group.main.name - virtual_network_name = azurerm_virtual_network.main[0].name - address_prefixes = [var.private_endpoints_subnet_prefix] - default_outbound_access_enabled = true -} - -resource "azurerm_subnet" "vms" { - count = var.enable_vm ? 1 : 0 - name = var.vm_subnet_name - resource_group_name = azurerm_resource_group.main.name - virtual_network_name = azurerm_virtual_network.main[0].name - address_prefixes = [var.vm_subnet_prefix] - default_outbound_access_enabled = true -} - -resource "azurerm_subnet" "bastion" { - count = var.enable_vm ? 1 : 0 - name = "AzureBastionSubnet" - resource_group_name = azurerm_resource_group.main.name - virtual_network_name = azurerm_virtual_network.main[0].name - address_prefixes = [var.bastion_subnet_prefix] - default_outbound_access_enabled = true -} - -# Bastion Public IP -resource "azurerm_public_ip" "bastion" { - count = var.enable_vm ? 1 : 0 - name = "${var.bastion_name}-pip" - location = var.location - resource_group_name = azurerm_resource_group.main.name - allocation_method = "Static" - sku = "Standard" -} - -# Azure Bastion -resource "azurerm_bastion_host" "main" { - count = var.enable_vm ? 1 : 0 - name = var.bastion_name - location = var.location - resource_group_name = azurerm_resource_group.main.name - sku = "Standard" - - ip_configuration { - name = "bastionIpConfig" - subnet_id = azurerm_subnet.bastion[0].id - public_ip_address_id = azurerm_public_ip.bastion[0].id - } - - # Enable features for enhanced connectivity - tunneling_enabled = true - shareable_link_enabled = false - ip_connect_enabled = true - copy_paste_enabled = true - file_copy_enabled = true - - tags = merge( - var.tags, - { - environment = "lab" - } - ) -} - -# Network Interface for VM -resource "azurerm_network_interface" "vm" { - count = var.enable_vm ? 1 : 0 - name = "${var.vm_name}-nic" - location = var.location - resource_group_name = azurerm_resource_group.main.name - - ip_configuration { - name = "ipconfig1" - subnet_id = azurerm_subnet.vms[0].id - private_ip_address_allocation = "Dynamic" - } -} - -# Windows Server 2025 VM with Entra ID Authentication -resource "azurerm_windows_virtual_machine" "main" { - count = var.enable_vm ? 1 : 0 - name = var.vm_name - location = var.location - resource_group_name = azurerm_resource_group.main.name - size = "Standard_B2s" - admin_username = var.vm_admin_username - admin_password = random_password.vm_admin[0].result - - network_interface_ids = [ - azurerm_network_interface.vm[0].id - ] - - os_disk { - caching = "ReadWrite" - storage_account_type = "Standard_LRS" - } - - source_image_reference { - publisher = "MicrosoftWindowsServer" - offer = "WindowsServer" - sku = "2025-datacenter-azure-edition" - version = "latest" - } - - # Enable system-assigned managed identity for Entra ID login - identity { - type = "SystemAssigned" - } - - # Enable Azure Hybrid Benefit for Windows Server - license_type = "Windows_Server" - - automatic_updates_enabled = true - provision_vm_agent = true - patch_mode = "AutomaticByPlatform" - patch_assessment_mode = "AutomaticByPlatform" - - tags = merge( - var.tags, - { - environment = "lab" - } - ) -} - -# AAD Login Extension for Windows -resource "azurerm_virtual_machine_extension" "aad_login" { - count = var.enable_vm ? 1 : 0 - name = "AADLoginForWindows" - virtual_machine_id = azurerm_windows_virtual_machine.main[0].id - publisher = "Microsoft.Azure.ActiveDirectory" - type = "AADLoginForWindows" - type_handler_version = "2.0" - auto_upgrade_minor_version = true -} - -# Role Assignment: Virtual Machine Administrator Login for current user -resource "azurerm_role_assignment" "vm_admin_login" { - count = var.enable_vm ? 1 : 0 - scope = azurerm_windows_virtual_machine.main[0].id - role_definition_name = "Virtual Machine Administrator Login" - principal_id = data.azurerm_client_config.current.object_id -} +# Virtual Network +resource "azurerm_virtual_network" "main" { + count = var.enable_networking ? 1 : 0 + name = var.vnet_name + location = var.location + resource_group_name = azurerm_resource_group.main.name + address_space = [var.vnet_address_prefix] +} + +# Subnets +resource "azurerm_subnet" "private_endpoints" { + count = var.enable_networking ? 1 : 0 + name = var.private_endpoints_subnet_name + resource_group_name = azurerm_resource_group.main.name + virtual_network_name = azurerm_virtual_network.main[0].name + address_prefixes = [var.private_endpoints_subnet_prefix] + default_outbound_access_enabled = true +} + +resource "azurerm_subnet" "vms" { + count = var.enable_vm ? 1 : 0 + name = var.vm_subnet_name + resource_group_name = azurerm_resource_group.main.name + virtual_network_name = azurerm_virtual_network.main[0].name + address_prefixes = [var.vm_subnet_prefix] + default_outbound_access_enabled = true +} + +resource "azurerm_subnet" "bastion" { + count = var.enable_vm ? 1 : 0 + name = "AzureBastionSubnet" + resource_group_name = azurerm_resource_group.main.name + virtual_network_name = azurerm_virtual_network.main[0].name + address_prefixes = [var.bastion_subnet_prefix] + default_outbound_access_enabled = true +} + +# Bastion Public IP +resource "azurerm_public_ip" "bastion" { + count = var.enable_vm ? 1 : 0 + name = "${var.bastion_name}-pip" + location = var.location + resource_group_name = azurerm_resource_group.main.name + allocation_method = "Static" + sku = "Standard" +} + +# Azure Bastion +resource "azurerm_bastion_host" "main" { + count = var.enable_vm ? 1 : 0 + name = var.bastion_name + location = var.location + resource_group_name = azurerm_resource_group.main.name + sku = "Standard" + + ip_configuration { + name = "bastionIpConfig" + subnet_id = azurerm_subnet.bastion[0].id + public_ip_address_id = azurerm_public_ip.bastion[0].id + } + + # Enable features for enhanced connectivity + tunneling_enabled = true + shareable_link_enabled = false + ip_connect_enabled = true + copy_paste_enabled = true + file_copy_enabled = true + + tags = merge( + var.tags, + { + environment = "lab" + } + ) +} + +# Network Interface for VM +resource "azurerm_network_interface" "vm" { + count = var.enable_vm ? 1 : 0 + name = "${var.vm_name}-nic" + location = var.location + resource_group_name = azurerm_resource_group.main.name + + ip_configuration { + name = "ipconfig1" + subnet_id = azurerm_subnet.vms[0].id + private_ip_address_allocation = "Dynamic" + } +} + +# Windows Server 2025 VM with Entra ID Authentication +resource "azurerm_windows_virtual_machine" "main" { + count = var.enable_vm ? 1 : 0 + name = var.vm_name + location = var.location + resource_group_name = azurerm_resource_group.main.name + size = "Standard_B2s" + admin_username = var.vm_admin_username + admin_password = random_password.vm_admin[0].result + + network_interface_ids = [ + azurerm_network_interface.vm[0].id + ] + + os_disk { + caching = "ReadWrite" + storage_account_type = "Standard_LRS" + } + + source_image_reference { + publisher = "MicrosoftWindowsServer" + offer = "WindowsServer" + sku = "2025-datacenter-azure-edition" + version = "latest" + } + + # Enable system-assigned managed identity for Entra ID login + identity { + type = "SystemAssigned" + } + + # Enable Azure Hybrid Benefit for Windows Server + license_type = "Windows_Server" + + automatic_updates_enabled = true + provision_vm_agent = true + patch_mode = "AutomaticByPlatform" + patch_assessment_mode = "AutomaticByPlatform" + + tags = merge( + var.tags, + { + environment = "lab" + } + ) +} + +# AAD Login Extension for Windows +resource "azurerm_virtual_machine_extension" "aad_login" { + count = var.enable_vm ? 1 : 0 + name = "AADLoginForWindows" + virtual_machine_id = azurerm_windows_virtual_machine.main[0].id + publisher = "Microsoft.Azure.ActiveDirectory" + type = "AADLoginForWindows" + type_handler_version = "2.0" + auto_upgrade_minor_version = true +} + +# Role Assignment: Virtual Machine Administrator Login for current user +resource "azurerm_role_assignment" "vm_admin_login" { + count = var.enable_vm ? 1 : 0 + scope = azurerm_windows_virtual_machine.main[0].id + role_definition_name = "Virtual Machine Administrator Login" + principal_id = data.azurerm_client_config.current.object_id +} diff --git a/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network-preview/outputs.tf b/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network/outputs.tf similarity index 97% rename from infrastructure/infrastructure-setup-terraform/18-managed-virtual-network-preview/outputs.tf rename to infrastructure/infrastructure-setup-terraform/18-managed-virtual-network/outputs.tf index ee30382cd..29618a2bd 100644 --- a/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network-preview/outputs.tf +++ b/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network/outputs.tf @@ -1,155 +1,155 @@ -# Outputs -output "resource_group_name" { - description = "The name of the resource group" - value = azurerm_resource_group.main.name -} - -output "resource_group_location" { - description = "The location of the resource group" - value = azurerm_resource_group.main.location -} - -output "vnet_id" { - description = "The ID of the virtual network" - value = var.enable_networking ? azurerm_virtual_network.main[0].id : null -} - -output "vnet_name" { - description = "The name of the virtual network" - value = var.enable_networking ? azurerm_virtual_network.main[0].name : null -} - -output "private_endpoints_subnet_id" { - description = "The ID of the private endpoints subnet" - value = var.enable_networking ? azurerm_subnet.private_endpoints[0].id : null -} - -output "vm_subnet_id" { - description = "The ID of the VM subnet" - value = var.enable_vm ? azurerm_subnet.vms[0].id : null -} - -output "bastion_id" { - description = "The ID of the Azure Bastion" - value = var.enable_vm ? azurerm_bastion_host.main[0].id : null -} - -output "bastion_dns_name" { - description = "The DNS name of the Azure Bastion" - value = var.enable_vm ? azurerm_bastion_host.main[0].dns_name : null -} - -output "windows_vm_id" { - description = "The ID of the Windows VM" - value = var.enable_vm ? azurerm_windows_virtual_machine.main[0].id : null -} - -output "windows_vm_private_ip" { - description = "The private IP address of the Windows VM" - value = var.enable_vm ? azurerm_network_interface.vm[0].private_ip_address : null -} - -output "windows_vm_computer_name" { - description = "The computer name of the Windows VM" - value = var.enable_vm ? azurerm_windows_virtual_machine.main[0].computer_name : null -} - -output "key_vault_id" { - description = "The ID of the Key Vault" - value = var.enable_vm ? azurerm_key_vault.main[0].id : null -} - -output "key_vault_name" { - description = "The name of the Key Vault" - value = var.enable_vm ? azurerm_key_vault.main[0].name : null -} - -output "key_vault_uri" { - description = "The URI of the Key Vault" - value = var.enable_vm ? azurerm_key_vault.main[0].vault_uri : null -} - -output "vm_admin_password_secret_id" { - description = "The Key Vault secret ID containing the VM admin password" - value = var.enable_vm ? azurerm_key_vault_secret.vm_admin_password[0].id : null - sensitive = true -} - -output "storage_account_id" { - description = "The ID of the storage account" - value = var.enable_storage ? azurerm_storage_account.main[0].id : null -} - -output "storage_account_name" { - description = "The name of the storage account" - value = var.enable_storage ? azurerm_storage_account.main[0].name : null -} - -output "cosmos_account_id" { - description = "The ID of the Cosmos DB account" - value = var.enable_cosmos ? azurerm_cosmosdb_account.main[0].id : null -} - -output "cosmos_account_name" { - description = "The name of the Cosmos DB account" - value = var.enable_cosmos ? azurerm_cosmosdb_account.main[0].name : null -} - -output "cosmos_account_endpoint" { - description = "The endpoint of the Cosmos DB account" - value = var.enable_cosmos ? azurerm_cosmosdb_account.main[0].endpoint : null -} - -output "aisearch_id" { - description = "The ID of the AI Search service" - value = var.enable_aisearch ? azurerm_search_service.main[0].id : null -} - -output "aisearch_name" { - description = "The name of the AI Search service" - value = var.enable_aisearch ? azurerm_search_service.main[0].name : null -} - -output "aisearch_endpoint" { - description = "The endpoint of the AI Search service" - value = var.enable_aisearch ? "https://${azurerm_search_service.main[0].name}.search.windows.net" : null -} - -output "ai_foundry_id" { - description = "The ID of the AI Foundry / Cognitive Services account" - value = azapi_resource.cognitive_account.id -} - -output "ai_foundry_name" { - description = "The name of the AI Foundry / Cognitive Services account" - value = azapi_resource.cognitive_account.name -} - -output "ai_foundry_endpoint" { - description = "The endpoint of the AI Foundry / Cognitive Services account" - value = "https://${azapi_resource.cognitive_account.name}.cognitiveservices.azure.com/" -} - -output "ai_foundry_custom_subdomain" { - description = "The custom subdomain name of the AI Foundry account" - value = try(jsondecode(azapi_resource.cognitive_account.output).properties.customSubDomainName, azapi_resource.cognitive_account.name) -} - -output "private_dns_zone_ids" { - description = "Map of private DNS zone IDs" - value = var.enable_dns ? { - cognitive_services = azurerm_private_dns_zone.cognitive_services[0].id - storage_blob = azurerm_private_dns_zone.storage_blob[0].id - storage_file = azurerm_private_dns_zone.storage_file[0].id - storage_table = azurerm_private_dns_zone.storage_table[0].id - storage_queue = azurerm_private_dns_zone.storage_queue[0].id - key_vault = azurerm_private_dns_zone.key_vault[0].id - container_registry = azurerm_private_dns_zone.container_registry[0].id - openai = azurerm_private_dns_zone.openai[0].id - aifoundry_api = azurerm_private_dns_zone.aifoundry_api[0].id - aifoundry_notebooks = azurerm_private_dns_zone.aifoundry_notebooks[0].id - aifoundry_services = azurerm_private_dns_zone.aifoundry_services[0].id - cosmos = azurerm_private_dns_zone.cosmos[0].id - aisearch = azurerm_private_dns_zone.aisearch[0].id - } : {} -} +# Outputs +output "resource_group_name" { + description = "The name of the resource group" + value = azurerm_resource_group.main.name +} + +output "resource_group_location" { + description = "The location of the resource group" + value = azurerm_resource_group.main.location +} + +output "vnet_id" { + description = "The ID of the virtual network" + value = var.enable_networking ? azurerm_virtual_network.main[0].id : null +} + +output "vnet_name" { + description = "The name of the virtual network" + value = var.enable_networking ? azurerm_virtual_network.main[0].name : null +} + +output "private_endpoints_subnet_id" { + description = "The ID of the private endpoints subnet" + value = var.enable_networking ? azurerm_subnet.private_endpoints[0].id : null +} + +output "vm_subnet_id" { + description = "The ID of the VM subnet" + value = var.enable_vm ? azurerm_subnet.vms[0].id : null +} + +output "bastion_id" { + description = "The ID of the Azure Bastion" + value = var.enable_vm ? azurerm_bastion_host.main[0].id : null +} + +output "bastion_dns_name" { + description = "The DNS name of the Azure Bastion" + value = var.enable_vm ? azurerm_bastion_host.main[0].dns_name : null +} + +output "windows_vm_id" { + description = "The ID of the Windows VM" + value = var.enable_vm ? azurerm_windows_virtual_machine.main[0].id : null +} + +output "windows_vm_private_ip" { + description = "The private IP address of the Windows VM" + value = var.enable_vm ? azurerm_network_interface.vm[0].private_ip_address : null +} + +output "windows_vm_computer_name" { + description = "The computer name of the Windows VM" + value = var.enable_vm ? azurerm_windows_virtual_machine.main[0].computer_name : null +} + +output "key_vault_id" { + description = "The ID of the Key Vault" + value = var.enable_vm ? azurerm_key_vault.main[0].id : null +} + +output "key_vault_name" { + description = "The name of the Key Vault" + value = var.enable_vm ? azurerm_key_vault.main[0].name : null +} + +output "key_vault_uri" { + description = "The URI of the Key Vault" + value = var.enable_vm ? azurerm_key_vault.main[0].vault_uri : null +} + +output "vm_admin_password_secret_id" { + description = "The Key Vault secret ID containing the VM admin password" + value = var.enable_vm ? azurerm_key_vault_secret.vm_admin_password[0].id : null + sensitive = true +} + +output "storage_account_id" { + description = "The ID of the storage account" + value = var.enable_storage ? azurerm_storage_account.main[0].id : null +} + +output "storage_account_name" { + description = "The name of the storage account" + value = var.enable_storage ? azurerm_storage_account.main[0].name : null +} + +output "cosmos_account_id" { + description = "The ID of the Cosmos DB account" + value = var.enable_cosmos ? azurerm_cosmosdb_account.main[0].id : null +} + +output "cosmos_account_name" { + description = "The name of the Cosmos DB account" + value = var.enable_cosmos ? azurerm_cosmosdb_account.main[0].name : null +} + +output "cosmos_account_endpoint" { + description = "The endpoint of the Cosmos DB account" + value = var.enable_cosmos ? azurerm_cosmosdb_account.main[0].endpoint : null +} + +output "aisearch_id" { + description = "The ID of the AI Search service" + value = var.enable_aisearch ? azurerm_search_service.main[0].id : null +} + +output "aisearch_name" { + description = "The name of the AI Search service" + value = var.enable_aisearch ? azurerm_search_service.main[0].name : null +} + +output "aisearch_endpoint" { + description = "The endpoint of the AI Search service" + value = var.enable_aisearch ? "https://${azurerm_search_service.main[0].name}.search.windows.net" : null +} + +output "ai_foundry_id" { + description = "The ID of the AI Foundry / Cognitive Services account" + value = azapi_resource.cognitive_account.id +} + +output "ai_foundry_name" { + description = "The name of the AI Foundry / Cognitive Services account" + value = azapi_resource.cognitive_account.name +} + +output "ai_foundry_endpoint" { + description = "The endpoint of the AI Foundry / Cognitive Services account" + value = "https://${azapi_resource.cognitive_account.name}.cognitiveservices.azure.com/" +} + +output "ai_foundry_custom_subdomain" { + description = "The custom subdomain name of the AI Foundry account" + value = try(jsondecode(azapi_resource.cognitive_account.output).properties.customSubDomainName, azapi_resource.cognitive_account.name) +} + +output "private_dns_zone_ids" { + description = "Map of private DNS zone IDs" + value = var.enable_dns ? { + cognitive_services = azurerm_private_dns_zone.cognitive_services[0].id + storage_blob = azurerm_private_dns_zone.storage_blob[0].id + storage_file = azurerm_private_dns_zone.storage_file[0].id + storage_table = azurerm_private_dns_zone.storage_table[0].id + storage_queue = azurerm_private_dns_zone.storage_queue[0].id + key_vault = azurerm_private_dns_zone.key_vault[0].id + container_registry = azurerm_private_dns_zone.container_registry[0].id + openai = azurerm_private_dns_zone.openai[0].id + aifoundry_api = azurerm_private_dns_zone.aifoundry_api[0].id + aifoundry_notebooks = azurerm_private_dns_zone.aifoundry_notebooks[0].id + aifoundry_services = azurerm_private_dns_zone.aifoundry_services[0].id + cosmos = azurerm_private_dns_zone.cosmos[0].id + aisearch = azurerm_private_dns_zone.aisearch[0].id + } : {} +} diff --git a/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network-preview/providers.tf b/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network/providers.tf similarity index 94% rename from infrastructure/infrastructure-setup-terraform/18-managed-virtual-network-preview/providers.tf rename to infrastructure/infrastructure-setup-terraform/18-managed-virtual-network/providers.tf index 7bbdebe8b..f137e3664 100644 --- a/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network-preview/providers.tf +++ b/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network/providers.tf @@ -1,29 +1,29 @@ -terraform { - required_version = ">= 1.0" - - required_providers { - azurerm = { - source = "hashicorp/azurerm" - version = "~> 4.0" - } - azapi = { - source = "azure/azapi" - version = "~> 2.0" - } - random = { - source = "hashicorp/random" - version = "~> 3.0" - } - } -} - -provider "azurerm" { - subscription_id = var.subscription_id - storage_use_azuread = true - - features { - resource_group { - prevent_deletion_if_contains_resources = false - } - } -} +terraform { + required_version = ">= 1.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.0" + } + azapi = { + source = "azure/azapi" + version = "~> 2.0" + } + random = { + source = "hashicorp/random" + version = "~> 3.0" + } + } +} + +provider "azurerm" { + subscription_id = var.subscription_id + storage_use_azuread = true + + features { + resource_group { + prevent_deletion_if_contains_resources = false + } + } +} diff --git a/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network-preview/storage.tf b/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network/storage.tf similarity index 97% rename from infrastructure/infrastructure-setup-terraform/18-managed-virtual-network-preview/storage.tf rename to infrastructure/infrastructure-setup-terraform/18-managed-virtual-network/storage.tf index 6e1be16a2..702d6c561 100644 --- a/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network-preview/storage.tf +++ b/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network/storage.tf @@ -1,147 +1,147 @@ -# Storage Account -resource "azurerm_storage_account" "main" { - count = var.enable_storage ? 1 : 0 - name = local.storage_name - resource_group_name = azurerm_resource_group.main.name - location = azurerm_resource_group.main.location - account_tier = "Standard" - account_replication_type = "LRS" - account_kind = "StorageV2" - shared_access_key_enabled = false - allow_nested_items_to_be_public = false - - # Disable public network access - only accessible via private endpoints - public_network_access_enabled = false - - # Ignore changes to queue/blob/file/table properties to avoid validation issues - lifecycle { - ignore_changes = [ - queue_properties, - blob_properties, - share_properties - ] - } - - tags = merge( - var.tags, - { - environment = "lab" - } - ) -} - -# Private Endpoint for Blob -resource "azurerm_private_endpoint" "storage_blob" { - count = var.enable_storage && var.enable_networking ? 1 : 0 - name = "${azurerm_storage_account.main[0].name}-blob-pe" - resource_group_name = azurerm_resource_group.main.name - location = azurerm_resource_group.main.location - subnet_id = azurerm_subnet.private_endpoints[0].id - - private_service_connection { - name = "${azurerm_storage_account.main[0].name}-blob-psc" - private_connection_resource_id = azurerm_storage_account.main[0].id - is_manual_connection = false - subresource_names = ["blob"] - } - - private_dns_zone_group { - name = "blob-dns-zone-group" - private_dns_zone_ids = [azurerm_private_dns_zone.storage_blob[0].id] - } -} - -# Private Endpoint for File -resource "azurerm_private_endpoint" "storage_file" { - count = var.enable_storage && var.enable_networking ? 1 : 0 - name = "${azurerm_storage_account.main[0].name}-file-pe" - resource_group_name = azurerm_resource_group.main.name - location = azurerm_resource_group.main.location - subnet_id = azurerm_subnet.private_endpoints[0].id - - private_service_connection { - name = "${azurerm_storage_account.main[0].name}-file-psc" - private_connection_resource_id = azurerm_storage_account.main[0].id - is_manual_connection = false - subresource_names = ["file"] - } - - private_dns_zone_group { - name = "file-dns-zone-group" - private_dns_zone_ids = [azurerm_private_dns_zone.storage_file[0].id] - } -} - -# Private Endpoint for Table -resource "azurerm_private_endpoint" "storage_table" { - count = var.enable_storage && var.enable_networking ? 1 : 0 - name = "${azurerm_storage_account.main[0].name}-table-pe" - resource_group_name = azurerm_resource_group.main.name - location = azurerm_resource_group.main.location - subnet_id = azurerm_subnet.private_endpoints[0].id - - private_service_connection { - name = "${azurerm_storage_account.main[0].name}-table-psc" - private_connection_resource_id = azurerm_storage_account.main[0].id - is_manual_connection = false - subresource_names = ["table"] - } - - private_dns_zone_group { - name = "table-dns-zone-group" - private_dns_zone_ids = [azurerm_private_dns_zone.storage_table[0].id] - } -} - -# Private Endpoint for Queue -resource "azurerm_private_endpoint" "storage_queue" { - count = var.enable_storage && var.enable_networking ? 1 : 0 - name = "${azurerm_storage_account.main[0].name}-queue-pe" - resource_group_name = azurerm_resource_group.main.name - location = azurerm_resource_group.main.location - subnet_id = azurerm_subnet.private_endpoints[0].id - - private_service_connection { - name = "${azurerm_storage_account.main[0].name}-queue-psc" - private_connection_resource_id = azurerm_storage_account.main[0].id - is_manual_connection = false - subresource_names = ["queue"] - } - - private_dns_zone_group { - name = "queue-dns-zone-group" - private_dns_zone_ids = [azurerm_private_dns_zone.storage_queue[0].id] - } -} - -# Role Assignment: Current user needs Storage Blob Data Contributor for Terraform to manage storage -resource "azurerm_role_assignment" "current_user_storage_blob" { - count = var.enable_storage ? 1 : 0 - scope = azurerm_storage_account.main[0].id - role_definition_name = "Storage Blob Data Contributor" - principal_id = data.azurerm_client_config.current.object_id -} - -# Role Assignment: Current user needs Storage Queue Data Contributor -resource "azurerm_role_assignment" "current_user_storage_queue" { - count = var.enable_storage ? 1 : 0 - scope = azurerm_storage_account.main[0].id - role_definition_name = "Storage Queue Data Contributor" - principal_id = data.azurerm_client_config.current.object_id -} - -# Role Assignment: Current user needs Storage File Data SMB Share Contributor -resource "azurerm_role_assignment" "current_user_storage_file" { - count = var.enable_storage ? 1 : 0 - scope = azurerm_storage_account.main[0].id - role_definition_name = "Storage File Data SMB Share Contributor" - principal_id = data.azurerm_client_config.current.object_id -} - -# Role Assignment: Current user needs Storage Table Data Contributor -resource "azurerm_role_assignment" "current_user_storage_table" { - count = var.enable_storage ? 1 : 0 - scope = azurerm_storage_account.main[0].id - role_definition_name = "Storage Table Data Contributor" - principal_id = data.azurerm_client_config.current.object_id -} +# Storage Account +resource "azurerm_storage_account" "main" { + count = var.enable_storage ? 1 : 0 + name = local.storage_name + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + account_tier = "Standard" + account_replication_type = "LRS" + account_kind = "StorageV2" + shared_access_key_enabled = false + allow_nested_items_to_be_public = false + + # Disable public network access - only accessible via private endpoints + public_network_access_enabled = false + + # Ignore changes to queue/blob/file/table properties to avoid validation issues + lifecycle { + ignore_changes = [ + queue_properties, + blob_properties, + share_properties + ] + } + + tags = merge( + var.tags, + { + environment = "lab" + } + ) +} + +# Private Endpoint for Blob +resource "azurerm_private_endpoint" "storage_blob" { + count = var.enable_storage && var.enable_networking ? 1 : 0 + name = "${azurerm_storage_account.main[0].name}-blob-pe" + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + subnet_id = azurerm_subnet.private_endpoints[0].id + + private_service_connection { + name = "${azurerm_storage_account.main[0].name}-blob-psc" + private_connection_resource_id = azurerm_storage_account.main[0].id + is_manual_connection = false + subresource_names = ["blob"] + } + + private_dns_zone_group { + name = "blob-dns-zone-group" + private_dns_zone_ids = [azurerm_private_dns_zone.storage_blob[0].id] + } +} + +# Private Endpoint for File +resource "azurerm_private_endpoint" "storage_file" { + count = var.enable_storage && var.enable_networking ? 1 : 0 + name = "${azurerm_storage_account.main[0].name}-file-pe" + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + subnet_id = azurerm_subnet.private_endpoints[0].id + + private_service_connection { + name = "${azurerm_storage_account.main[0].name}-file-psc" + private_connection_resource_id = azurerm_storage_account.main[0].id + is_manual_connection = false + subresource_names = ["file"] + } + + private_dns_zone_group { + name = "file-dns-zone-group" + private_dns_zone_ids = [azurerm_private_dns_zone.storage_file[0].id] + } +} + +# Private Endpoint for Table +resource "azurerm_private_endpoint" "storage_table" { + count = var.enable_storage && var.enable_networking ? 1 : 0 + name = "${azurerm_storage_account.main[0].name}-table-pe" + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + subnet_id = azurerm_subnet.private_endpoints[0].id + + private_service_connection { + name = "${azurerm_storage_account.main[0].name}-table-psc" + private_connection_resource_id = azurerm_storage_account.main[0].id + is_manual_connection = false + subresource_names = ["table"] + } + + private_dns_zone_group { + name = "table-dns-zone-group" + private_dns_zone_ids = [azurerm_private_dns_zone.storage_table[0].id] + } +} + +# Private Endpoint for Queue +resource "azurerm_private_endpoint" "storage_queue" { + count = var.enable_storage && var.enable_networking ? 1 : 0 + name = "${azurerm_storage_account.main[0].name}-queue-pe" + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + subnet_id = azurerm_subnet.private_endpoints[0].id + + private_service_connection { + name = "${azurerm_storage_account.main[0].name}-queue-psc" + private_connection_resource_id = azurerm_storage_account.main[0].id + is_manual_connection = false + subresource_names = ["queue"] + } + + private_dns_zone_group { + name = "queue-dns-zone-group" + private_dns_zone_ids = [azurerm_private_dns_zone.storage_queue[0].id] + } +} + +# Role Assignment: Current user needs Storage Blob Data Contributor for Terraform to manage storage +resource "azurerm_role_assignment" "current_user_storage_blob" { + count = var.enable_storage ? 1 : 0 + scope = azurerm_storage_account.main[0].id + role_definition_name = "Storage Blob Data Contributor" + principal_id = data.azurerm_client_config.current.object_id +} + +# Role Assignment: Current user needs Storage Queue Data Contributor +resource "azurerm_role_assignment" "current_user_storage_queue" { + count = var.enable_storage ? 1 : 0 + scope = azurerm_storage_account.main[0].id + role_definition_name = "Storage Queue Data Contributor" + principal_id = data.azurerm_client_config.current.object_id +} + +# Role Assignment: Current user needs Storage File Data SMB Share Contributor +resource "azurerm_role_assignment" "current_user_storage_file" { + count = var.enable_storage ? 1 : 0 + scope = azurerm_storage_account.main[0].id + role_definition_name = "Storage File Data SMB Share Contributor" + principal_id = data.azurerm_client_config.current.object_id +} + +# Role Assignment: Current user needs Storage Table Data Contributor +resource "azurerm_role_assignment" "current_user_storage_table" { + count = var.enable_storage ? 1 : 0 + scope = azurerm_storage_account.main[0].id + role_definition_name = "Storage Table Data Contributor" + principal_id = data.azurerm_client_config.current.object_id +} diff --git a/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network-preview/terraform.tfvars.example b/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network/terraform.tfvars.example similarity index 97% rename from infrastructure/infrastructure-setup-terraform/18-managed-virtual-network-preview/terraform.tfvars.example rename to infrastructure/infrastructure-setup-terraform/18-managed-virtual-network/terraform.tfvars.example index 75a56c12d..34ccbebd2 100644 --- a/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network-preview/terraform.tfvars.example +++ b/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network/terraform.tfvars.example @@ -1,42 +1,42 @@ -# Example Terraform Variables File -# Copy this file to terraform.tfvars and update with your values -subscription_id = "%subscription_id%" -resource_group_name = "foundry-mvnet-rg" -location = "eastus2" -foundry_identifier = "foundry-v1" # Avoid issues with soft delete, helps make the resource unique - -# Tags to apply to all resources -tags = { - Environment = "lab" - Project = "AI Foundry" - ManagedBy = "Terraform" -} - -# Feature Flags - Enable/disable resources -enable_networking = true -enable_storage = true -enable_aisearch = true -enable_cosmos = true -enable_vm = false -enable_dns = true - -# Network Configuration (only used if enable_networking = true) -vnet_name = "foundry-vnet" -vnet_address_prefix = "10.0.0.0/16" -private_endpoints_subnet_name = "private-endpoints-subnet" -private_endpoints_subnet_prefix = "10.0.1.0/24" -vm_subnet_name = "vm-subnet" -vm_subnet_prefix = "10.0.2.0/24" -bastion_subnet_prefix = "10.0.3.0/26" - -# Bastion Configuration (only used if enable_vm = true) -bastion_name = "foundry-bastion" - -# Virtual Machine Configuration (only used if enable_vm = true) -vm_name = "foundry-vm" -vm_admin_username = "azureadmin" - -# Key Vault Network Configuration -allowed_public_ips = ["0.0.0.0/32"] # Replace with your public IP in CIDR format (e.g., "1.2.3.4/32") - -# Note: VM admin password is automatically generated and stored in Azure Key Vault +# Example Terraform Variables File +# Copy this file to terraform.tfvars and update with your values +subscription_id = "%subscription_id%" +resource_group_name = "foundry-mvnet-rg" +location = "eastus2" +foundry_identifier = "foundry-v1" # Avoid issues with soft delete, helps make the resource unique + +# Tags to apply to all resources +tags = { + Environment = "lab" + Project = "AI Foundry" + ManagedBy = "Terraform" +} + +# Feature Flags - Enable/disable resources +enable_networking = true +enable_storage = true +enable_aisearch = true +enable_cosmos = true +enable_vm = false +enable_dns = true + +# Network Configuration (only used if enable_networking = true) +vnet_name = "foundry-vnet" +vnet_address_prefix = "10.0.0.0/16" +private_endpoints_subnet_name = "private-endpoints-subnet" +private_endpoints_subnet_prefix = "10.0.1.0/24" +vm_subnet_name = "vm-subnet" +vm_subnet_prefix = "10.0.2.0/24" +bastion_subnet_prefix = "10.0.3.0/26" + +# Bastion Configuration (only used if enable_vm = true) +bastion_name = "foundry-bastion" + +# Virtual Machine Configuration (only used if enable_vm = true) +vm_name = "foundry-vm" +vm_admin_username = "azureadmin" + +# Key Vault Network Configuration +allowed_public_ips = ["0.0.0.0/32"] # Replace with your public IP in CIDR format (e.g., "1.2.3.4/32") + +# Note: VM admin password is automatically generated and stored in Azure Key Vault diff --git a/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network-preview/variables.tf b/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network/variables.tf similarity index 95% rename from infrastructure/infrastructure-setup-terraform/18-managed-virtual-network-preview/variables.tf rename to infrastructure/infrastructure-setup-terraform/18-managed-virtual-network/variables.tf index cd39bb89a..3f4db5731 100644 --- a/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network-preview/variables.tf +++ b/infrastructure/infrastructure-setup-terraform/18-managed-virtual-network/variables.tf @@ -1,130 +1,130 @@ -variable "subscription_id" { - description = "The Azure subscription ID" - type = string -} - -variable "resource_group_name" { - description = "The name of the resource group" - type = string -} - -variable "location" { - description = "Azure region for resources" - type = string - default = "uaenorth" -} - -variable "foundry_identifier" { - description = "Unique identifier for the AI Foundry account name (change this to recreate the foundry account)" - type = string - default = "foundry" -} - -variable "tags" { - description = "A map of tags to apply to all resources" - type = map(string) - default = {} -} - -variable "allowed_public_ips" { - description = "List of public IP addresses (CIDR format) allowed to access Key Vault" - type = list(string) - default = [] -} - -# Feature flags for optional resources -variable "enable_networking" { - description = "Enable VNet, subnets, and network infrastructure" - type = bool - default = false -} - -variable "enable_storage" { - description = "Enable Storage Account and its private endpoints" - type = bool - default = false -} - -variable "enable_aisearch" { - description = "Enable AI Search Service and its private endpoint" - type = bool - default = false -} - -variable "enable_cosmos" { - description = "Enable Cosmos DB Account and its private endpoint" - type = bool - default = false -} - -variable "enable_vm" { - description = "Enable Windows VM and Azure Bastion" - type = bool - default = false -} - -variable "enable_dns" { - description = "Enable Private DNS Zones and VNet links" - type = bool - default = false -} - -variable "vnet_name" { - description = "Name of the virtual network" - type = string - default = "vnet-aifoundry" -} - -variable "vnet_address_prefix" { - description = "Address prefix for the virtual network" - type = string - default = "10.0.0.0/16" -} - -variable "private_endpoints_subnet_name" { - description = "Name of the private endpoints subnet" - type = string - default = "snet-privateendpoints" -} - -variable "private_endpoints_subnet_prefix" { - description = "Address prefix for private endpoints subnet" - type = string - default = "10.0.1.0/24" -} - -variable "vm_subnet_name" { - description = "Name of the VM subnet" - type = string - default = "snet-vms" -} - -variable "vm_subnet_prefix" { - description = "Address prefix for VM subnet" - type = string - default = "10.0.2.0/24" -} - -variable "bastion_subnet_prefix" { - description = "Address prefix for Azure Bastion subnet" - type = string - default = "10.0.3.0/26" -} - -variable "bastion_name" { - description = "Name of Azure Bastion" - type = string - default = "bastion-aifoundry" -} - -variable "vm_name" { - description = "Name of the virtual machine" - type = string - default = "vm-win2025" -} - -variable "vm_admin_username" { - description = "Admin username for the VM" - type = string - sensitive = true -} +variable "subscription_id" { + description = "The Azure subscription ID" + type = string +} + +variable "resource_group_name" { + description = "The name of the resource group" + type = string +} + +variable "location" { + description = "Azure region for resources" + type = string + default = "uaenorth" +} + +variable "foundry_identifier" { + description = "Unique identifier for the AI Foundry account name (change this to recreate the foundry account)" + type = string + default = "foundry" +} + +variable "tags" { + description = "A map of tags to apply to all resources" + type = map(string) + default = {} +} + +variable "allowed_public_ips" { + description = "List of public IP addresses (CIDR format) allowed to access Key Vault" + type = list(string) + default = [] +} + +# Feature flags for optional resources +variable "enable_networking" { + description = "Enable VNet, subnets, and network infrastructure" + type = bool + default = false +} + +variable "enable_storage" { + description = "Enable Storage Account and its private endpoints" + type = bool + default = false +} + +variable "enable_aisearch" { + description = "Enable AI Search Service and its private endpoint" + type = bool + default = false +} + +variable "enable_cosmos" { + description = "Enable Cosmos DB Account and its private endpoint" + type = bool + default = false +} + +variable "enable_vm" { + description = "Enable Windows VM and Azure Bastion" + type = bool + default = false +} + +variable "enable_dns" { + description = "Enable Private DNS Zones and VNet links" + type = bool + default = false +} + +variable "vnet_name" { + description = "Name of the virtual network" + type = string + default = "vnet-aifoundry" +} + +variable "vnet_address_prefix" { + description = "Address prefix for the virtual network" + type = string + default = "10.0.0.0/16" +} + +variable "private_endpoints_subnet_name" { + description = "Name of the private endpoints subnet" + type = string + default = "snet-privateendpoints" +} + +variable "private_endpoints_subnet_prefix" { + description = "Address prefix for private endpoints subnet" + type = string + default = "10.0.1.0/24" +} + +variable "vm_subnet_name" { + description = "Name of the VM subnet" + type = string + default = "snet-vms" +} + +variable "vm_subnet_prefix" { + description = "Address prefix for VM subnet" + type = string + default = "10.0.2.0/24" +} + +variable "bastion_subnet_prefix" { + description = "Address prefix for Azure Bastion subnet" + type = string + default = "10.0.3.0/26" +} + +variable "bastion_name" { + description = "Name of Azure Bastion" + type = string + default = "bastion-aifoundry" +} + +variable "vm_name" { + description = "Name of the virtual machine" + type = string + default = "vm-win2025" +} + +variable "vm_admin_username" { + description = "Admin username for the VM" + type = string + sensitive = true +} diff --git a/migration/README.md b/migration/README.md index f1e317dd9..5087ffc45 100644 --- a/migration/README.md +++ b/migration/README.md @@ -72,7 +72,7 @@ Ready-to-run: ## RBAC β€” Required permissions -The signed-in user needs **Azure AI User** on the AI Services resource. +The signed-in user needs **Foundry User** on the AI Services resource. Without this role you will see `401` or `403` errors. ### Check your current roles @@ -87,7 +87,7 @@ az role assignment list --assignee "nikhowlett@microsoft.com" ` ```powershell az role assignment create ` - --role "Azure AI User" ` + --role "Foundry User" ` --assignee "nikhowlett@microsoft.com" ` --scope "/subscriptions/47f1c914-e299-4953-a99d-3e34644cfe1c/resourceGroups/rg-nikhowlett-6102/providers/Microsoft.CognitiveServices/accounts/nikhowlett-6102-resource" ``` @@ -158,8 +158,8 @@ Linux / macOS: use `./migrate.sh` with the same flags. | `failed to connect to the docker API` | Docker Desktop is not running β€” start it and wait for the whale icon | | `AADSTS53003` or `You don't have access to this` | Corporate Conditional Access blocked sign-in inside Linux containers β€” use host Azure CLI login; `migrate-docker.ps1` now does this automatically | | `az : The term 'az' is not recognized` | Install Azure CLI, or rerun `migrate-docker.ps1` on Windows and let it install Azure CLI with `winget` | -| `401 Unauthorized` | Check RBAC β€” you need **Azure AI User** on the resource (see above) | -| `403 Forbidden` | Assign **Azure AI User** role (see RBAC section above) | +| `401 Unauthorized` | Check RBAC β€” you need **Foundry User** on the resource (see above) | +| `403 Forbidden` | Assign **Foundry User** role (see RBAC section above) | | `Could not switch to subscription` | Run `az login --tenant 72f988bf-86f1-41af-91ab-2d7cd011db47` | | Items missing from `--list` | Both endpoints are checked; if still missing the item may have been deleted | | Docker Desktop crashes on startup | Delete `%USERPROFILE%\.docker\contexts\meta` and restart | diff --git a/migration/run-migration-docker-auth.ps1 b/migration/run-migration-docker-auth.ps1 index c22089459..58d956928 100644 --- a/migration/run-migration-docker-auth.ps1 +++ b/migration/run-migration-docker-auth.ps1 @@ -332,9 +332,9 @@ try { if ($exitCode -eq 1) { Write-Host "" Write-Host "${Yellow}πŸ” If you saw 401/403 errors, check your RBAC role assignments:${Reset}" - Write-Host " β€’ Reading assistants/agents : ${Blue}Azure AI User${Reset} on the resource (minimum for Foundry)" - Write-Host " β€’ Creating/writing agents : ${Blue}Azure AI User${Reset} on the resource" - Write-Host " β€’ Uploading/downloading files: ${Blue}Azure AI User${Reset} on the resource" + Write-Host " β€’ Reading assistants/agents : ${Blue}Foundry User${Reset} on the resource (minimum for Foundry)" + Write-Host " β€’ Creating/writing agents : ${Blue}Foundry User${Reset} on the resource" + Write-Host " β€’ Uploading/downloading files: ${Blue}Foundry User${Reset} on the resource" Write-Host " β€’ Managing connections (ARM) : ${Blue}Contributor${Reset} on the resource" Write-Host "" Write-Host " Assign via: Azure portal β†’ Resource β†’ Access control (IAM) β†’ Add role assignment" diff --git a/migration/run-migration-docker-auth.sh b/migration/run-migration-docker-auth.sh index a73bc4d8f..2c6e48882 100644 --- a/migration/run-migration-docker-auth.sh +++ b/migration/run-migration-docker-auth.sh @@ -245,9 +245,9 @@ else if [ $EXIT_CODE -eq 1 ]; then echo "" echo -e "${YELLOW}πŸ” If you saw 401/403 errors, check your RBAC role assignments:${NC}" - echo " β€’ Reading assistants/agents : Azure AI User on the resource (minimum for Foundry)" - echo " β€’ Creating/writing agents : Azure AI User on the resource" - echo " β€’ Uploading/downloading files: Azure AI User on the resource" + echo " β€’ Reading assistants/agents : Foundry User on the resource (minimum for Foundry)" + echo " β€’ Creating/writing agents : Foundry User on the resource" + echo " β€’ Uploading/downloading files: Foundry User on the resource" echo " β€’ Managing connections (ARM) : Contributor on the resource" echo "" echo " Assign via: Azure portal β†’ Resource β†’ Access control (IAM) β†’ Add role assignment" diff --git a/migration/v1_to_v2_migration.py b/migration/v1_to_v2_migration.py index b2a911a6d..965384322 100644 --- a/migration/v1_to_v2_migration.py +++ b/migration/v1_to_v2_migration.py @@ -442,29 +442,29 @@ def _print_rbac_guidance(status_code: int, url: str, method: str = "GET"): print("β•‘ Scope : Resource / resource-group β•‘") elif is_files and is_write: print("β•‘ Operation : Upload files to target β•‘") - print("β•‘ Role : Azure AI User (dataActions: CognitiveServices/*) β•‘") + print("β•‘ Role : Foundry User (dataActions: CognitiveServices/*) β•‘") print("β•‘ Scope : Foundry resource or project β•‘") elif is_files: print("β•‘ Operation : Download/list files from source β•‘") - print("β•‘ Role : Azure AI User or Cognitive Services OpenAI User β•‘") + print("β•‘ Role : Foundry User or Cognitive Services OpenAI User β•‘") print("β•‘ Scope : Source AI resource β•‘") elif (is_agents or is_assistants) and is_write: print("β•‘ Operation : Create/write v2 agents β•‘") - print("β•‘ Role : Azure AI User (dataActions: CognitiveServices/*) β•‘") + print("β•‘ Role : Foundry User (dataActions: CognitiveServices/*) β•‘") print("β•‘ Scope : Foundry resource or project β•‘") print("β•‘ NOTE : 'Cognitive Services OpenAI User' IS sufficient for β•‘") - print("β•‘ assistants/* but Azure AI User is recommended. β•‘") + print("β•‘ assistants/* but Foundry User is recommended. β•‘") elif is_agents or is_assistants: print("β•‘ Operation : Read assistants / agents β•‘") - print("β•‘ Role : Azure AI User or Cognitive Services OpenAI User β•‘") + print("β•‘ Role : Foundry User or Cognitive Services OpenAI User β•‘") print("β•‘ Scope : Source or target AI resource β•‘") else: print("β•‘ Operation : General data-plane call β•‘") - print("β•‘ Role : Azure AI User (recommended minimum for Foundry) β•‘") + print("β•‘ Role : Foundry User (recommended minimum for Foundry) β•‘") print("β•‘ β•‘") print("╠══════════════════════════════════════════════════════════════════════╣") print("β•‘ Assign roles: Azure portal β†’ Resource β†’ Access control (IAM) β•‘") - print("β•‘ Or: az role assignment create --role 'Azure AI User' β•‘") + print("β•‘ Or: az role assignment create --role 'Foundry User' β•‘") print("β•‘ --assignee --scope /subscriptions//... β•‘") print(f"β•‘ {RBAC_DOCS.splitlines()[0]:<67s}β•‘") print(f"β•‘ {RBAC_DOCS.splitlines()[1]:<67s}β•‘") diff --git a/samples-mistral/python/0000950170-25-100226.pdf b/samples-mistral/python/0000950170-25-100226.pdf index 09918b716..83440cb40 100644 Binary files a/samples-mistral/python/0000950170-25-100226.pdf and b/samples-mistral/python/0000950170-25-100226.pdf differ diff --git a/samples/REST/model-router/deploy-model-router-all-configs.sh b/samples/REST/model-router/deploy-model-router-all-configs.sh new file mode 100644 index 000000000..4690352d1 --- /dev/null +++ b/samples/REST/model-router/deploy-model-router-all-configs.sh @@ -0,0 +1,82 @@ +# Deploys a model-router deployment with both a routing mode and a custom model subset. +# WSL example: bash /mnt/c/Work/repos/foundry-samples-pr/samples/REST/model-router/deploy-model-router-all-configs.sh quality + +SUBSCRIPTION_ID="" +RESOURCE_GROUP="" +ACCOUNT_NAME="" +DEPLOYMENT_NAME="" +API_VERSION="2025-10-01-preview" +SKU_NAME="GlobalStandard" +SKU_CAPACITY="10" +MODEL_ROUTER_FORMAT="OpenAI" +MODEL_ROUTER_NAME="model-router" +MODEL_ROUTER_VERSION="2025-11-18" +# Valid values: balanced (default) | cost | quality +ROUTING_MODE="balanced" +SUBSET_1_FORMAT="OpenAI" +SUBSET_1_NAME="gpt-4.1" +SUBSET_1_VERSION="2025-04-14" +SUBSET_2_FORMAT="OpenAI" +SUBSET_2_NAME="gpt-5.2-chat" +SUBSET_2_VERSION="2025-12-11" +SUBSET_3_FORMAT="Meta" +SUBSET_3_NAME="Llama-4-Maverick-17B-128E-Instruct-FP8" +SUBSET_3_VERSION="1" + +if [ $# -gt 0 ]; then + ROUTING_MODE="$1" +fi + +if [ -z "${AZURE_AI_AUTH_TOKEN:-}" ]; then + AZURE_AI_AUTH_TOKEN="$(az account get-access-token --resource https://management.azure.com --query accessToken -o tsv)" +fi + +case "${ROUTING_MODE}" in + balanced|cost|quality) + ;; + *) + echo "Invalid ROUTING_MODE: ${ROUTING_MODE}. Valid values: balanced | cost | quality" >&2 + exit 1 + ;; +esac + +# +curl -X PUT "https://management.azure.com/subscriptions/${SUBSCRIPTION_ID}/resourceGroups/${RESOURCE_GROUP}/providers/Microsoft.CognitiveServices/accounts/${ACCOUNT_NAME}/deployments/${DEPLOYMENT_NAME}?api-version=${API_VERSION}" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $AZURE_AI_AUTH_TOKEN" \ + -d @- < diff --git a/samples/REST/model-router/deploy-model-router-model-subset.sh b/samples/REST/model-router/deploy-model-router-model-subset.sh new file mode 100644 index 000000000..6763509a5 --- /dev/null +++ b/samples/REST/model-router/deploy-model-router-model-subset.sh @@ -0,0 +1,66 @@ +# Deploys a model-router deployment that routes only to a custom subset of models. +# WSL example: bash /mnt/c/Work/repos/foundry-samples-pr/samples/REST/model-router/deploy-model-router-model-subset.sh + +SUBSCRIPTION_ID="" +RESOURCE_GROUP="" +ACCOUNT_NAME="" +DEPLOYMENT_NAME="" +API_VERSION="2025-10-01-preview" +SKU_NAME="GlobalStandard" +SKU_CAPACITY="10" +MODEL_ROUTER_FORMAT="OpenAI" +MODEL_ROUTER_NAME="model-router" +MODEL_ROUTER_VERSION="2025-11-18" +SUBSET_1_FORMAT="OpenAI" +SUBSET_1_NAME="gpt-4.1" +SUBSET_1_VERSION="2025-04-14" +SUBSET_2_FORMAT="OpenAI" +SUBSET_2_NAME="gpt-5.2-chat" +SUBSET_2_VERSION="2025-12-11" +SUBSET_3_FORMAT="Meta" +SUBSET_3_NAME="Llama-4-Maverick-17B-128E-Instruct-FP8" +SUBSET_3_VERSION="1" + +if [ -z "${AZURE_AI_AUTH_TOKEN:-}" ]; then + AZURE_AI_AUTH_TOKEN="$(az account get-access-token --resource https://management.azure.com --query accessToken -o tsv)" +fi + +# +curl -X PUT "https://management.azure.com/subscriptions/${SUBSCRIPTION_ID}/resourceGroups/${RESOURCE_GROUP}/providers/Microsoft.CognitiveServices/accounts/${ACCOUNT_NAME}/deployments/${DEPLOYMENT_NAME}?api-version=${API_VERSION}" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $AZURE_AI_AUTH_TOKEN" \ + -d @- < \ No newline at end of file diff --git a/samples/REST/model-router/deploy-model-router-routing-mode.sh b/samples/REST/model-router/deploy-model-router-routing-mode.sh new file mode 100644 index 000000000..81a62bf8b --- /dev/null +++ b/samples/REST/model-router/deploy-model-router-routing-mode.sh @@ -0,0 +1,57 @@ + +# Deploys a model-router deployment with a selected routing mode. +# WSL example: bash /mnt/c/Work/repos/foundry-samples-pr/samples/REST/model-router/deploy-model-router-routing-mode.sh quality + +SUBSCRIPTION_ID="" +RESOURCE_GROUP="" +ACCOUNT_NAME="" +DEPLOYMENT_NAME="" +API_VERSION="2025-10-01-preview" +# Valid values: balanced (default) | cost | quality +ROUTING_MODE="balanced" +SKU_NAME="GlobalStandard" +SKU_CAPACITY="10" +MODEL_FORMAT="OpenAI" +MODEL_NAME="model-router" +MODEL_VERSION="2025-11-18" + +if [ $# -gt 0 ]; then + ROUTING_MODE="$1" +fi + +if [ -z "${AZURE_AI_AUTH_TOKEN:-}" ]; then + AZURE_AI_AUTH_TOKEN="$(az account get-access-token --resource https://management.azure.com --query accessToken -o tsv)" +fi + +case "${ROUTING_MODE}" in + balanced|cost|quality) + ;; + *) + echo "Invalid ROUTING_MODE: ${ROUTING_MODE}. Valid values: balanced | cost | quality" >&2 + exit 1 + ;; +esac + +# +curl -X PUT "https://management.azure.com/subscriptions/${SUBSCRIPTION_ID}/resourceGroups/${RESOURCE_GROUP}/providers/Microsoft.CognitiveServices/accounts/${ACCOUNT_NAME}/deployments/${DEPLOYMENT_NAME}?api-version=${API_VERSION}" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $AZURE_AI_AUTH_TOKEN" \ + -d @- < \ No newline at end of file diff --git a/samples/REST/quickstart/quickstart-responses.sh b/samples/REST/quickstart/quickstart-responses.sh index f030676af..e50040930 100644 --- a/samples/REST/quickstart/quickstart-responses.sh +++ b/samples/REST/quickstart/quickstart-responses.sh @@ -2,6 +2,6 @@ curl -X POST https://YOUR-FOUNDRY-RESOURCE-NAME.services.ai.azure.com/api/projec -H "Content-Type: application/json" \ -H "Authorization: Bearer $AZURE_AI_AUTH_TOKEN" \ -d '{ - "model": "gpt-4.1-mini", + "model": "gpt-5.1-mini", "input": "What is the size of France in square miles?" -}' \ No newline at end of file +}' diff --git a/samples/cli/training/README.md b/samples/cli/training/README.md new file mode 100644 index 000000000..6c58948e3 --- /dev/null +++ b/samples/cli/training/README.md @@ -0,0 +1,34 @@ +# Training Job Templates + +This directory contains starter templates for submitting training jobs in Azure AI Foundry using the Azure Developer CLI (azd) training extension (`azure.ai.training`). + +The goal is a code-first, automatable CLI workflow for custom training jobs in Foundry, optimized for ML engineers and data scientists who prefer terminal workflows and need repeatable execution. + +When you run `azd ai training init` with a template flag, these templates are pulled locally to provide sample configurations for your training jobs: + +```bash +azd ai training init -t +``` + +## Templates + +| Template | Link | Description | +|----------|------|-------------| +| Hello World | [sample_hello_world.yaml](sample_hello_world.yaml) | Minimal `commandJob` template that echoes `"hello world"`. Useful as a starting point to validate your compute and environment setup. | +| Custom Training Job | [sample_training_job.yaml](sample_training_job.yaml) | End-to-end `commandJob` that runs a Python training script ([src/train.py](src/train.py)) over a JSONL dataset ([train_data/sample.jsonl](train_data/sample.jsonl)) and writes a result file output. | + +## Layout + +``` +training/ +β”œβ”€β”€ sample_hello_world.yaml # Minimal job +β”œβ”€β”€ sample_training_job.yaml # Custom code training job +β”œβ”€β”€ src/ +β”‚ └── train.py # Training script invoked by sample_training_job.yaml +└── train_data/ + └── sample.jsonl # Sample input data mounted as the train_data input +``` + +## Placeholders + +Templates use `` for fields that depend on the user's workspace or job requirements, such as `identity`, `compute`, `environment`, `instance_type`, `slaTier`, and `priority`. Replace these values before submitting the job. \ No newline at end of file diff --git a/samples/cli/training/sample_hello_world.yaml b/samples/cli/training/sample_hello_world.yaml new file mode 100644 index 000000000..a40adf013 --- /dev/null +++ b/samples/cli/training/sample_hello_world.yaml @@ -0,0 +1,15 @@ +$schema: https://azuremlschemas.azureedge.net/latest/commandJob.schema.json +type: command +display_name: cli-hello-world-sample +description: Sample hello world job using AZD Foundry Training CLI +command: echo "hello world" +environment: +compute: +resources: + instance_count: 1 + instance_type: + properties: + AISuperComputer: + imageVersion: "" + slaTier: + priority: \ No newline at end of file diff --git a/samples/cli/training/sample_training_job.yaml b/samples/cli/training/sample_training_job.yaml new file mode 100644 index 000000000..61346af51 --- /dev/null +++ b/samples/cli/training/sample_training_job.yaml @@ -0,0 +1,28 @@ +$schema: https://azuremlschemas.azureedge.net/latest/commandJob.schema.json +type: command +display_name: cli-training-job-sample +description: Sample training job using AZD Foundry Training CLI +command: python ${{inputs.code_dataset}}/train.py --input-folder ${{inputs.train_data}} --input-file-name "sample.jsonl" --result-file ${{outputs.result_file}} +identity: +environment: +compute: +resources: + instance_count: 1 + instance_type: + properties: + AISuperComputer: + imageVersion: "" + slaTier: + priority: +inputs: + train_data: + type: uri_folder + path: .\train_data + mode: download + code_dataset: + type: uri_folder + path: .\src + mode: download +outputs: + result_file: + type: uri_folder \ No newline at end of file diff --git a/samples/cli/training/src/train.py b/samples/cli/training/src/train.py new file mode 100644 index 000000000..6a0b3ae50 --- /dev/null +++ b/samples/cli/training/src/train.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import argparse +import json +import random +from pathlib import Path +from time import time + +import mlflow + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Print the provided JSONL input folder contents to stdout." + ) + parser.add_argument( + "--input-folder", + required=True, + help="Path to the mounted JSONL input folder.", + ) + parser.add_argument( + "--input-file-name", + default="sample.jsonl", + help="JSONL file name to read from the mounted input folder.", + ) + parser.add_argument( + "--result-file", + required=True, + help="Path to the output folder where result_file will be written.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + input_folder = Path(args.input_folder).expanduser() + if not input_folder.exists(): + raise FileNotFoundError(f"Input folder does not exist: {input_folder}") + if not input_folder.is_dir(): + raise NotADirectoryError(f"Expected a folder path but got a file: {input_folder}") + + input_path = input_folder / args.input_file_name + if not input_path.exists(): + raise FileNotFoundError( + f"Input file does not exist under the mounted folder: {input_path}" + ) + + print(f"Reading input file from folder: {input_path}") + print("=== BEGIN RAW INPUT ===") + raw_text = input_path.read_text(encoding="utf-8") + print(raw_text.rstrip()) + print("=== END RAW INPUT ===") + + print("=== BEGIN PARSED JSONL ===") + for line_number, line in enumerate(raw_text.splitlines(), start=1): + if not line.strip(): + continue + record = json.loads(line) + print(f"Line {line_number}:") + print(json.dumps(record, indent=2, sort_keys=True)) + print("=== END PARSED JSONL ===") + + # ── MLflow logging ───────────────────────────────────────────────── + print("=== BEGIN MLFLOW LOGGING ===") + mlflow.autolog(disable=True) + + num_epochs = 5 + for epoch in range(1, num_epochs + 1): + loss = 1.0 / epoch + random.uniform(-0.05, 0.05) + accuracy = 1.0 - loss / 2.0 + mlflow.log_metrics({"loss": loss, "accuracy": accuracy}, step=epoch) + print(f" Epoch {epoch}: loss={loss:.4f} accuracy={accuracy:.4f}") + + mlflow.log_params({ + "epochs": num_epochs, + "learning_rate": 0.001, + "batch_size": 32, + "optimizer": "adam", + }) + mlflow.log_metric("final_loss", loss) + mlflow.log_metric("final_accuracy", accuracy) + print("=== END MLFLOW LOGGING ===") + + # ── Write result_file output ─────────────────────────────────────── + output_folder = Path(args.result_file).expanduser() + output_folder.mkdir(parents=True, exist_ok=True) + result_path = output_folder / "result_file" + result_path.write_text( + json.dumps( + { + "final_loss": loss, + "final_accuracy": accuracy, + "epochs": num_epochs, + "input_file": str(input_path), + }, + indent=2, + ), + encoding="utf-8", + ) + print(f"Wrote result file to: {result_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/samples/cli/training/train_data/sample.jsonl b/samples/cli/training/train_data/sample.jsonl new file mode 100644 index 000000000..8c9cfb431 --- /dev/null +++ b/samples/cli/training/train_data/sample.jsonl @@ -0,0 +1,2 @@ +{"query":"What is the importance of choosing the right provider in getting the most value out of your health insurance plan?","ground_truth":"Choosing the right provider is an important part of getting the most value out of your health insurance plan. With Northwind Health Plus, you have access to an extensive network of in-network providers. Working with these providers is an essential part of getting the most value out of your plan.\n[Northwind_Health_Plus_Benefits_Details-3.pdf]","response":"Choosing the right provider is important in getting the most value out of your health insurance plan for several reasons.\n\n1. Cost savings: In-network providers offer cost savings compared to out-of-network providers [Northwind_Health_Plus_Benefits_Details.pdf]. By choosing an in-network provider, you can minimize your out-of-pocket expenses and maximize the benefits provided by your health insurance plan.\n\n2. Coverage: In-network providers are part of the network of healthcare professionals that are contracted with your health insurance plan [Northwind_Health_Plus_Benefits_Details.pdf]. This means that they have agreed to accept the amount of payment offered by your health insurance plan, reducing the portion of the cost you are responsible for.\n\n3. Additional services and discounts: Out-of-network providers may not offer additional services or discounts that are available to in-network providers [Northwind_Standard_Benefits_Details.pdf]. By choosing an in-network provider, you may have access to additional benefits and cost-saving opportunities.\n\n4. Continuity of care: Continuity of care refers to being treated by the same provider or medical team over a period of time [Northwind_Health_Plus_Benefits_Details.pdf]. When you have continuity of care, your provider has a better understanding of your medical history, leading to more accurate diagnoses and treatments. This can result in better quality of care and more effective treatments.\n\n5. Personalized care: When you stay with the same provider for a period of time, they are more likely to know about any changes in your health and can offer more personalized care [Northwind_Health_Plus_Benefits_Details.pdf]. This can lead to better health outcomes and a higher level of satisfaction with your healthcare experience.\n\nIn summary, choosing the right provider is important for cost savings, coverage, continuity of care, and personalized care. By selecting an in-network provider, you can maximize the benefits of your health insurance plan and receive high-quality, affordable healthcare services. [Northwind_Health_Plus_Benefits_Details.pdf][Northwind_Standard_Benefits_Details.pdf]","context":"Northwind_Health_Plus_Benefits_Details.pdf: is in-network. β€’ Use the searchable directory on the Northwind Health Plus website to find in-network providers in your area. β€’ Ask your current provider if they are part of the Northwind Health Plus network. β€’ Consider the cost savings associated with in-network providers when making your decision. β€’ Consider the quality of care when choosing a provider. β€’ Make sure the provider is familiar with your health insurance plan and its coverage. β€’ Make sure the provider is available and can accommodate your schedule. Conclusion Choosing the right provider is an important part of getting the most value out of your health insurance plan. Northwind Health Plus offers an extensive network of in-network providers that can help you save money on health care services. By following the tips outlined above, you can make sure that you are choosing an in-network provider that is most convenient and cost-effective for you and your family. Continuity of Care HOW PROVIDERS AFFECT YOUR COSTS: Continuity of Care It’s important to understand continuity of care when selecting a provider. Continuity of care is the process of being treated by the same provider or medical team over a period of time. When you have continuity of care, your provider has a better understanding of your medical history, enabling them to provide more accurate diagnoses and treatments. Continuity of care is important when selecting a provider because it ensures better quality of care. When you have continuity of care, your provider is more familiar with your medical history, which can lead to more effective treatments. Also, if you stay with the same provider for a period of time, the provider will be more likely to know about any changes in your health and can offer more personalized care. The Northwind Health Plus plan offers coverage for continuity of care. This means that if you have been seeing the same provider for a period of time, you may be able to continue\n\nNorthwind_Standard_Benefits_Details.pdf: Northwind Health. As a result, they are not required to accept the amount of payment offered by Northwind Health, meaning that you may be responsible for a greater portion of the cost. Additionally, out-of-network providers may not offer additional services or discounts that are available to in-network providers. When choosing a provider, it is important to make sure that the provider is in-network. While it is possible to receive care from out-of-network providers, it is important to understand that you will be responsible for a greater portion of the costs. To make sure that you are getting the best value for your health care expenses, it is recommended that you choose an in-network provider whenever possible. There are some exceptions when it comes to receiving care from out-of-network providers. If you are unable to find an in-network provider in your area or if you require a specific type of care that is not available from an in-network provider, you may receive care from an out- of-network provider. In these cases, the cost of care may be more expensive and you may be responsible for a greater portion of the costs. In addition to understanding the differences between in-network and out-of-network providers, it is important to understand the cost sharing associated with each. Northwind Standard does not cover the full cost of care for out-of-network providers, so you may be responsible for a greater portion of the costs. When choosing a provider, it is important to take into account the cost sharing associated with each provider. If you are unable to find an in-network provider in your area or if you require a specific type of care that is not available from an in-network provider, it is important to understand that you may be responsible for a greater portion of the costs. Finally, it is important to be aware of any additional fees that may be associated with receiving care from an out-of-network provider. Some providers may charge additional fees\n\nNorthwind_Standard_Benefits_Details.pdf: weekend hours. Choosing an in-network provider can help you save money on your health care costs. By following the tips above and researching your options, you can find a provider that is convenient, affordable, and in-network with your Northwind Standard plan. Continuity of Care HOW PROVIDERS AFFECT YOUR COSTS: CONTINUITY OF CARE At Contoso, we understand the importance of providing our employees with access to a quality, affordable health care plan. We are proud to offer our employees Northwind Standard, an insurance plan that provides coverage for medical, vision, and dental services. We also offer continuity of care, which is the ability to stay with the same provider for all your health care needs, from routine checkups to specialized care. Continuity of care is an important component of any health care plan, as it allows you to develop a relationship with your doctor, who is more likely to recognize any changes in your health and be able to provide better, more personalized care. With Northwind Standard, you can stay with the same provider throughout your health care journey, helping you to receive the best possible care. Continuity of care also helps you save money on health care costs. When you stay with the same provider, you are likely to pay less than if you switch around from provider to provider. This is because your provider already has all your medical records, and you won’t need to pay for expensive tests and procedures that you’ve already had done. There are a few exceptions to the continuity of care. If you move to a different area, you may need to find a new provider. Additionally, if you are in need of specialized care, such as surgery or a complex treatment, you may need to visit a specialist who does not accept your insurance plan. In these cases, you should be sure to check with your insurance company and/or provider to make sure you understand any associated costs.","latency":8.733296,"response_length":2160} +{"query":"What should you do when choosing an in-network provider for your health care needs?","ground_truth":"When choosing an in-network provider for your health care needs, you should check with Northwind Health Plus to ensure that the provider is in-network. This is important because in-network providers charge lower rates than out-of-network providers.\n[Northwind_Health_Plus_Benefits_Details-3.pdf]","response":"When choosing an in-network provider for your health care needs, there are a few important factors to consider [Northwind_Standard_Benefits_Details.pdf]:\n\n1. Confirm that the provider is in-network with your health insurance plan. You can do this by calling the provider's office or using the provider search tool on the Northwind Health website.\n\n2. Check if the provider is accepting new patients. Some providers may be in-network but not currently accepting new patients.\n\n3. Consider the location of the provider. If the provider is too far away, it may be difficult for you to attend appointments.\n\n4. Take into account the provider's office hours. If you work during the day, you may need to find a provider that offers evening or weekend hours.\n\nChoosing an in-network provider can help you save money on your health care costs. When you choose an in-network provider, you typically pay lower copays and deductibles compared to out-of-network providers. Additionally, many services, such as preventive care, may be covered at no cost when received from an in-network provider [Northwind_Standard_Benefits_Details.pdf].\n\nContinuity of care is also an important consideration. Staying with the same provider allows you to develop a relationship with your doctor, who is more likely to recognize changes in your health and provide personalized care. It can also help you save money by avoiding duplicate tests and procedures. However, there may be exceptions to continuity of care, such as moving to a different area or needing specialized care that is not covered by your insurance plan [Northwind_Standard_Benefits_Details.pdf].","context":"Northwind_Standard_Benefits_Details.pdf: Northwind Health. As a result, they are not required to accept the amount of payment offered by Northwind Health, meaning that you may be responsible for a greater portion of the cost. Additionally, out-of-network providers may not offer additional services or discounts that are available to in-network providers. When choosing a provider, it is important to make sure that the provider is in-network. While it is possible to receive care from out-of-network providers, it is important to understand that you will be responsible for a greater portion of the costs. To make sure that you are getting the best value for your health care expenses, it is recommended that you choose an in-network provider whenever possible. There are some exceptions when it comes to receiving care from out-of-network providers. If you are unable to find an in-network provider in your area or if you require a specific type of care that is not available from an in-network provider, you may receive care from an out-of-network provider. In these cases, the cost of care may be more expensive and you may be responsible for a greater portion of the costs. In addition to understanding the differences between in-network and out-of-network providers, it is important to understand the cost sharing associated with each. Northwind Standard does not cover the full cost of care for out-of-network providers, so you may be responsible for a greater portion of the costs. When choosing a provider, it is important to take into account the cost sharing associated with each provider. If you are unable to find an in-network provider in your area or if you require a specific type of care that is not available from an in-network provider, it is important to understand that you may be responsible for a greater portion of the costs. Finally, it is important to be aware of any additional fees that may be associated with receiving care from an out-of-network provider. Some providers may charge additional fees\n\nNorthwind_Standard_Benefits_Details.pdf: of your Northwind Standard health plan. HOW PROVIDERS AFFECT YOUR COSTS In-Network Providers HOW PROVIDERS AFFECT YOUR COSTS When selecting a health insurance plan, one of the most important factors to consider is the network of in-network providers that are available with the plan. Northwind Standard offers a wide variety of in-network providers, ranging from primary care physicians, specialists, hospitals, and pharmacies. This allows you to choose a provider that is convenient for you and your family, while also helping you to keep your costs low. When you choose a provider that is in-network with your plan, you will typically pay lower copays and deductibles than you would with an out-of-network provider. In addition, many services, such as preventive care, may be covered at no cost when you receive care from an in-network provider. It is important to note, however, that Northwind Standard does not offer coverage for emergency services, mental health and substance abuse coverage, or out-of-network services. This means that you may have to pay out of pocket for these services if you receive them from an out-of-network provider. When choosing an in-network provider, there are a few tips to keep in mind. First, make sure that the provider you choose is in-network with your plan. You can confirm this by calling the provider's office and asking them if they are in-network with Northwind Standard. You can also use the provider search tool on the Northwind Health website to make sure your provider is in-network. Second, make sure that the provider you choose is accepting new patients. Some providers may be in-network but not be taking new patients. Third, consider the location of the provider. If the provider is too far away, it may be difficult for you to get to your appointments. Finally, consider the provider's office hours. If you work during the day, you may need to find a provider that has evening or weekend hours.\n\nNorthwind_Standard_Benefits_Details.pdf: weekend hours. Choosing an in-network provider can help you save money on your health care costs. By following the tips above and researching your options, you can find a provider that is convenient, affordable, and in-network with your Northwind Standard plan. Continuity of Care HOW PROVIDERS AFFECT YOUR COSTS: CONTINUITY OF CARE At Contoso, we understand the importance of providing our employees with access to a quality, affordable health care plan. We are proud to offer our employees Northwind Standard, an insurance plan that provides coverage for medical, vision, and dental services. We also offer continuity of care, which is the ability to stay with the same provider for all your health care needs, from routine checkups to specialized care. Continuity of care is an important component of any health care plan, as it allows you to develop a relationship with your doctor, who is more likely to recognize any changes in your health and be able to provide better, more personalized care. With Northwind Standard, you can stay with the same provider throughout your health care journey, helping you to receive the best possible care. Continuity of care also helps you save money on health care costs. When you stay with the same provider, you are likely to pay less than if you switch around from provider to provider. This is because your provider already has all your medical records, and you won’t need to pay for expensive tests and procedures that you’ve already had done. There are a few exceptions to the continuity of care. If you move to a different area, you may need to find a new provider. Additionally, if you are in need of specialized care, such as surgery or a complex treatment, you may need to visit a specialist who does not accept your insurance plan. In these cases, you should be sure to check with your insurance company and/or provider to make sure you understand any associated costs.","latency":6.809682,"response_length":1634} diff --git a/samples/csharp/FoundryA365/infra/modules/application.bicep b/samples/csharp/FoundryA365/infra/modules/application.bicep deleted file mode 100644 index 4f0c4b5a8..000000000 --- a/samples/csharp/FoundryA365/infra/modules/application.bicep +++ /dev/null @@ -1,38 +0,0 @@ -// Parameters for the application module -param accountName string -param projectName string -param applicationName string - -// Application properties -param displayName string = applicationName - -// Agent configuration -param agents array = [] - -// Reference to existing Cognitive Services account -resource account 'Microsoft.CognitiveServices/accounts@2024-10-01' existing = { - name: accountName -} - -// Reference to existing Cognitive Services project -resource project 'Microsoft.CognitiveServices/accounts/projects@2025-06-01' existing = { - parent: account - name: projectName -} - -// Cognitive Services Application (child resource of project) -resource application 'Microsoft.CognitiveServices/accounts/projects/applications@2025-10-01-preview' = { - parent: project - name: applicationName - properties: { - displayName: displayName - agents: agents - authorizationPolicy: { - AuthorizationScheme: 'Channels' - } - } -} - -// Outputs -output baseUrl string = application.properties.baseUrl -output agentIdentityBlueprintId string = application.properties.agentIdentityBlueprint.clientId diff --git a/samples/csharp/FoundryA365/scripts/build-docker-image.ps1 b/samples/csharp/FoundryA365/scripts/build-docker-image.ps1 deleted file mode 100644 index 46f41c220..000000000 --- a/samples/csharp/FoundryA365/scripts/build-docker-image.ps1 +++ /dev/null @@ -1,31 +0,0 @@ -Set-Location "$($PSScriptRoot)/../src/hello_world_a365_agent" - -Remove-Item "./publish" -Recurse -Force -ErrorAction SilentlyContinue - -dotnet publish -c Release -o "./publish" - - -$authorityEndpoint = "https://login.microsoftonline.com/$($env:TENANT_ID)" -$azureOpenAIEndpoint = "https://$($env:ACCOUNT_NAME).openai.azure.com/" - -$projectClientId = az ad sp show --id $env:PROJECT_PRINCIPAL_ID --query appId -o tsv - -# if the projectClientId is null or empty, throw an error -if ([string]::IsNullOrEmpty($projectClientId)) { - throw "Failed to get project client ID for principal ID $($env:PROJECT_PRINCIPAL_ID)" -} - -docker build -t hello-world-a365-agent:a365preview001 --build-arg BLUEPRINT_CLIENT_ID=$env:AGENT_IDENTITY_BLUEPRINT_ID --build-arg AUTHORITY_ENDPOINT=$authorityEndpoint --build-arg FEDERATED_CLIENT_ID=$projectClientId --build-arg AZURE_OPENAI_ENDPOINT=$azureOpenAIEndpoint --build-arg MODEL_DEPLOYMENT='gpt-4o' -f "./foundry-infra/Dockerfile" . - -$acrLoginServer = $env:AZURE_CONTAINER_REGISTRY_ENDPOINT - -# split the login server to get the registry name -$registryName = $acrLoginServer.Split(".")[0] - -docker tag hello-world-a365-agent:a365preview001 $acrLoginServer/hello-world-a365-agent:a365preview001 - -az acr login --name $registryName - -docker push $acrLoginServer/hello-world-a365-agent:a365preview001 - -Remove-Item "./publish" -Recurse -Force -ErrorAction SilentlyContinue diff --git a/samples/csharp/FoundryA365/scripts/create-application-deployment.ps1 b/samples/csharp/FoundryA365/scripts/create-application-deployment.ps1 deleted file mode 100644 index d6ef0f4e1..000000000 --- a/samples/csharp/FoundryA365/scripts/create-application-deployment.ps1 +++ /dev/null @@ -1,79 +0,0 @@ -param( - [string]$AgentName, - [string]$AgentVersion -) - - -$ErrorActionPreference = "Stop" - -$applicationDeploymentBody = @{ - properties =@{ - displayName = "Foundry Agent Deployment" - protocols = @( - @{ - protocol = "Activity" - version = "v1" - } - ) - agents = @( - @{ - agentName = $AgentName - agentVersion = $AgentVersion - #TODO: AgentId - } - ) - deploymentType = "Hosted" - minReplicas = 1 - maxReplicas = 1 - } -} - - -$createDeploymentUrl = "https://management.azure.com/subscriptions/$($env:AZURE_SUBSCRIPTION_ID)/resourceGroups/$($env:AZURE_RESOURCE_GROUP)/providers/Microsoft.CognitiveServices/accounts/$($env:ACCOUNT_NAME)/projects/$($env:PROJECT_NAME)/applications/$($env:APPLICATION_NAME)/agentDeployments/foundry-agent-deployment?api-version=2025-10-01-preview" -$jsonDeploymentBody = $applicationDeploymentBody | ConvertTo-Json -Depth 100 -Write-Host "Creating application deployment..." -Write-Host "JSON Body:" -Write-Host $jsonDeploymentBody - -$managementToken = az account get-access-token --resource https://management.azure.com --query accessToken -o tsv -# Send PUT request -$responseDeployment = Invoke-RestMethod -Uri $createDeploymentUrl ` - -Method Put ` - -Headers @{ - "Content-Type" = "application/json" - "Accept" = "application/json" - "Authorization" = "Bearer $($managementToken)" - } ` - -Body $jsonDeploymentBody - -Write-Host "" -Write-Host "Response:" -$responseDeployment | ConvertTo-Json -Depth 100 | Write-Host - - -while ($true) { - Start-Sleep -Seconds 10 - Write-Host "Get application deployment status..." - $getDeploymentUrl = "https://management.azure.com/subscriptions/$($env:AZURE_SUBSCRIPTION_ID)/resourceGroups/$($env:AZURE_RESOURCE_GROUP)/providers/Microsoft.CognitiveServices/accounts/$($env:ACCOUNT_NAME)/projects/$($env:PROJECT_NAME)/applications/$($env:APPLICATION_NAME)/agentDeployments/foundry-agent-deployment?api-version=2025-10-01-preview" - - $responseDeployment = Invoke-RestMethod -Uri $getDeploymentUrl ` - -Method Get ` - -Headers @{ - "Content-Type" = "application/json" - "Accept" = "application/json" - "Authorization" = "Bearer $($managementToken)" - } ` - - Write-Host "" - Write-Host "Response:" - $responseDeployment | ConvertTo-Json -Depth 100 | Write-Host - if ($responseDeployment.properties.state -eq "Running") { - Write-Host "Deployment succeeded." - break - } elseif ($responseDeployment.properties.provisioningState -eq "Failed") { - Write-Host "Deployment failed." - break - } else { - Write-Host "Deployment in progress..." - } -} \ No newline at end of file diff --git a/samples/csharp/FoundryA365/scripts/create-foundry-container-agent.ps1 b/samples/csharp/FoundryA365/scripts/create-foundry-container-agent.ps1 deleted file mode 100644 index cf15f4e7d..000000000 --- a/samples/csharp/FoundryA365/scripts/create-foundry-container-agent.ps1 +++ /dev/null @@ -1,50 +0,0 @@ -$ErrorActionPreference = "Stop" - -$agentUrl = "$($env:AZURE_AI_PROJECT_ENDPOINT)/agents/$($env:AGENT_NAME)/versions?api-version=2025-11-15-preview" - -$agentCreationBody = @{ - definition = @{ - kind = "hosted" - image = "$($env:AZURE_CONTAINER_REGISTRY_ENDPOINT)/hello-world-a365-agent:a365preview001" - cpu = "2" - memory = "4Gi" - environment_variables = @{} - container_protocol_versions = @( - @{ - protocol = "activity_protocol" - version = "v1" - } - ) - } - metadata = @{ - botid = "$($env:AGENT_IDENTITY_BLUEPRINT_ID)" - } - description="Foundry digital worker." -} - -$jsonBody = $agentCreationBody | ConvertTo-Json -Depth 5 - -$aiAzureToken = az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv - - -Write-Host "Creating agent version..." -Write-Host "JSON Body:" -Write-Host $jsonBody - -# Send POST request -$response = Invoke-RestMethod -Uri $agentUrl ` - -Method Post ` - -Headers @{ - "Content-Type" = "application/json" - "Accept" = "application/json" - "Authorization" = "Bearer $($aiAzureToken)" - } ` - -Body $jsonBody - -Write-Host "" -Write-Host "Response:" -$response | ConvertTo-Json -Depth 100 | Write-Host - -# & "$PSScriptRoot/create-application-deployment.ps1" -AgentName $response.name -AgentVersion $response.version - -& "$PSScriptRoot/start-foundry-container-agent.ps1" -AgentName $response.name -AgentVersion $response.version \ No newline at end of file diff --git a/samples/csharp/FoundryA365/scripts/start-foundry-container-agent.ps1 b/samples/csharp/FoundryA365/scripts/start-foundry-container-agent.ps1 deleted file mode 100644 index 24284eed5..000000000 --- a/samples/csharp/FoundryA365/scripts/start-foundry-container-agent.ps1 +++ /dev/null @@ -1,59 +0,0 @@ -param( - [string]$AgentName, - [string]$AgentVersion -) - -$ErrorActionPreference = "Stop" - - -# Helper: call REST API -function Invoke-FoundryApi { - param( - [string]$Method, - [string]$Url, - [object]$Body = $null - ) - - $aiAzureToken = az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv - $Headers = @{ - "Authorization" = "Bearer $aiAzureToken" - "Content-Type" = "application/json" - } - if ($Body) { - Invoke-RestMethod -Method $Method -Uri $Url -Headers $Headers -Body ($Body | ConvertTo-Json -Depth 100) - } else { - Invoke-RestMethod -Method $Method -Uri $Url -Headers $Headers - } -} - -# 1. Start container -$startUrl = "$($env:AZURE_AI_PROJECT_ENDPOINT)/agents/$AgentName/versions/$AgentVersion/containers/default:start?api-version=2025-11-15-preview" -$body = @{ - minReplicas = 1 - maxReplicas = 1 -} -$operation = Invoke-FoundryApi -Method POST -Url $startUrl -Body $body -Write-Host "Starting container (operation id: $($operation.id), status: $($operation.status))" - -# 2. Poll until operation is complete -while ($operation.status -in @("NotStarted", "InProgress")) { - Start-Sleep -Seconds 5 - $opUrl = "$($env:AZURE_AI_PROJECT_ENDPOINT)/agents/$AgentName/operations/$($operation.id)?api-version=2025-11-15-preview" - $operation = Invoke-FoundryApi -Method GET -Url $opUrl - Write-Host " Operation status: $($operation.status)" - - # 3. Check operation result - switch ($operation.status) { - "Succeeded" { - $containerUrl = "$($env:AZURE_AI_PROJECT_ENDPOINT)/agents/$AgentName/versions/$AgentVersion/containers/default?api-version=2025-11-15-preview" - $container = Invoke-FoundryApi -Method GET -Url $containerUrl - Write-Host "Container status: $($container.status), created at: $($container.createdAt)" - break - } - "Failed" { - Write-Host "Operation failed. Error message: $($operation.error)" - break - } - } -} - diff --git a/samples/csharp/FoundryA365/src/hello_world_a365_agent/AgentLogic/AgentConfiguration.cs b/samples/csharp/FoundryA365/src/hello_world_a365_agent/AgentLogic/AgentConfiguration.cs deleted file mode 100644 index 81c7f9210..000000000 --- a/samples/csharp/FoundryA365/src/hello_world_a365_agent/AgentLogic/AgentConfiguration.cs +++ /dev/null @@ -1,10 +0,0 @@ -ο»Ώnamespace HelloWorldA365.AgentLogic; - -public static class AgentConfiguration -{ - public static string? GetAgentEmailFilter(this IConfiguration configuration) => - configuration.GetValue("AgentConfiguration:AgentEmailFilter"); - - public static string? GetCertificateData(this IConfiguration configuration) => - configuration["agent-blueprint-secret"]; -} \ No newline at end of file diff --git a/samples/csharp/FoundryA365/src/hello_world_a365_agent/AgentLogic/AgentLogicServiceFactory.cs b/samples/csharp/FoundryA365/src/hello_world_a365_agent/AgentLogic/AgentLogicServiceFactory.cs deleted file mode 100644 index 6d0066395..000000000 --- a/samples/csharp/FoundryA365/src/hello_world_a365_agent/AgentLogic/AgentLogicServiceFactory.cs +++ /dev/null @@ -1,39 +0,0 @@ -namespace HelloWorldA365.AgentLogic; - -using HelloWorldA365.AgentLogic.SemanticKernel; -using HelloWorldA365.Models; -using Microsoft.Agents.Builder; - -public sealed class AgentLogicServiceFactory( - IConfiguration configuration, - ILogger logger, - SemanticKernelAgentLogicServiceFactory semanticKernelAgentLogicServiceFactory) -{ - private readonly string implementationType = configuration["Type"] ?? "SK"; - - /// - /// Gets or creates a AgentLogicService instance for the specified agent. - /// The implementation (Semantic Kernel vs OpenAI) is determined by the Type configuration setting. - /// - /// The agent to get the service for. - /// A AgentLogicService instance. - public async Task GetService(AgentMetadata agent, ITurnContext turnContext) - { - // Note: We should not cache the service per bot. - // The service must be created per turn. Context is not desined to be shared across turns. - return await CreateServiceAsync(agent, turnContext); - } - - private async Task CreateServiceAsync(AgentMetadata agent, ITurnContext turnContext) - { - switch (implementationType.ToUpperInvariant()) - { - case "SK": - case "SEMANTICKERNEL": - default: - logger.LogInformation("Creating Semantic Kernel-based AgentLogicService for agent {AgentId}", agent.AgentId); - return await semanticKernelAgentLogicServiceFactory.CreateAsync(agent, turnContext); - - } - } -} \ No newline at end of file diff --git a/samples/csharp/FoundryA365/src/hello_world_a365_agent/AgentLogic/AuthCache/AgentTokenCache.cs b/samples/csharp/FoundryA365/src/hello_world_a365_agent/AgentLogic/AuthCache/AgentTokenCache.cs deleted file mode 100644 index aade664e0..000000000 --- a/samples/csharp/FoundryA365/src/hello_world_a365_agent/AgentLogic/AuthCache/AgentTokenCache.cs +++ /dev/null @@ -1,51 +0,0 @@ -ο»Ώusing Azure.Core; -using HelloWorldA365.Services; -using System.Collections.Concurrent; - -namespace HelloWorldA365.AgentLogic.AuthCache; - -/// -/// Cache only for observability (exporter) scoped tokens per (agentId, tenantId). -/// -public interface IAgentTokenCache -{ - /// - /// Registers (idempotent) a credential to be used for observability token acquisition. - /// - void RegisterObservability(string agentId, string tenantId, AgentTokenCredential credential, string[] observabilityScopes); - - /// - /// Returns an observability token (cached inside the credential) or null on failure/not registered. - /// - string? GetObservabilityToken(string agentId, string tenantId); -} - -public sealed class AgentTokenCache : IAgentTokenCache -{ - private sealed record Entry(AgentTokenCredential Credential, string[] Scopes); - - private readonly ConcurrentDictionary _map = new(); - - public void RegisterObservability(string agentId, string tenantId, AgentTokenCredential credential, string[] observabilityScopes) - { - // First registration wins; subsequent calls ignored (idempotent). - _map.TryAdd(agentId, new Entry(credential, observabilityScopes)); - } - - public string? GetObservabilityToken(string agentId, string tenantId) - { - if (!_map.TryGetValue(agentId, out var entry)) - return null; - - try - { - // Use sync path; credential handles caching & refresh internally. - var ctx = new TokenRequestContext(entry.Scopes); - return entry.Credential.GetToken(ctx, CancellationToken.None).Token; - } - catch - { - return null; - } - } -} \ No newline at end of file diff --git a/samples/csharp/FoundryA365/src/hello_world_a365_agent/AgentLogic/SemanticKernel/SemanticKernelAgentLogicService.cs b/samples/csharp/FoundryA365/src/hello_world_a365_agent/AgentLogic/SemanticKernel/SemanticKernelAgentLogicService.cs deleted file mode 100644 index b39c9c040..000000000 --- a/samples/csharp/FoundryA365/src/hello_world_a365_agent/AgentLogic/SemanticKernel/SemanticKernelAgentLogicService.cs +++ /dev/null @@ -1,340 +0,0 @@ -namespace HelloWorldA365.AgentLogic.SemanticKernel; - -using HelloWorldA365.AgentLogic.AuthCache; -using HelloWorldA365.Services; -using Microsoft.Agents.Builder; -using Microsoft.Agents.Builder.State; -using Microsoft.Agents.Core.Models; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents; -using Microsoft.SemanticKernel.ChatCompletion; -using System; -using System.Net.Http.Headers; -using System.Text; -using System.Text.Json; -using System.Threading; -using AgentMetadata = HelloWorldA365.Models.AgentMetadata; -using Microsoft.Agents.A365.Tooling.Extensions.SemanticKernel.Services; -using Microsoft.Agents.A365.Observability.Runtime.Common; -using Microsoft.Agents.A365.Observability.Extensions.SemanticKernel; -using Microsoft.Agents.A365.Notifications.Models; -using Microsoft.Agents.A365.Observability.Common; - -/// -/// Semantic Kernel-based implementation of AgentLogicService. -/// This contains all core business logic for a agent instance using Semantic Kernel. -/// -public class SemanticKernelAgentLogicService : IAgentLogicService -{ - private readonly Kernel _kernel; - private readonly AgentMetadata _agentMetadata; - private readonly ChatCompletionAgent _chatCompletionAgent; - private readonly ILogger _logger; - - public SemanticKernelAgentLogicService( - AgentTokenHelper tokenHelper, - AgentMetadata agent, - Kernel kernel, - string certificateData, - IConfiguration config, - ILogger logger, - IMcpToolRegistrationService mcpToolRegistrationService, - IAgentTokenCache tokenCache) - { - _agentMetadata = agent ?? throw new ArgumentNullException(nameof(agent)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - - // Register observability-only credential (separate instance to isolate caching if needed) - var observabilityCredential = new AgentTokenCredential(tokenHelper, agent, certificateData); - var obsScopes = EnvironmentUtils.GetObservabilityAuthenticationScope(); - tokenCache.RegisterObservability(agent.AgentId.ToString(), agent.TenantId.ToString(), observabilityCredential, obsScopes); - - var deployment = config["ModelDeployment"] ?? throw new ArgumentNullException("ModelDeployment"); - var endpoint = config["AzureOpenAIEndpoint"]; - var mem0Token = config["Mem0ApiToken"]; - if (string.IsNullOrWhiteSpace(deployment) || string.IsNullOrWhiteSpace(endpoint)) - { - throw new InvalidOperationException("ModelDeployment and AzureOpenAIEndpoint must be configured."); - } - - // Create an HttpClient for the Mem0 service if key is provided - if (!string.IsNullOrWhiteSpace(mem0Token)) - { - var httpClient = new HttpClient() - { - BaseAddress = new Uri("https://api.mem0.ai") - }; - httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Token", mem0Token); - } - - var instructions = AgentInstructions.GetInstructions(agent); - _kernel = kernel; - _chatCompletionAgent = new ChatCompletionAgent - { - // NOTE: This ID should match the agent ID for which the token is registered on L48-51 above - Id = agent.AgentId.ToString(), - Name = agent.EmailId, - Instructions = instructions, - Kernel = _kernel, - Arguments = new KernelArguments(new PromptExecutionSettings() - { - FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() - }), - }.WithTracing(); - } - - /// - /// This processes message from activity protocol, aka ABS (Azure Bot Service). - /// - public async Task NewActivityReceived(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken) - { - var baggageScope = new BaggageBuilder() - .FromTurnContext(turnContext) - .CorrelationId(turnContext.Activity.RequestId) - .Build(); - - var incomingText = turnContext.Activity.Text; - _logger.LogInformation("New activity received (Semantic Kernel): {IncomingText}", incomingText); - - // Log target recipient - var recipient = turnContext.Activity.Recipient; - var json = recipient != null ? JsonSerializer.Serialize(recipient) : "null"; - _logger.LogInformation("Target Recipient: {Recipient}", json); - - // Log sender information - var sender = turnContext.Activity.From; - var jsonSender = sender != null ? JsonSerializer.Serialize(sender) : "null"; - _logger.LogInformation("Sender: {Sender}", jsonSender); - - bool skipResponse = false; - if (turnContext.Activity.ChannelId == "email" || turnContext.Activity.ChannelId == "agents:email") - { - var subject = string.Empty; - if (turnContext.Activity.ChannelData is JsonElement jsonElement && jsonElement.TryGetProperty("subject", out var subjectProperty)) - { - subject = subjectProperty.GetString() ?? string.Empty; - } - - _logger.LogInformation("Extracted subject: {Subject}", subject); - incomingText = $"Please respond to this email From: {sender!.Id}\nSubject: {subject}\nMessage: {incomingText}"; - if (!string.IsNullOrEmpty(sender?.Id) && sender.Id.Contains("MicrosoftExchange329e71ec88ae4615bbc36ab6ce41109")) - { - _logger.LogWarning("Non delivery response from MicrosoftExchange329e71ec88ae4615bbc36ab6ce41109 skipping further response to prevent infinite loop of email storm"); - skipResponse = true; - } - } - else if (turnContext.Activity.ChannelId == "msteams") - { - // name and email missing in teams channel data - incomingText = $"Respond to this chat message with chat id {turnContext.Activity.Conversation.Id} " + - $"From: {sender?.Name} ({sender?.Id})\n" + - $"Message: {incomingText}"; - } - else if (turnContext.Activity.Type == ActivityTypes.InstallationUpdate) - { - incomingText = $"You were just added as a digital worker. Please send an email to {sender!.Id} with a information on what you can do."; - } - - if (!skipResponse) - { - await foreach (var responseItem in InvokeAgentAsync(incomingText)) - { - try - { - var responseText = responseItem.Message.Content ?? string.Empty; - _logger.LogInformation("Sending response: {ResponseText}", responseText); - - await turnContext.SendActivityAsync(MessageFactory.Text(responseText), cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error sending response: {ResponseText}", responseItem.Message.Content); - } - } - } - - await Task.CompletedTask; - } - - public async Task NewEmailReceived(string fromEmail, string subject, string messageBody) - { - using var baggageScope = new BaggageBuilder() - .TenantId(_agentMetadata.TenantId.ToString()) - .AgentId(_agentMetadata.AgentId.ToString()) - .Build(); - - try - { - ChatHistoryAgentThread agentThread = new(); - - var formattedMessage = $"Please respond to this email From: {fromEmail}\nSubject: {subject}\nMessage: {messageBody}"; - - var responseText = new StringBuilder(); - await foreach (var responseItem in InvokeAgentAsync(formattedMessage, agentThread)) - { - responseText.Append(responseItem.Message.Content ?? string.Empty); - } - - return responseText.ToString(); - } - catch (Exception ex) - { - throw new Exception($"Error processing message: {ex.Message}", ex); - } - } - - public async Task NewChatReceived(string chatId, string fromUser, string messageBody) - { - using var baggageScope = new BaggageBuilder() - .TenantId(_agentMetadata.TenantId.ToString()) - .AgentId(_agentMetadata.AgentId.ToString()) - .Build(); - - try - { - ChatHistoryAgentThread agentThread = new(); - - // Create context and user messages - var contextMessage = new ChatMessageContent(AuthorRole.System, $"You are chatting with {fromUser} via Teams - ChatId {chatId}"); - var userMessage = new ChatMessageContent(AuthorRole.User, messageBody); - var messages = new List { contextMessage, userMessage }; - - var responseText = new StringBuilder(); - await foreach (var responseItem in _chatCompletionAgent.InvokeAsync(messages, agentThread)) - { - responseText.Append(responseItem.Message.Content ?? string.Empty); - } - - // Reset status message and set presence to Available before returning - - return responseText.ToString(); - } - catch (Exception ex) - { - throw new Exception($"Error processing chat message: {ex.Message}", ex); - } - } - - - #region IAgentLogicService Event Handler Methods - - /// - /// Handles email notification events from Messaging - /// - public async Task HandleEmailNotificationAsync(ITurnContext turnContext, ITurnState turnState, AgentNotificationActivity emailEvent) - { - _logger.LogInformation("Processing email notification - NotificationType: {NotificationType}", - emailEvent.NotificationType); - - var emailContent = emailEvent.Text ?? string.Empty; - - // Collect all agent responses into a single text - var responseText = new StringBuilder(); - await foreach (var responseItem in InvokeAgentAsync(emailContent)) - { - responseText.Append(responseItem.Message.Content ?? string.Empty); - } - - // var responseActivity = Microsoft.Agents.A365.Notifications.Models.EmailResponse.CreateEmailResponseActivity(responseText.ToString()); - // Create email response with the collected content - var responseActivity2 = MessageFactory.Text("a"); - responseActivity2.Entities.Add(new EmailResponse(responseText.ToString())); - await turnContext.SendActivityAsync(responseActivity2); - - } - - /// - /// Handles document comment notification events (Word, Excel, PowerPoint) from Messaging - /// - public async Task HandleCommentNotificationAsync(ITurnContext turnContext, ITurnState turnState, AgentNotificationActivity commentEvent) - { - _logger.LogInformation("Processing comment notification - NotificationType: {NotificationType}", - commentEvent.NotificationType); - - // For now returning a static response - can be enhanced with actual AI processing - var responseText = "Hello this is a response to a comment notification received through Messaging."; - var commentActivity = MessageFactory.Text(responseText); - turnContext.SendActivityAsync(commentActivity); - } - - /// - /// Handles Teams message events from Messaging - /// - public async Task HandleTeamsMessageAsync(ITurnContext turnContext, ITurnState turnState, AgentNotificationActivity teamsEvent) - { - _logger.LogInformation("Processing Teams message event - From: {FromUser}", - teamsEvent.From?.Name); - - var formattedMessage = $"Respond to this chat message with chat id {teamsEvent.Conversation?.Id} " + - $"From: {teamsEvent.From?.Name} ({teamsEvent.From?.Id})\n" + - $"Message: {teamsEvent.Text}"; - - // For now returning a static response - can be enhanced with actual AI processing - var responseText = "Hello this is a response to a Teams message received through Messaging."; - - _logger.LogInformation("Teams message response prepared: {ResponseText}", responseText); - } - - /// - /// Handles installation update events from Messaging - /// - public async Task HandleInstallationUpdateAsync(ITurnContext turnContext, ITurnState turnState, AgentNotificationActivity installationEvent) - { - _logger.LogInformation("Processing installation update event for {SenderId}", installationEvent.From?.Id); - - var formattedMessage = $"You were just added as a digital worker. Please send an email to {installationEvent.From?.Id} with information on what you can do."; - - // For now returning a static response - can be enhanced with actual AI processing - var responseText = "Hello this is a response to an installation update received through Messaging."; - - _logger.LogInformation("Installation update response prepared: {ResponseText}", responseText); - } - - /// - /// Handles generic activity events that don't fit other categories - /// - public async Task NewActivityReceived(ITurnContext turnContext, ITurnState turnState, AgentNotificationActivity genericEvent) - { - _logger.LogInformation("Processing generic activity event - NotificationType: {NotificationType}", - genericEvent.NotificationType); - - // For generic events, provide basic processing - // For now, just return a static response - var responseText = "Hello this is a response to a generic activity received through Messaging."; - - _logger.LogInformation("Generic activity response prepared: {ResponseText}", responseText); - } - - #endregion - - #region Helper Methods - - /// - /// Invokes the agent with the specified input text and returns the response stream. - /// The caller is responsible for handling the responses (e.g., sending via turn context or collecting as string). - /// - /// The input text to send to the agent - /// Optional agent thread to use. If null, creates a new empty thread. - /// Cancellation token - /// An async enumerable of agent response items - private IAsyncEnumerable> InvokeAgentAsync( - string incomingText, - ChatHistoryAgentThread? agentThread = null, - CancellationToken cancellationToken = default) - { - // NOTE: This won't retain history from previous messages in the thread currently - // This could be added at a later time - // For now, just always use new empty thread unless one is provided - agentThread ??= new ChatHistoryAgentThread(); - - var content = new ChatMessageContent - { - Role = AuthorRole.User, - Content = incomingText, - }; - - return _chatCompletionAgent.InvokeAsync(content, agentThread, cancellationToken: cancellationToken); - } - - #endregion -} \ No newline at end of file diff --git a/samples/csharp/FoundryA365/src/hello_world_a365_agent/AgentLogic/SemanticKernel/SemanticKernelAgentLogicServiceFactory.cs b/samples/csharp/FoundryA365/src/hello_world_a365_agent/AgentLogic/SemanticKernel/SemanticKernelAgentLogicServiceFactory.cs deleted file mode 100644 index 2dd2eb48f..000000000 --- a/samples/csharp/FoundryA365/src/hello_world_a365_agent/AgentLogic/SemanticKernel/SemanticKernelAgentLogicServiceFactory.cs +++ /dev/null @@ -1,83 +0,0 @@ -namespace HelloWorldA365.AgentLogic.SemanticKernel; - -using Azure.Core; -using Azure.Identity; -using HelloWorldA365.AgentLogic.AuthCache; -using HelloWorldA365.Mcp; -using HelloWorldA365.Models; // added for PresenceState -using HelloWorldA365.Services; -using Microsoft.Agents.A365.Tooling.Extensions.SemanticKernel.Services; -using Microsoft.Agents.Builder; -using Microsoft.Agents.Builder.App.UserAuth; -using Microsoft.SemanticKernel; - -/// -/// There are still some work left here: -/// 1- The factory structure doesn't follow the factory pattern. -/// 2- There are constants that need to be moved to configuration (TBD on what configuration). -/// 3- We need a way to dynamically build the MCP server URL. It is environment specific and it won't work in prod as is. -/// 4- still the way we get the AA cert seems hacky. -/// 5- Scope needs to be updated. -/// 6- Remove disabling cert validation. -/// -public sealed class SemanticKernelAgentLogicServiceFactory( - IConfiguration configuration, - IServiceProvider serviceProvider, - ILogger logger, - McpToolDiscovery mcpToolDiscovery, - AgentTokenHelper tokenHelper, - IMcpToolRegistrationService mcpToolRegistrationService, - IAgentTokenCache tokenCache) -{ - private readonly string certificateData = configuration.GetCertificateData() ?? string.Empty; - - public async Task CreateAsync(AgentMetadata agent, ITurnContext turnContext) - { - var kernelBuilder = Kernel.CreateBuilder(); - AddModel(kernelBuilder); - var kernel = kernelBuilder.Build(); - await ConfigureKernelPlugins(agent, kernel, turnContext); - // Resolve GraphService for constructor injection - var scopedServiceProvider = serviceProvider.CreateScope().ServiceProvider; - - // Attempt to set presence to busy and status message indicating active work - - return new SemanticKernelAgentLogicService(tokenHelper, agent, kernel, certificateData, configuration, logger, mcpToolRegistrationService, tokenCache); - } - - private async Task ConfigureKernelPlugins(AgentMetadata agent, Kernel kernel, ITurnContext turnContext) - { - var scopedServiceProvider = serviceProvider.CreateScope().ServiceProvider; - // Prod scope for MCP servers. - // aka.ms/atg/repo - // https://github.com/bap-microsoft/MCP-Platform?tab=readme-ov-file#environments - var requestContext = new TokenRequestContext(["ea9ffc3e-8a23-4a7d-836d-234d7c7565c1/.default"]); - var tokenCredential = new AgentTokenCredential(tokenHelper, agent, certificateData); - var accessToken = tokenCredential.GetTokenAsync(requestContext, CancellationToken.None).GetAwaiter().GetResult(); - string agentUserId = agent.UserId.ToString(); - var environmentId = configuration["McpPlatformEnvironmentId"] ?? Environment.GetEnvironmentVariable("McpPlatformEnvironmentId"); - if (string.IsNullOrEmpty(environmentId)) - { - environmentId = $"Default-{agent.TenantId.ToString()}"; - } - UserAuthorization userAuthorization = null; - string authHandlerName = string.Empty; - - await mcpToolRegistrationService.AddToolServersToAgentAsync(kernel, userAuthorization, authHandlerName, turnContext, accessToken.Token); - } - - private IKernelBuilder AddModel(IKernelBuilder kernelBuilder) - { - var deployment = configuration["ModelDeployment"] ?? throw new ArgumentNullException("ModelDeployment"); - var azureOpenAiEndpoint = configuration["AzureOpenAIEndpoint"] ?? throw new ArgumentNullException("AzureOpenAIEndPoint"); - // Kept this for people who use API key in settings. - // var apiKey = _configuration["OpenAiApiKey"] ?? throw new ArgumentNullException("OpenAiApiKey"); - - return kernelBuilder.AddAzureOpenAIChatCompletion( - deploymentName: deployment, - endpoint: azureOpenAiEndpoint, - // Ensure token is always picked up from terminal - new DefaultAzureCredential() - ); - } -} \ No newline at end of file diff --git a/samples/csharp/FoundryA365/src/hello_world_a365_agent/HelloWorldA365Agent.csproj b/samples/csharp/FoundryA365/src/hello_world_a365_agent/HelloWorldA365Agent.csproj deleted file mode 100644 index 6f0bb7510..000000000 --- a/samples/csharp/FoundryA365/src/hello_world_a365_agent/HelloWorldA365Agent.csproj +++ /dev/null @@ -1,62 +0,0 @@ -ο»Ώ - - - HelloWorldA365 - net9.0 - enable - enable - 6d3109d3-6ff5-438e-aa23-cddd3703b4cb - SKEXP0001;SKEXP0110;SKEXP0130 - - - - 1.12.0 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - \ No newline at end of file diff --git a/samples/csharp/FoundryA365/src/hello_world_a365_agent/HelloWorldA365Agent.sln b/samples/csharp/FoundryA365/src/hello_world_a365_agent/HelloWorldA365Agent.sln deleted file mode 100644 index 7388567e4..000000000 --- a/samples/csharp/FoundryA365/src/hello_world_a365_agent/HelloWorldA365Agent.sln +++ /dev/null @@ -1,56 +0,0 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.5.2.0 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HelloWorldA365Agent", "HelloWorldA365Agent.csproj", "{036DE73A-2484-FA6D-3988-3D7DB76EB74E}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {036DE73A-2484-FA6D-3988-3D7DB76EB74E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {036DE73A-2484-FA6D-3988-3D7DB76EB74E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {036DE73A-2484-FA6D-3988-3D7DB76EB74E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {036DE73A-2484-FA6D-3988-3D7DB76EB74E}.Release|Any CPU.Build.0 = Release|Any CPU - {70E6B5E9-1629-C773-71BF-524D7576D367}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {70E6B5E9-1629-C773-71BF-524D7576D367}.Debug|Any CPU.Build.0 = Debug|Any CPU - {70E6B5E9-1629-C773-71BF-524D7576D367}.Release|Any CPU.ActiveCfg = Release|Any CPU - {70E6B5E9-1629-C773-71BF-524D7576D367}.Release|Any CPU.Build.0 = Release|Any CPU - {0748F01D-BBD8-D794-DFCA-BA70E95C35A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {0748F01D-BBD8-D794-DFCA-BA70E95C35A2}.Debug|Any CPU.Build.0 = Debug|Any CPU - {0748F01D-BBD8-D794-DFCA-BA70E95C35A2}.Release|Any CPU.ActiveCfg = Release|Any CPU - {0748F01D-BBD8-D794-DFCA-BA70E95C35A2}.Release|Any CPU.Build.0 = Release|Any CPU - {86E3E929-5449-9E3F-2B1C-03E2B4760D04}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {86E3E929-5449-9E3F-2B1C-03E2B4760D04}.Debug|Any CPU.Build.0 = Debug|Any CPU - {86E3E929-5449-9E3F-2B1C-03E2B4760D04}.Release|Any CPU.ActiveCfg = Release|Any CPU - {86E3E929-5449-9E3F-2B1C-03E2B4760D04}.Release|Any CPU.Build.0 = Release|Any CPU - {7A3B78BE-C36A-C712-815C-93C5635FD9FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7A3B78BE-C36A-C712-815C-93C5635FD9FB}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7A3B78BE-C36A-C712-815C-93C5635FD9FB}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7A3B78BE-C36A-C712-815C-93C5635FD9FB}.Release|Any CPU.Build.0 = Release|Any CPU - {C4FE6266-DE04-8002-1A47-6802C93816BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C4FE6266-DE04-8002-1A47-6802C93816BC}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C4FE6266-DE04-8002-1A47-6802C93816BC}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C4FE6266-DE04-8002-1A47-6802C93816BC}.Release|Any CPU.Build.0 = Release|Any CPU - {FD1841CE-5F06-DA3F-DACC-828F06FD048D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {FD1841CE-5F06-DA3F-DACC-828F06FD048D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {FD1841CE-5F06-DA3F-DACC-828F06FD048D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {FD1841CE-5F06-DA3F-DACC-828F06FD048D}.Release|Any CPU.Build.0 = Release|Any CPU - {C87789D6-FE93-3871-1290-0BFEBE3D19EA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C87789D6-FE93-3871-1290-0BFEBE3D19EA}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C87789D6-FE93-3871-1290-0BFEBE3D19EA}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C87789D6-FE93-3871-1290-0BFEBE3D19EA}.Release|Any CPU.Build.0 = Release|Any CPU - {E099B67E-A7D7-B158-9101-7FCF3436F3D3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E099B67E-A7D7-B158-9101-7FCF3436F3D3}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E099B67E-A7D7-B158-9101-7FCF3436F3D3}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E099B67E-A7D7-B158-9101-7FCF3436F3D3}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {A62A6059-F7FB-4D65-A242-68CC4925C04A} - EndGlobalSection -EndGlobal diff --git a/samples/csharp/FoundryA365/src/hello_world_a365_agent/Mcp/McpAuthenticationHandler.cs b/samples/csharp/FoundryA365/src/hello_world_a365_agent/Mcp/McpAuthenticationHandler.cs deleted file mode 100644 index 9990849db..000000000 --- a/samples/csharp/FoundryA365/src/hello_world_a365_agent/Mcp/McpAuthenticationHandler.cs +++ /dev/null @@ -1,113 +0,0 @@ -namespace HelloWorldA365.Mcp; - -using System.Net.Http.Headers; -using Azure.Core; -using HelloWorldA365.Models; -using HelloWorldA365.Services; - -/// -/// HTTP message handler that automatically adds authentication tokens to requests -/// using AgentTokenCredential for MCP endpoint authentication with built-in token caching. -/// Only authenticates requests to the configured MCP server endpoint. -/// -public class McpAuthenticationHandler : DelegatingHandler -{ - private readonly AgentTokenCredential _tokenCredential; - private readonly ILogger _logger; - private readonly string[] _scopes; - private readonly Uri _mcpServerEndpoint; - - public McpAuthenticationHandler( - AgentTokenHelper tokenHelper, - AgentMetadata agent, - string certificateData, - ILogger logger, - string mcpServerEndpoint, - string[]? scopes = null) - { - _tokenCredential = new AgentTokenCredential(tokenHelper, agent, certificateData); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _scopes = scopes ?? throw new ArgumentNullException(nameof(scopes)); - - if (string.IsNullOrWhiteSpace(mcpServerEndpoint)) - { - throw new ArgumentException("MCP server endpoint cannot be null or empty", nameof(mcpServerEndpoint)); - } - - _mcpServerEndpoint = new Uri(mcpServerEndpoint); - _logger.LogInformation("MCPAuthenticationHandler configured for selective authentication on endpoint: {Endpoint}", _mcpServerEndpoint); - } - - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - // Only add authentication for requests to the configured MCP server endpoint - if (ShouldAuthenticateRequest(request)) - { - try - { - var requestContext = new TokenRequestContext(_scopes); - - var accessToken = await _tokenCredential.GetTokenAsync(requestContext, cancellationToken); - - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken.Token); - - _logger.LogDebug("Added authentication token to MCP request for {RequestUri}, token expires at {ExpiresOn}", - request.RequestUri, accessToken.ExpiresOn); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to acquire authentication token for MCP request to {RequestUri}", request.RequestUri); - throw; - } - } - else - { - _logger.LogDebug("Skipping authentication for non-MCP request to {RequestUri}", request.RequestUri); - } - - return await base.SendAsync(request, cancellationToken); - } - - /// - /// Determines if the request should be authenticated based on the target endpoint - /// - /// The HTTP request - /// True if the request should be authenticated, false otherwise - private bool ShouldAuthenticateRequest(HttpRequestMessage request) - { - if (request.RequestUri == null) - { - _logger.LogDebug("Request has no URI, skipping authentication"); - return false; - } - - var requestUri = request.RequestUri; - - // Match scheme and host - if (!string.Equals(requestUri.Scheme, _mcpServerEndpoint.Scheme, StringComparison.OrdinalIgnoreCase) || - !string.Equals(requestUri.Host, _mcpServerEndpoint.Host, StringComparison.OrdinalIgnoreCase)) - { - _logger.LogDebug("Request scheme/host mismatch: Request={RequestScheme}://{RequestHost}, MCP={MCPScheme}://{MCPHost}", - requestUri.Scheme, requestUri.Host, _mcpServerEndpoint.Scheme, _mcpServerEndpoint.Host); - return false; - } - - // Match port if specified - if (requestUri.Port != _mcpServerEndpoint.Port) - { - _logger.LogDebug("Request port mismatch: Request={RequestPort}, MCP={MCPPort}", - requestUri.Port, _mcpServerEndpoint.Port); - return false; - } - - var requestPath = requestUri.AbsolutePath.TrimEnd('/'); - var mcpPath = _mcpServerEndpoint.AbsolutePath.TrimEnd('/'); - - var shouldAuthenticate = requestPath.StartsWith(mcpPath, StringComparison.OrdinalIgnoreCase); - - _logger.LogDebug("Path matching: Request='{RequestPath}', MCP='{MCPPath}', Should Authenticate={ShouldAuthenticate}", - requestPath, mcpPath, shouldAuthenticate); - - return shouldAuthenticate; - } -} \ No newline at end of file diff --git a/samples/csharp/FoundryA365/src/hello_world_a365_agent/Mcp/McpClientHttpRequestLogger.cs b/samples/csharp/FoundryA365/src/hello_world_a365_agent/Mcp/McpClientHttpRequestLogger.cs deleted file mode 100644 index ee28c035a..000000000 --- a/samples/csharp/FoundryA365/src/hello_world_a365_agent/Mcp/McpClientHttpRequestLogger.cs +++ /dev/null @@ -1,41 +0,0 @@ -namespace HelloWorldA365.Mcp; - -using System.Text.Json; - -/// -/// HTTP logging handler for debugging purposes -/// -public class McpClientHttpRequestLogger(ILogger logger) : DelegatingHandler -{ - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - // Log request - logger.LogInformation("HTTP Request: {Method} {Uri}", request.Method, request.RequestUri); - logger.LogInformation("Request Headers: {Headers}", JsonSerializer.Serialize( - request.Headers.ToDictionary(h => h.Key, h => string.Join(", ", h.Value)) - )); - - if (request.Content != null) - { - var requestBody = await request.Content.ReadAsStringAsync(cancellationToken); - logger.LogInformation("Request Body: {Body}", requestBody); - } - - // Send request - var response = await base.SendAsync(request, cancellationToken); - - // Log response - logger.LogInformation("HTTP Response: {StatusCode} {ReasonPhrase}", (int)response.StatusCode, response.ReasonPhrase); - logger.LogInformation("Response Headers: {Headers}", JsonSerializer.Serialize( - response.Headers.ToDictionary(h => h.Key, h => string.Join(", ", h.Value)) - )); - - if (response.Content != null) - { - var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); - logger.LogInformation("Response Body: {Body}", responseBody); - } - - return response; - } -} \ No newline at end of file diff --git a/samples/csharp/FoundryA365/src/hello_world_a365_agent/Mcp/McpFunctionWrapper.cs b/samples/csharp/FoundryA365/src/hello_world_a365_agent/Mcp/McpFunctionWrapper.cs deleted file mode 100644 index 26f78f3aa..000000000 --- a/samples/csharp/FoundryA365/src/hello_world_a365_agent/Mcp/McpFunctionWrapper.cs +++ /dev/null @@ -1,138 +0,0 @@ -namespace HelloWorldA365.Mcp; - -using System.Text.Json; -using Microsoft.SemanticKernel; - -/// -/// Wrapper class for MCP functions that allows parameter inspection and logging -/// -public class McpFunctionWrapper( - KernelFunction originalFunction, - string functionName, - ILogger logger, - IConfiguration configuration) -{ - public KernelFunction CreateWrappedFunction() - { - return KernelFunctionFactory.CreateFromMethod( - method: InvokeWithLogging, - functionName: functionName, - description: originalFunction.Description, - parameters: originalFunction.Metadata.Parameters, - returnParameter: originalFunction.Metadata.ReturnParameter - ); - } - - private async Task InvokeWithLogging(KernelArguments arguments, Kernel kernel, CancellationToken cancellationToken = default) - { - if (configuration["ToolMCPServer:EnableMCPFunctionLogging"]?.ToLower() != "true") - { - // If logging is disabled, just invoke the original function directly - var result = await originalFunction.InvokeAsync(kernel, arguments, cancellationToken); - return result.GetValue(); - } - - try - { - // Log function invocation with parameters - logger.LogInformation("Invoking MCP function: {FunctionName}", functionName); - var processedArguments = ProcessParameters(arguments); - if (processedArguments.Count > 0) - { - var parametersJson = JsonSerializer.Serialize( - arguments.ToDictionary(kvp => kvp.Key, kvp => kvp.Value), - new JsonSerializerOptions { WriteIndented = true } - ); - logger.LogInformation("MCP Function Parameters for {FunctionName}:\n{Parameters}", functionName, parametersJson); - } - else - { - logger.LogInformation("MCP Function {FunctionName} called with no parameters", functionName); - } - - // Invoke the original function with processed arguments - var result = await originalFunction.InvokeAsync(kernel, processedArguments, cancellationToken); - - // Log the result - var resultValue = result.GetValue()?.ToString() ?? "null"; - logger.LogInformation("MCP Function {FunctionName} result: {Result}", functionName, resultValue); - - return result.GetValue(); - } - catch (Exception ex) - { - logger.LogError(ex, "Error executing MCP function {FunctionName}", functionName); - throw; - } - } - - /// - /// Processes parameters to handle specific parameter name transformations - /// - /// Original arguments - /// Processed arguments with transformations applied - private KernelArguments ProcessParameters(KernelArguments arguments) - { - var processedArguments = new KernelArguments(); - - foreach (var kvp in arguments) - { - var parameterName = kvp.Key; - var parameterValue = kvp.Value; - - try - { - // Handle "message" or "body" parameters - parse from string to JSON object if they are strings - if ((parameterName.Equals("message", StringComparison.OrdinalIgnoreCase) || - parameterName.Equals("body", StringComparison.OrdinalIgnoreCase)) && - parameterValue is string stringValue && !string.IsNullOrWhiteSpace(stringValue)) - { - try - { - // Try to parse as JSON - if it's already a JSON string, parse it to object - var jsonObject = JsonSerializer.Deserialize(stringValue); - processedArguments[parameterName] = jsonObject; - logger.LogInformation("Parsed parameter '{ParameterName}' from string to JSON object for function {FunctionName}", parameterName, functionName); - } - catch (JsonException) - { - // If it's not valid JSON, treat it as plain text and wrap it in a simple object - var textObject = new { content = stringValue }; - processedArguments[parameterName] = textObject; - logger.LogInformation("Wrapped parameter '{ParameterName}' plain text in object for function {FunctionName}", parameterName, functionName); - } - } - // Handle "saveToSentItem" parameter - convert from string to boolean - else if (parameterName.Equals("saveToSentItem", StringComparison.OrdinalIgnoreCase) && - parameterValue is string boolStringValue) - { - if (bool.TryParse(boolStringValue, out var boolValue)) - { - processedArguments[parameterName] = boolValue; - logger.LogInformation("Converted parameter '{ParameterName}' from string '{StringValue}' to boolean {BoolValue} for function {FunctionName}", - parameterName, boolStringValue, boolValue, functionName); - } - else - { - // If it can't be parsed as boolean, log warning and keep original value - logger.LogWarning("Could not parse parameter '{ParameterName}' value '{StringValue}' as boolean for function {FunctionName}, keeping original value", - parameterName, boolStringValue, functionName); - processedArguments[parameterName] = parameterValue; - } - } - else - { - // For all other parameters, keep the original value - processedArguments[parameterName] = parameterValue; - } - } - catch (Exception ex) - { - logger.LogError(ex, "Error processing parameter '{ParameterName}' for function {FunctionName}, keeping original value", parameterName, functionName); - processedArguments[parameterName] = parameterValue; - } - } - - return processedArguments; - } -} \ No newline at end of file diff --git a/samples/csharp/FoundryA365/src/hello_world_a365_agent/Mcp/McpToolDiscovery.cs b/samples/csharp/FoundryA365/src/hello_world_a365_agent/Mcp/McpToolDiscovery.cs deleted file mode 100644 index 0b2002834..000000000 --- a/samples/csharp/FoundryA365/src/hello_world_a365_agent/Mcp/McpToolDiscovery.cs +++ /dev/null @@ -1,121 +0,0 @@ -namespace HelloWorldA365.Mcp; - -using System.Text; -using HelloWorldA365.AgentLogic; -using HelloWorldA365.Models; -using HelloWorldA365.Services; -using Microsoft.SemanticKernel; -using ModelContextProtocol.Client; - -public sealed class McpToolDiscovery( - ILogger logger, - IConfiguration configuration, - AgentTokenHelper tokenHelper) -{ - // If certificateData is empty then AgentTokenCredential uses managed identity through IMDS using DefaultAzureCredential - private readonly string certificateData = configuration.GetCertificateData() ?? string.Empty; - - public async Task> Discover(AgentMetadata agent) - { - // Check if agent has MCP server URL configured - if (string.IsNullOrWhiteSpace(agent.McpServerUrl)) - { - logger.LogInformation("Agent {AgentId} has no MCP server URL configured, skipping MCP tool discovery", agent.AgentId); - return []; - } - - var tools = await GetMcpToolsAsync(agent); - return tools.Select(aiFunction => - { - var originalKernelFunction = aiFunction.AsKernelFunction(); - var wrapper = new McpFunctionWrapper(originalKernelFunction, aiFunction.Name, logger, configuration); - return wrapper.CreateWrappedFunction(); - }); - } - - private async Task> GetMcpToolsAsync(AgentMetadata agent) - { - var mcpClient = await SetupMcpClientAsync(agent); - var tools = await mcpClient.ListToolsAsync(); - LogDiscoveredTools(tools, agent.McpServerUrl!); - return tools; - } - - /// - /// Sets up and configures the MCP client with authentication and logging handlers - /// - /// The agent for authentication - /// Configured MCP client ready for use - private async Task SetupMcpClientAsync(AgentMetadata agent) - { - if (string.IsNullOrWhiteSpace(agent.McpServerUrl)) - { - throw new ArgumentException("Agent MCP server URL is not configured", nameof(agent)); - } - - // Create HTTP client handler chain for MCP service authentication - var httpClientHandler = new HttpClientHandler(); - - // WARNING: Only use this in development/testing - never in production! - // This bypasses SSL certificate validation - httpClientHandler.ServerCertificateCustomValidationCallback = - HttpClientHandler.DangerousAcceptAnyServerCertificateValidator; - - // Create authentication handler for MCP service using AgentTokenCredential - var authHandler = new McpAuthenticationHandler( - tokenHelper, - agent, - certificateData, - logger, - agent.McpServerUrl, // Use the agent's MCP server URL instead of global config - ["https://api.test.powerplatform.com/.default"]) - { - InnerHandler = httpClientHandler - }; - - logger.LogInformation("Configured MCPAuthenticationHandler with AgentTokenCredential for agent {AgentId} with selective authentication for MCP endpoint {Endpoint}", agent.AgentId, agent.McpServerUrl); - - // Create logging handler (optional - for debugging HTTP requests) - var loggingHandler = new McpClientHttpRequestLogger(logger) - { - InnerHandler = authHandler - }; - - // Setup SSE client transport options without manual token management - var options = new SseClientTransportOptions - { - Endpoint = new Uri(agent.McpServerUrl), - TransportMode = HttpTransportMode.AutoDetect, - Name = "MCPClientService" - }; - - // Create HTTP client with the authentication handler chain - var httpClient = new HttpClient(loggingHandler); - var clientTransport = new SseClientTransport(options, httpClient); - - return await McpClientFactory.CreateAsync(clientTransport); - } - - /// - /// Logs discovered MCP tools for debugging and monitoring purposes - /// - /// The collection of discovered MCP tools - /// The MCP server URL for this agent - private void LogDiscoveredTools(IList tools, string mcpServerUrl) - { - logger.LogInformation("Discovered {ToolCount} tools from MCP service at {McpServiceEndpoint}", tools.Count, mcpServerUrl); - - if (tools.Count == 0) - { - logger.LogWarning("No tools were discovered from MCP service at {McpServiceEndpoint}", mcpServerUrl); - return; - } - - var toolList = new StringBuilder(); - foreach (var tool in tools) - { - toolList.AppendLine($"- {tool.Name} ({tool.Description})"); - } - logger.LogInformation("Tools:\n{ToolList}", toolList.ToString()); - } -} \ No newline at end of file diff --git a/samples/csharp/FoundryA365/src/hello_world_a365_agent/Models/AgentMetadata.cs b/samples/csharp/FoundryA365/src/hello_world_a365_agent/Models/AgentMetadata.cs deleted file mode 100644 index f722d7de6..000000000 --- a/samples/csharp/FoundryA365/src/hello_world_a365_agent/Models/AgentMetadata.cs +++ /dev/null @@ -1,40 +0,0 @@ -namespace HelloWorldA365.Models; - -public class AgentMetadata -{ - // Business properties - public Guid UserId { get; set; } - public Guid AgentId { get; set; } - public Guid AgentApplicationId { get; set; } - public Guid TenantId { get; set; } - public string AgentFriendlyName { get; set; } = string.Empty; - - // This is used to keep track of which AppService is responsible for running logic of this agent. - public string OwningServiceName { get; set; } = string.Empty; - - public DateTime? LastEmailCheck { get; set; } - public DateTime? LastTeamsCheck { get; set; } - public string EmailId { get; set; } = string.Empty; - public string? WebhookUrl { get; set; } - public bool SkipAgentIdAuth { get; set; } = false; - - public bool IsMessagingEnabled { get; set; } = false; - - /// - /// MCP Server URL for this agent. If null or empty, MCP tools will not be enabled. - /// - public string? McpServerUrl { get; set; } - - public AgentMetadata() - { - } - - public AgentMetadata(Guid tenantId, Guid agentId, Guid userId, string agentFriendlyName, string owningServiceName) - { - TenantId = tenantId; - AgentId = agentId; - UserId = userId; - AgentFriendlyName = agentFriendlyName; - OwningServiceName = owningServiceName; - } -} \ No newline at end of file diff --git a/samples/csharp/FoundryA365/src/hello_world_a365_agent/Models/PresenceState.cs b/samples/csharp/FoundryA365/src/hello_world_a365_agent/Models/PresenceState.cs deleted file mode 100644 index 52a99d424..000000000 --- a/samples/csharp/FoundryA365/src/hello_world_a365_agent/Models/PresenceState.cs +++ /dev/null @@ -1,38 +0,0 @@ -ο»Ώnamespace HelloWorldA365.Models; - -public enum Availability -{ - Available, - Busy, - Away, - DoNotDisturb -} - -public enum PresenceActivity -{ - Available, - InACall, - InAConferenceCall, - Away, - Presenting -} - -public sealed record PresenceState( - Availability Availability, - PresenceActivity Activity) -{ - public static PresenceState Available => - new(Availability.Available, PresenceActivity.Available); - - public static PresenceState BusyInCall => - new(Availability.Busy, PresenceActivity.InACall); - - public static PresenceState BusyInConferenceCall => - new(Availability.Busy, PresenceActivity.InAConferenceCall); - - public static PresenceState Away => - new(Availability.Away, PresenceActivity.Away); - - public static PresenceState DoNotDisturbPresenting => - new(Availability.DoNotDisturb, PresenceActivity.Presenting); -} \ No newline at end of file diff --git a/samples/csharp/FoundryA365/src/hello_world_a365_agent/Services/AgentTokenHelper.cs b/samples/csharp/FoundryA365/src/hello_world_a365_agent/Services/AgentTokenHelper.cs deleted file mode 100644 index 3a83fa7f4..000000000 --- a/samples/csharp/FoundryA365/src/hello_world_a365_agent/Services/AgentTokenHelper.cs +++ /dev/null @@ -1,324 +0,0 @@ -namespace HelloWorldA365.Services; - -using Azure.Core; -using Azure.Identity; -using Microsoft.Identity.Client; -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; -using System.Text; -using System.Text.Json; - -public class AgentTokenHelper(ILogger logger) -{ - /// - /// Performs the three-step agentic user identity token acquisition process - /// - /// The Agent Application ID - /// The Agent Application Instance ID - /// The user's UPN - /// Base64 encoded certificate data - /// The Azure AD tenant ID - /// The scopes to request for the token - /// The final user token for Graph API calls - public async Task GetAgenticUserTokenAsync(string agentAppId, string agentAppInstanceId, string userUpn, string certificateData, string tenantId, string[] scopes) - { - // Return value will look like: - // {"token_type":"Bearer","expires_in":3599,"ext_expires_in":3599,"access_token":"eyJ0eX..."} - try - { - var isClientSecret = false; - var useManagedIdentity = string.IsNullOrEmpty(certificateData); - X509Certificate2? certificate = null; - AuthenticationResult? agentTokenResult = null; - // Parse certificate data - - if (!useManagedIdentity) - { - try - { - var certificateBytes = Convert.FromBase64String(certificateData); - certificate = X509CertificateLoader.LoadPkcs12(certificateBytes, null); - } - catch (Exception ex) - { - logger.LogError(ex, "Error loading certificate from provided data."); - isClientSecret = true; - } - } - - - // FIRST: Get AAD token for AgentAppId - // - // client_id : AgentAppId - // scope : api://AzureAdTokenExchange/.default - // grant_type : client_credentials - // fmi_path : AgentAppInstanceId - // client_secret : Secret - // - // However, implementation is changed to use certificate instead of direct secret - // - if (isClientSecret) - { - agentTokenResult = await GetTokenWithCustomParametersAsync( - agentAppId, - tenantId, - certificateData, - ["api://AzureAdTokenExchange/.default"], - new Dictionary { { "fmi_path", agentAppInstanceId } }); - } - else - { - agentTokenResult = await GetTokenWithCustomParametersAsync( - agentAppId, - tenantId, - ["api://AzureAdTokenExchange/.default"], - new Dictionary { { "fmi_path", agentAppInstanceId } }, - certificate); - } - - // SECOND: Get AAD token for AgentAppInstanceId - // - // client_id : AgentAppInstanceId - // scope : api://AzureAdTokenExchange/.default - // client_assertion_type : urn:ietf:params:oauth:client-assertion-type:jwt-bearer - // client_assertion : JWT from FIRST step - // grant_type : client_credentials - // - var instanceApp = ConfidentialClientApplicationBuilder - .Create(agentAppInstanceId) - .WithClientAssertion((AssertionRequestOptions _) => Task.FromResult(agentTokenResult.AccessToken)) - .WithAuthority(new Uri($"https://login.microsoftonline.com/{tenantId}")) - .Build(); - - var instanceTokenResult = await instanceApp - .AcquireTokenForClient(["api://AzureAdTokenExchange/.default"]) - .ExecuteAsync(); - - // THIRD: Get combined user token - // - // client_id : AgentAppInstanceId - // scope : Team.ReadBasic.All - // client_assertion_type : urn:ietf:params:oauth:client-assertion-type:jwt-bearer - // client_assertion : JWT from FIRST step - // username : userUpn - // user_federated_identity_credential : JWT from SECOND step - // grant_type : user_fic - // - var userToken = await GetUserFederatedIdentityTokenAsync( - agentAppInstanceId, - tenantId, - agentTokenResult.AccessToken, - instanceTokenResult.AccessToken, - userUpn, - scopes); - - return userToken; - } - catch (Exception ex) - { - logger.LogError(ex, "Error acquiring agentic user token"); - throw; - } - } - - private async Task GetTokenWithCustomParametersAsync( - string clientId, - string tenantId, - string[] scopes, - Dictionary extraParameters, - X509Certificate2? certificate) - { - using var httpClient = new HttpClient(); - - var tokenEndpoint = $"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token"; - - // Create client assertion JWT using the certificate - string clientAssertion; - if (certificate == null) - { - var credential = new DefaultAzureCredential(); - var msiTokenResponse = await credential.GetTokenAsync(new TokenRequestContext(new[] { "api://AzureADTokenExchange/.default" })); - clientAssertion = msiTokenResponse.Token; - } - else - { - clientAssertion = CreateClientAssertion(clientId, tokenEndpoint, certificate); - } - - var parameters = new Dictionary - { - { "client_id", clientId }, - { "scope", string.Join(" ", scopes) }, - { "grant_type", "client_credentials" }, - { "client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" }, - { "client_assertion", clientAssertion } - }; - - // Add extra parameters to the request body - foreach (var param in extraParameters) - { - parameters[param.Key] = param.Value; - } - - var content = new FormUrlEncodedContent(parameters); - var response = await httpClient.PostAsync(tokenEndpoint, content); - - if (!response.IsSuccessStatusCode) - { - var errorContent = await response.Content.ReadAsStringAsync(); - throw new InvalidOperationException($"Failed to acquire token: {errorContent}"); - } - - var responseContent = await response.Content.ReadAsStringAsync(); - var tokenResponse = JsonSerializer.Deserialize>(responseContent); - - if (tokenResponse != null && tokenResponse.TryGetValue("access_token", out var accessToken)) - { - // Create a mock AuthenticationResult for compatibility - return new MockAuthenticationResult(accessToken?.ToString() ?? throw new InvalidOperationException("Access token is null")); - } - - throw new InvalidOperationException("Failed to parse access token from response"); - } - - private async Task GetTokenWithCustomParametersAsync( - string clientId, - string tenantId, - string clientSecret, - string[] scopes, - Dictionary extraParameters) - { - using var httpClient = new HttpClient(); - - var tokenEndpoint = $"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token"; - - var parameters = new Dictionary - { - { "client_id", clientId }, - { "scope", string.Join(" ", scopes) }, - { "grant_type", "client_credentials" }, - // { "client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" }, - { "client_secret", clientSecret } - }; - - // Add extra parameters to the request body - foreach (var param in extraParameters) - { - parameters[param.Key] = param.Value; - } - - var content = new FormUrlEncodedContent(parameters); - var response = await httpClient.PostAsync(tokenEndpoint, content); - - if (!response.IsSuccessStatusCode) - { - var errorContent = await response.Content.ReadAsStringAsync(); - throw new InvalidOperationException($"Failed to acquire token: {errorContent}"); - } - - var responseContent = await response.Content.ReadAsStringAsync(); - var tokenResponse = JsonSerializer.Deserialize>(responseContent); - - if (tokenResponse != null && tokenResponse.TryGetValue("access_token", out var accessToken)) - { - // Create a mock AuthenticationResult for compatibility - return new MockAuthenticationResult(accessToken?.ToString() ?? throw new InvalidOperationException("Access token is null")); - } - - throw new InvalidOperationException("Failed to parse access token from response"); - } - - private async Task GetUserFederatedIdentityTokenAsync( - string clientId, - string tenantId, - string clientAssertion, - string userFederatedIdentityCredential, - string username, - string[] scopes) - { - using var httpClient = new HttpClient(); - - var tokenEndpoint = $"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token"; - - var parameters = new Dictionary - { - { "client_id", clientId }, - { "scope", string.Join(" ", scopes) }, - { "client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" }, - { "client_assertion", clientAssertion }, - // { "username", username }, - { "user_federated_identity_credential", userFederatedIdentityCredential }, - { "grant_type", "user_fic" } - }; - - if (username.Contains('@')) - { - parameters["username"] = username; - } - else - { - parameters["user_id"] = username; - } - - - var content = new FormUrlEncodedContent(parameters); - var response = await httpClient.PostAsync(tokenEndpoint, content); - - if (!response.IsSuccessStatusCode) - { - var errorContent = await response.Content.ReadAsStringAsync(); - throw new InvalidOperationException($"Failed to acquire user federated identity token: {errorContent}"); - } - - var responseContent = await response.Content.ReadAsStringAsync(); - var tokenResponse = JsonSerializer.Deserialize>(responseContent); - - if (tokenResponse != null && tokenResponse.TryGetValue("access_token", out var accessToken)) - { - var token = accessToken?.ToString(); - return token ?? throw new InvalidOperationException("Access token is null"); - } - - throw new InvalidOperationException("Failed to parse access token from response"); - } - - private string CreateClientAssertion(string clientId, string audience, X509Certificate2 certificate) - { - // This is a simplified JWT creation - in production you might want to use a proper JWT library - var header = new { alg = "RS256", typ = "JWT", x5t = Convert.ToBase64String(certificate.GetCertHash()) }; - var payload = new - { - iss = clientId, - sub = clientId, - aud = audience, - jti = Guid.NewGuid().ToString(), - nbf = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), - exp = DateTimeOffset.UtcNow.AddMinutes(10).ToUnixTimeSeconds() - }; - - var headerJson = JsonSerializer.Serialize(header); - var payloadJson = JsonSerializer.Serialize(payload); - - var headerBytes = Encoding.UTF8.GetBytes(headerJson); - var payloadBytes = Encoding.UTF8.GetBytes(payloadJson); - - var headerBase64 = Convert.ToBase64String(headerBytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); - var payloadBase64 = Convert.ToBase64String(payloadBytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); - - var signatureInput = $"{headerBase64}.{payloadBase64}"; - var signatureInputBytes = Encoding.UTF8.GetBytes(signatureInput); - - using var rsa = certificate.GetRSAPrivateKey(); - var signature = rsa!.SignData(signatureInputBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - var signatureBase64 = Convert.ToBase64String(signature).TrimEnd('=').Replace('+', '-').Replace('/', '_'); - - return $"{headerBase64}.{payloadBase64}.{signatureBase64}"; - } - - private class MockAuthenticationResult(string accessToken) : AuthenticationResult( - accessToken, false, null, - DateTimeOffset.UtcNow.AddHours(1), DateTimeOffset.UtcNow.AddHours(1), null, null, null, null, - Guid.NewGuid()); - - -} \ No newline at end of file diff --git a/samples/csharp/FoundryA365/src/hello_world_a365_agent/ToolingManifest.json b/samples/csharp/FoundryA365/src/hello_world_a365_agent/ToolingManifest.json deleted file mode 100644 index 6556abae2..000000000 --- a/samples/csharp/FoundryA365/src/hello_world_a365_agent/ToolingManifest.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "mcpServers": [ - { - "mcpServerName": "mcp_OneDriveServer", - "mcpServerUniqueName": "mcp_OneDriveServer" - }, - { - "mcpServerName": "mcp_WordServer", - "mcpServerUniqueName": "mcp_WordServer" - } - ] -} \ No newline at end of file diff --git a/samples/csharp/FoundryA365/src/hello_world_a365_agent/appsettings.Development.json b/samples/csharp/FoundryA365/src/hello_world_a365_agent/appsettings.Development.json deleted file mode 100644 index 9b87d55f1..000000000 --- a/samples/csharp/FoundryA365/src/hello_world_a365_agent/appsettings.Development.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "ToolMCPServer": { - "EnableMCPFunctionLogging": true - }, - "AzureStorageConnectionString": "", - "AgentConfiguration": { - "AgentEmailFilter": "" - } \ No newline at end of file diff --git a/samples/csharp/FoundryA365/.gitignore b/samples/csharp/foundry-ai-teammate/.gitignore similarity index 100% rename from samples/csharp/FoundryA365/.gitignore rename to samples/csharp/foundry-ai-teammate/.gitignore diff --git a/samples/csharp/FoundryA365/azure.yaml b/samples/csharp/foundry-ai-teammate/azure.yaml similarity index 100% rename from samples/csharp/FoundryA365/azure.yaml rename to samples/csharp/foundry-ai-teammate/azure.yaml diff --git a/samples/csharp/FoundryA365/image-1.png b/samples/csharp/foundry-ai-teammate/image-1.png similarity index 100% rename from samples/csharp/FoundryA365/image-1.png rename to samples/csharp/foundry-ai-teammate/image-1.png diff --git a/samples/csharp/FoundryA365/image-2.png b/samples/csharp/foundry-ai-teammate/image-2.png similarity index 100% rename from samples/csharp/FoundryA365/image-2.png rename to samples/csharp/foundry-ai-teammate/image-2.png diff --git a/samples/csharp/FoundryA365/image-3.png b/samples/csharp/foundry-ai-teammate/image-3.png similarity index 100% rename from samples/csharp/FoundryA365/image-3.png rename to samples/csharp/foundry-ai-teammate/image-3.png diff --git a/samples/csharp/FoundryA365/image-4.png b/samples/csharp/foundry-ai-teammate/image-4.png similarity index 100% rename from samples/csharp/FoundryA365/image-4.png rename to samples/csharp/foundry-ai-teammate/image-4.png diff --git a/samples/csharp/FoundryA365/image.png b/samples/csharp/foundry-ai-teammate/image.png similarity index 100% rename from samples/csharp/FoundryA365/image.png rename to samples/csharp/foundry-ai-teammate/image.png diff --git a/samples/csharp/FoundryA365/infra/main.bicep b/samples/csharp/foundry-ai-teammate/infra/main.bicep similarity index 68% rename from samples/csharp/FoundryA365/infra/main.bicep rename to samples/csharp/foundry-ai-teammate/infra/main.bicep index 0bfa2c398..6ba87dcc7 100644 --- a/samples/csharp/FoundryA365/infra/main.bicep +++ b/samples/csharp/foundry-ai-teammate/infra/main.bicep @@ -33,39 +33,29 @@ param cognitiveServicesSku string = 'S0' @allowed(['Basic', 'Standard', 'Premium']) param containerRegistrySku string = 'Basic' -// ================================================================================================= -// Application module parameters -// ================================================================================================= - -@description('Name of the Cognitive Services application') -param applicationName string = '${environmentName}app' - -@description('Display name of the application') -param applicationDisplayName string = '${environmentName} Application' - param agentName string = 'foundry-agent' -@description('Agents configuration for the application') -param agents array = [ - { - agentId: '$azureml://tenants/${tenant().tenantId}/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/accounts/${accountName}/projects/${projectName}/${agentName}' - agentName: agentName - } -] +param maibName string = '${agentName}-maib' // ================================================================================================= // Bot Service module parameters // ================================================================================================= @description('Name of the Bot Service') -param botName string = '${environmentName}bot' +param botName string = '${agentName}-bot' @description('Display name of the bot') -param botDisplayName string = '${environmentName} Bot' +param botDisplayName string = '${agentName} Bot' @description('SKU of the Bot Service') param botServiceSku string = 'F0' +@description('Model name') +param modelName string = 'gpt-5-chat' + +@description('Model version') +param modelVersion string = '2025-10-03' + // ================================================================================================= // Common parameters // ================================================================================================= @@ -88,6 +78,8 @@ module project 'modules/project.bicep' = { tags: tags cognitiveServicesSku: cognitiveServicesSku containerRegistrySku: containerRegistrySku + modelName: modelName + modelVersion: modelVersion } } @@ -99,14 +91,13 @@ module deploymentScriptUmi 'modules/deployment-script-umi.bicep' = { ] } -// 3. Create agent definition as deployment script. -module deploymentScriptAgent 'modules/agent-deployment-script.bicep' = { - name: 'agent-deployment-script' +// 3. Create managed agent identity blueprint using a deployment script as that is a dataplane operation. +module deploymentScriptAgent 'modules/maib-creation-script.bicep' = { + name: 'maib-creation-script' params: { uamiResourceId: deploymentScriptUmi.outputs.uamiResourceId azureAIProjectEndpoint: project.outputs.foundryProjectEndpoint - agentName: agentName - azureContainerRegistryEndpoint: project.outputs.acrloginServer + maibName: maibName } dependsOn: [ deploymentScriptUmi @@ -114,33 +105,18 @@ module deploymentScriptAgent 'modules/agent-deployment-script.bicep' = { } -// 4. Deploy the application module (depends on project) -module application 'modules/application.bicep' = { - name: 'application-deployment' - params: { - accountName: accountName - projectName: projectName - applicationName: applicationName - displayName: applicationDisplayName - agents: agents - } - dependsOn: [ - deploymentScriptAgent - ] -} - -// 5. Deploy the bot service module +// 4. Deploy the bot service module module botService 'modules/botservice.bicep' = { name: 'botservice-deployment' params: { botName: botName displayName: botDisplayName - msaAppId: application.outputs.agentIdentityBlueprintId - endpoint: 'https://${accountName}.services.ai.azure.com/api/projects/${projectName}/applications/${applicationName}/protocols/activityprotocol?api-version=2025-05-15-preview' + msaAppId: deploymentScriptAgent.outputs.blueprintClientId + endpoint: 'https://${accountName}.services.ai.azure.com/api/projects/${projectName}/agents/${agentName}/endpoint/protocols/activityProtocol?api-version=2025-05-15-preview' botServiceSku: botServiceSku } dependsOn: [ - application + deploymentScriptAgent ] } @@ -154,13 +130,12 @@ output AZURE_CONTAINER_REGISTRY_ENDPOINT string = project.outputs.acrloginServer output AZURE_AI_PROJECT_ENDPOINT string = project.outputs.foundryProjectEndpoint @description('Agent identity blueprint ID') -output AGENT_IDENTITY_BLUEPRINT_ID string = application.outputs.agentIdentityBlueprintId - -@description('Application name') -output APPLICATION_NAME string = applicationName +output AGENT_IDENTITY_BLUEPRINT_ID string = deploymentScriptAgent.outputs.blueprintClientId output SUBSCRIPTION_ID string = subscription().subscriptionId +output RESOURCE_GROUP string = resourceGroup().name + output LOCATION string = location output ACCOUNT_NAME string = accountName @@ -173,4 +148,6 @@ output TENANT_ID string = tenant().tenantId output PROJECT_PRINCIPAL_ID string = project.outputs.foundryProjectPrincipalId -output AGENT_VERSION string = deploymentScriptAgent.outputs.agentVersion +output MAIB_NAME string = maibName + +output MODEL_NAME string = modelName diff --git a/samples/csharp/FoundryA365/infra/modules/botservice.bicep b/samples/csharp/foundry-ai-teammate/infra/modules/botservice.bicep similarity index 100% rename from samples/csharp/FoundryA365/infra/modules/botservice.bicep rename to samples/csharp/foundry-ai-teammate/infra/modules/botservice.bicep diff --git a/samples/csharp/FoundryA365/infra/modules/deployment-script-umi.bicep b/samples/csharp/foundry-ai-teammate/infra/modules/deployment-script-umi.bicep similarity index 100% rename from samples/csharp/FoundryA365/infra/modules/deployment-script-umi.bicep rename to samples/csharp/foundry-ai-teammate/infra/modules/deployment-script-umi.bicep diff --git a/samples/csharp/FoundryA365/infra/modules/agent-deployment-script.bicep b/samples/csharp/foundry-ai-teammate/infra/modules/maib-creation-script.bicep similarity index 52% rename from samples/csharp/FoundryA365/infra/modules/agent-deployment-script.bicep rename to samples/csharp/foundry-ai-teammate/infra/modules/maib-creation-script.bicep index f01782a41..e97deba14 100644 --- a/samples/csharp/FoundryA365/infra/modules/agent-deployment-script.bicep +++ b/samples/csharp/foundry-ai-teammate/infra/modules/maib-creation-script.bicep @@ -4,11 +4,8 @@ param uamiResourceId string @description('Azure AI Project Endpoint URL') param azureAIProjectEndpoint string -@description('Agent name for the Azure AI Project') -param agentName string - -@description('Azure Container Registry Endpoint URL') -param azureContainerRegistryEndpoint string +@description('Managed agent identity blueprint name for the Azure AI Project') +param maibName string // PowerShell deployment script resource psScript 'Microsoft.Resources/deploymentScripts@2023-08-01' = { @@ -27,7 +24,7 @@ resource psScript 'Microsoft.Resources/deploymentScripts@2023-08-01' = { timeout: 'PT15M' retentionInterval: 'P1D' - arguments: '-AzureAIProjectEndpoint "${azureAIProjectEndpoint}" -AgentName "${agentName}" -AzureContainerRegistryEndpoint "${azureContainerRegistryEndpoint}"' + arguments: '-AzureAIProjectEndpoint "${azureAIProjectEndpoint}" -MAIBName "${maibName}"' environmentVariables: [ { @@ -41,33 +38,12 @@ resource psScript 'Microsoft.Resources/deploymentScripts@2023-08-01' = { [Parameter(Mandatory = $true)] [string] $AzureAIProjectEndpoint, [Parameter(Mandatory = $true)] - [string] $AgentName, - [Parameter(Mandatory = $true)] - [string] $AzureContainerRegistryEndpoint + [string] $MAIBName ) $ErrorActionPreference = "Stop" - $agentUrl = "$($AzureAIProjectEndpoint)/agents/$($AgentName)/versions?api-version=2025-11-15-preview" - - $agentCreationBody = @{ - definition = @{ - kind = "hosted" - image = "$($AzureContainerRegistryEndpoint)/hello-world-a365-agent:a365preview001" - cpu = "2" - memory = "4Gi" - environment_variables = @{} - container_protocol_versions = @( - @{ - protocol = "activity_protocol" - version = "v1" - } - ) - } - description = "Foundry digital worker." - } - - $jsonBody = $agentCreationBody | ConvertTo-Json -Depth 5 + $maibUrl = "$($AzureAIProjectEndpoint)/managedagentidentityblueprints/$($MAIBName)?api-version=2025-11-15-preview" Write-Host "Connecting with managed identity..." Connect-AzAccount -Identity @@ -83,25 +59,21 @@ resource psScript 'Microsoft.Resources/deploymentScripts@2023-08-01' = { "Authorization" = "Bearer $aiAzureToken" } - Write-Host "Creating agent version at: $agentUrl" - Write-Host "JSON Body:" - Write-Host $jsonBody - - $response = Invoke-RestMethod -Uri $agentUrl ` - -Method Post ` + Write-Host "Creating managed agent identity blueprint at: $maibUrl" + + $response = Invoke-RestMethod -Uri $maibUrl ` + -Method Put ` -Headers $headers ` - -Body $jsonBody ` -ErrorAction Stop Write-Host "" Write-Host "Response:" $response | ConvertTo-Json -Depth 100 | Write-Host + + $blueprintClientId = $response.agentIdentityBlueprint.clientId - # Output the agent version - $agentVersion = $response.version - Write-Host "Agent Version: $agentVersion" $DeploymentScriptOutputs = @{ - agentVersion = $agentVersion + blueprintClientId = $blueprintClientId } ''' @@ -109,4 +81,4 @@ resource psScript 'Microsoft.Resources/deploymentScripts@2023-08-01' = { } } -output agentVersion string = psScript.properties.outputs.agentVersion +output blueprintClientId string = psScript.properties.outputs.blueprintClientId diff --git a/samples/csharp/FoundryA365/infra/modules/project.bicep b/samples/csharp/foundry-ai-teammate/infra/modules/project.bicep similarity index 90% rename from samples/csharp/FoundryA365/infra/modules/project.bicep rename to samples/csharp/foundry-ai-teammate/infra/modules/project.bicep index cc2a6a4f7..45ccfd900 100644 --- a/samples/csharp/FoundryA365/infra/modules/project.bicep +++ b/samples/csharp/foundry-ai-teammate/infra/modules/project.bicep @@ -15,6 +15,9 @@ param containerRegistrySku string = 'Basic' // Cognitive Services account properties param publicNetworkAccess string = 'Enabled' +param modelName string +param modelVersion string + // Cognitive Services Account resource account 'Microsoft.CognitiveServices/accounts@2025-09-01' = { name: accountName @@ -35,18 +38,8 @@ resource account 'Microsoft.CognitiveServices/accounts@2025-09-01' = { } } -resource accountCapHost 'Microsoft.CognitiveServices/accounts/capabilityHosts@2025-10-01-preview' = { - name: 'accountcaphost' - parent: account - properties: { - capabilityHostKind: 'Agents' - enablePublicHostingEnvironment: true - } -} - - // Cognitive Services Project (child resource) -resource project 'Microsoft.CognitiveServices/accounts/projects@2025-09-01' = { +resource project 'Microsoft.CognitiveServices/accounts/projects@2026-03-01' = { parent: account name: projectName location: location @@ -104,8 +97,9 @@ resource cogServicesUserRoleAssignment 'Microsoft.Authorization/roleAssignments@ } } + resource modelDeployment 'Microsoft.CognitiveServices/accounts/deployments@2024-10-01' = { - name: 'gpt-4o' + name: modelName parent: account sku: { name: 'GlobalStandard' @@ -114,14 +108,13 @@ resource modelDeployment 'Microsoft.CognitiveServices/accounts/deployments@2024- properties: { model: { format: 'OpenAI' - name: 'gpt-4o' - version: '2024-11-20' + name: modelName + version: modelVersion } } } - output acrloginServer string = containerRegistry.properties.loginServer output foundryProjectEndpoint string = project.properties.endpoints['AI Foundry API'] diff --git a/samples/csharp/FoundryA365/readme.md b/samples/csharp/foundry-ai-teammate/readme.md similarity index 74% rename from samples/csharp/FoundryA365/readme.md rename to samples/csharp/foundry-ai-teammate/readme.md index 21ce24b53..0376d11c3 100644 --- a/samples/csharp/FoundryA365/readme.md +++ b/samples/csharp/foundry-ai-teammate/readme.md @@ -11,15 +11,14 @@ Ensure you have the following installed: | Requirement | Description | -|------------|-------------| +|-------------|-------------| | [Azure Developer CLI](https://learn.microsoft.com/azure/developer/azure-developer-cli/install-azd) | Infrastructure deployment tool | | [.NET 9.0 SDK](https://dotnet.microsoft.com/download) | Development framework | - ### πŸ” Required Permissions - **Owner** role on the Azure subscription -- **Azure AI User** or **Cognitive Services User** role at subscription or resource group level +- **Foundry User** or **Cognitive Services User** role at subscription or resource group level - **Tenant Admin** role for organization-wide configuration --- @@ -28,26 +27,21 @@ Ensure you have the following installed: ### Step 1: Authenticate -Login to your Azure tenant and authenticate with Azure Developer CLI: - -Based on tenant security settings, sometimes just az login might be sufficient, sometimes one will need to login to each scope that is used in these scripts. +Login to your Azure tenant and authenticate with Azure Developer CLI. Depending on your tenant's security settings, `az login` alone may be sufficient, or you may need to additionally sign in for the specific scopes used by the deployment scripts. ```powershell # Login to Azure CLI az login -az login --scope https://ai.azure.com/.default - -az login --scope https://graph.microsoft.com//.default - -az login --scope https://management.azure.com/.default # Login to Azure Developer CLI azd auth login ``` ### Step 2: Deploy Everything -**Note:** Hosted agents are only available in the **North Central US** region. All resources must be created in this region. +> **πŸ“ Region availability:** This sample uses [Foundry hosted agents](https://learn.microsoft.com/en-us/azure/foundry/agents/quickstarts/quickstart-hosted-agent?pivots=azd). Your Foundry account and other resources must be in a region where hosted agents are available. At the time of writing, supported regions are: +> +> Australia East, Brazil South, Canada Central, Canada East, East US, East US 2, France Central, Germany West Central, Italy North, Japan East, Korea Central, North Central US, Norway East, Poland Central, South Africa North, South Central US, South India, Southeast Asia, Spain Central, Sweden Central, Switzerland North, UAE North, UK South, West Central US, West US, West US 3. #### Optional: Customize Your Agent @@ -112,7 +106,7 @@ After configuring the agent blueprint in Teams Developer Portal, you can now cre ## πŸ—οΈ Architecture Overview -This deployment orchestrates six key components to create a fully functional A365 agent: +This deployment orchestrates five key components to create a fully functional A365 agent: ### 1️⃣ Creating a Foundry Project @@ -120,33 +114,26 @@ Creates a Foundry project configured to support hosted agents with appropriate p πŸ“š [Learn more about prerequisites](https://github.com/microsoft/container_agents_docs?tab=readme-ov-file#11---prerequisites) -### 2️⃣ Creating an Application - -Applications provide stable endpoints and identity for exposing your agent to users while maintaining development flexibility within Foundry. The application is configured to accept requests from Azure Bot Service. - -### 3️⃣ Setting up Azure Bot Service +### 2️⃣ Setting up Azure Bot Service Azure Bot Service acts as a relay between M365 ecosystem interactions and the Foundry application. The bot is configured with: -- Application endpoint -- Application's agent blueprint identity as the appId +- Agent endpoint +- Agent's blueprint identity as the appId -### 4️⃣ Building a Hosted Agent +### 3️⃣ Building a Hosted Agent Docker Image Compiles the sample code into a Docker container and registers it as a hosted agent with the Foundry project. πŸ“š [Learn more about building agents](https://github.com/microsoft/container_agents_docs?tab=readme-ov-file#14---build-agent-image) -### 5️⃣ Deploying the Agent - -Deploys the hosted agent to the application, granting it: +### 4️⃣ Creating the Agent -- Access to the application's identity -- Configuration to serve application requests +Creates the hosted agent using the Docker image above. πŸ“š [Learn more about agent deployment](https://github.com/microsoft/container_agents_docs?tab=readme-ov-file#step-2-deploy-agent) -### 6️⃣ Publishing to Your Organization +### 5️⃣ Publishing to Your Organization Publishes the application to Microsoft 365 via Foundry API, creating a hireable digital worker with: @@ -154,7 +141,22 @@ Publishes the application to Microsoft 365 via Foundry API, creating a hireable - Agent blueprint ID - Digital worker designation -> **⚠️ Important**: The agent requires [admin approval](https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/review-admin-consent-requests#review-and-take-action-on-admin-consent-requests-1) before becoming available for hiring. +> **⚠️ Important:** The agent requires [admin approval](https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/review-admin-consent-requests#review-and-take-action-on-admin-consent-requests-1) before becoming available for hiring. + +--- + +## πŸ“œ Hosted Agent Logs + +If you receive an error, the response will include a `FOUNDRY_AGENT_SESSION_ID`. Use it to stream the hosted agent's session logs: + +```bash +curl -N \ + -H "Authorization: Bearer $TOKEN" \ + -H "Accept: text/event-stream" \ + -H "Cache-Control: no-cache" \ + -H "Foundry-Features: HostedAgents=V1Preview" \ + "https://$ACCOUNT_NAME.services.ai.azure.com/api/projects/$PROJECT_NAME/agents/$AGENT_NAME/sessions/$SESSION_NAME:logstream?api-version=2025-11-15-preview" +``` --- diff --git a/samples/csharp/foundry-ai-teammate/scripts/add-current-user-as-blueprint-owner.ps1 b/samples/csharp/foundry-ai-teammate/scripts/add-current-user-as-blueprint-owner.ps1 new file mode 100644 index 000000000..6efe6b3a8 --- /dev/null +++ b/samples/csharp/foundry-ai-teammate/scripts/add-current-user-as-blueprint-owner.ps1 @@ -0,0 +1,63 @@ +#!/usr/bin/env pwsh +$ErrorActionPreference = "Stop" + +Write-Host "Adding current az login user as owner on the blueprint application..." + +$blueprintAppId = $env:AGENT_IDENTITY_BLUEPRINT_ID +if ([string]::IsNullOrEmpty($blueprintAppId)) { + throw "AGENT_IDENTITY_BLUEPRINT_ID environment variable is not set." +} + +# Get the current signed-in user's object ID (works for user principals; service principals are not supported here). +$currentUserId = az ad signed-in-user show --query id -o tsv +if ([string]::IsNullOrEmpty($currentUserId)) { + throw "Failed to get the current signed-in user's object ID. Make sure you are logged in via 'az login'." +} + +Write-Host "Current user object ID: $currentUserId" + +# Resolve the blueprint application's object ID from its App ID. +$blueprintAppObjectId = az ad app show --id $blueprintAppId --query id -o tsv +if ([string]::IsNullOrEmpty($blueprintAppObjectId)) { + throw "Failed to get application object ID for blueprint app ID $blueprintAppId" +} + +Write-Host "Blueprint application object ID: $blueprintAppObjectId" + +$graphToken = az account get-access-token --resource https://graph.microsoft.com/ --query accessToken -o tsv +if ([string]::IsNullOrEmpty($graphToken)) { + throw "Failed to acquire a Microsoft Graph access token." +} + +$ownerBody = @{ + "@odata.id" = "https://graph.microsoft.com/v1.0/directoryObjects/$currentUserId" +} | ConvertTo-Json + +try { + $response = Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/applications/$blueprintAppObjectId/owners/`$ref" ` + -Method Post ` + -Headers @{ + "Content-Type" = "application/json" + "Accept" = "application/json" + "Authorization" = "Bearer $graphToken" + } ` + -Body $ownerBody + + Write-Host "Current user added as owner of blueprint application $blueprintAppId." + if ($response) { + $response | ConvertTo-Json -Depth 5 | Write-Host + } +} +catch { + $err = $null + if ($_.ErrorDetails -and $_.ErrorDetails.Message) { + try { $err = $_.ErrorDetails.Message | ConvertFrom-Json } catch { $err = $null } + } + + if ($err -and $err.error -and $err.error.message -like "*One or more added object references already exist*") { + Write-Host "Current user is already an owner of the blueprint application; ignoring." + } + else { + throw + } +} diff --git a/samples/csharp/foundry-ai-teammate/scripts/agent-creation-script.ps1 b/samples/csharp/foundry-ai-teammate/scripts/agent-creation-script.ps1 new file mode 100644 index 000000000..2119a54b6 --- /dev/null +++ b/samples/csharp/foundry-ai-teammate/scripts/agent-creation-script.ps1 @@ -0,0 +1,161 @@ + $ErrorActionPreference = "Stop" + + $AzureAIProjectEndpoint = $env:AZURE_AI_PROJECT_ENDPOINT + $AgentName = $env:AGENT_NAME + $AzureContainerRegistryEndpoint = $env:AZURE_CONTAINER_REGISTRY_ENDPOINT + $MAIBName = $env:MAIB_NAME + + + $agentUrl = "$($AzureAIProjectEndpoint)/agents/$($AgentName)/versions?api-version=2025-11-15-preview" + + $agentCreationBody = @{ + definition = @{ + kind = "hosted" + image = "$($AzureContainerRegistryEndpoint)/hello-world-a365-agent:latest" + cpu = "2" + memory = "4Gi" + environment_variables = @{} + container_protocol_versions = @( + @{ + protocol = "activity_protocol" + version = "v1" + } + ) + } + metadata = @{ + enableVnextExperience = "true" + } + description = "Foundry digital worker." + agent_endpoint = @{ + protocols = @("activity") + } + blueprint_reference = @{ + type = "ManagedAgentIdentityBlueprint" + blueprint_id = $MAIBName + } + } + + $jsonBody = $agentCreationBody | ConvertTo-Json -Depth 5 + + Write-Host "Getting access token for https://ai.azure.com ..." + + $aiAzureToken = az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv + + + Write-Host "Token length: $($aiAzureToken.Length)" + + $headers = @{ + "Content-Type" = "application/json" + "Accept" = "application/json" + "Authorization" = "Bearer $aiAzureToken" + "Foundry-Features" = "HostedAgents=V1Preview,AgentEndpoints=V1Preview" + } + + Write-Host "Creating agent version at: $agentUrl" + Write-Host "JSON Body:" + Write-Host $jsonBody + + $response = Invoke-RestMethod -Uri $agentUrl ` + -Method Post ` + -Headers $headers ` + -Body $jsonBody ` + -ErrorAction Stop + + Write-Host "" + Write-Host "Response:" + $response | ConvertTo-Json -Depth 100 | Write-Host + + # Output the agent version + $agentVersion = $response.version + $agentGuid = $response.agent_guid + $agentDefaultInstanceClientId = $response.instance_identity.client_id + Write-Host "Agent GUID: $agentGuid" + Write-Host "Agent Version: $agentVersion" + + # Poll for agent version provisioning status + $maxRetries = 30 + $delaySeconds = 10 + $provisioningStatus = $response.status + if (-not $provisioningStatus) { $provisioningStatus = "Unknown" } + + Write-Host "Initial provisioning status: $provisioningStatus" + + $pollUrl = "$($AzureAIProjectEndpoint)/agents/$($AgentName)/versions/$($agentVersion)?api-version=2025-11-15-preview" + + if ($provisioningStatus -ne "active" -and $provisioningStatus -ne "failed") { + for ($i = 1; $i -lt $maxRetries; $i++) { + Write-Host "Waiting ${delaySeconds}s before poll $($i + 1)/${maxRetries}..." + Start-Sleep -Seconds $delaySeconds + + try { + $pollResponse = Invoke-RestMethod -Uri $pollUrl ` + -Method Get ` + -Headers $headers ` + -ErrorAction Stop + + $provisioningStatus = $pollResponse.status + if (-not $provisioningStatus) { $provisioningStatus = "Unknown" } + } catch { + Write-Host "Poll failed: $($_.Exception.Message)" + } + + Write-Host "Provisioning status: $provisioningStatus" + + if ($provisioningStatus -eq "active" -or $provisioningStatus -eq "failed") { + break + } + } + } + + Write-Host "Agent version provisioned: $provisioningStatus" + + if ($provisioningStatus -ne "active") { + throw "Agent version provisioning status is '$provisioningStatus', expected 'active'." + } + + # Grant Cognitive Services User role on the foundry account to the agent's default instance identity. + $accountScope = "/subscriptions/$($env:SUBSCRIPTION_ID)/resourceGroups/$($env:RESOURCE_GROUP)/providers/Microsoft.CognitiveServices/accounts/$($env:ACCOUNT_NAME)" + $cognitiveServicesUserRoleId = "a97b65f3-24c7-4388-baec-2e87135dc908" + + Write-Host "Granting Cognitive Services User role to client id $agentDefaultInstanceClientId on scope $accountScope" + + $roleAssignmentOutput = az role assignment create ` + --assignee $agentDefaultInstanceClientId ` + --role $cognitiveServicesUserRoleId ` + --scope $accountScope 2>&1 | Out-String + + if ($LASTEXITCODE -eq 0) { + Write-Host "Cognitive Services User role assignment created." + } elseif ($roleAssignmentOutput -match "RoleAssignmentExists") { + Write-Host "Cognitive Services User role assignment already exists, skipping." + } else { + throw "Failed to create Cognitive Services User role assignment: $roleAssignmentOutput" + } + + # Patch agent endpoint with activity protocol + $patchUrl = "$($AzureAIProjectEndpoint)/agents/$($AgentName)?api-version=2025-11-15-preview" + $patchBody = @{ + agent_endpoint = @{ + protocols = @("activity") + authorization_schemes = @( + @{ "type" = "BotServiceRbac" } + ) + } + } | ConvertTo-Json -Depth 5 + + Write-Host "Patching agent endpoint at: $patchUrl" + Write-Host "Patch Body:" + Write-Host $patchBody + + $patchResponse = Invoke-RestMethod -Uri $patchUrl ` + -Method Patch ` + -Headers $headers ` + -Body $patchBody ` + -ErrorAction Stop + + Write-Host "" + Write-Host "Patch Response:" + $patchResponse | ConvertTo-Json -Depth 100 | Write-Host + + # Return agent GUID for downstream scripts + return $agentGuid diff --git a/samples/csharp/FoundryA365/scripts/build-docker-image-acr.ps1 b/samples/csharp/foundry-ai-teammate/scripts/build-docker-image-acr.ps1 similarity index 72% rename from samples/csharp/FoundryA365/scripts/build-docker-image-acr.ps1 rename to samples/csharp/foundry-ai-teammate/scripts/build-docker-image-acr.ps1 index 9d6cd742d..75b1b792c 100644 --- a/samples/csharp/FoundryA365/scripts/build-docker-image-acr.ps1 +++ b/samples/csharp/foundry-ai-teammate/scripts/build-docker-image-acr.ps1 @@ -4,25 +4,22 @@ Set-Location "$($PSScriptRoot)/../src/hello_world_a365_agent" Remove-Item "./publish" -Recurse -Force -ErrorAction SilentlyContinue +Remove-Item "./.vs" -Recurse -Force -ErrorAction SilentlyContinue +Remove-Item "./bin" -Recurse -Force -ErrorAction SilentlyContinue +Remove-Item "./obj" -Recurse -Force -ErrorAction SilentlyContinue dotnet publish -c Release -o "./publish" $authorityEndpoint = "https://login.microsoftonline.com/$($env:TENANT_ID)" $azureOpenAIEndpoint = "https://$($env:ACCOUNT_NAME).openai.azure.com/" -$projectClientId = az ad sp show --id $env:PROJECT_PRINCIPAL_ID --query appId -o tsv - -# if the projectClientId is null or empty, throw an error -if ([string]::IsNullOrEmpty($projectClientId)) { - throw "Failed to get project client ID for principal ID $($env:PROJECT_PRINCIPAL_ID)" -} $acrLoginServer = $env:AZURE_CONTAINER_REGISTRY_ENDPOINT # split the login server to get the registry name $registryName = $acrLoginServer.Split(".")[0] -$imageName = "hello-world-a365-agent:a365preview001" +$imageName = "hello-world-a365-agent:latest" Write-Host "Building image using ACR Build in registry: $registryName" @@ -33,9 +30,8 @@ az acr build ` --file "./foundry-infra/Dockerfile" ` --build-arg BLUEPRINT_CLIENT_ID=$env:AGENT_IDENTITY_BLUEPRINT_ID ` --build-arg AUTHORITY_ENDPOINT=$authorityEndpoint ` - --build-arg FEDERATED_CLIENT_ID=$projectClientId ` --build-arg AZURE_OPENAI_ENDPOINT=$azureOpenAIEndpoint ` - --build-arg MODEL_DEPLOYMENT='gpt-4o' ` + --build-arg MODEL_DEPLOYMENT=$env:MODEL_NAME ` . if ($LASTEXITCODE -ne 0) { diff --git a/samples/csharp/foundry-ai-teammate/scripts/configure-blueprint-backend.ps1 b/samples/csharp/foundry-ai-teammate/scripts/configure-blueprint-backend.ps1 new file mode 100644 index 000000000..9d284deb9 --- /dev/null +++ b/samples/csharp/foundry-ai-teammate/scripts/configure-blueprint-backend.ps1 @@ -0,0 +1,58 @@ +#!/usr/bin/env pwsh +$ErrorActionPreference = "Stop" + +Write-Host "Configuring blueprint backend configuration in Teams Developer Portal..." + +$blueprintId = $env:AGENT_IDENTITY_BLUEPRINT_ID +if ([string]::IsNullOrEmpty($blueprintId)) { + throw "AGENT_IDENTITY_BLUEPRINT_ID environment variable is not set." +} + +# The Teams Developer Portal API expects a token with audience https://dev.teams.microsoft.com. +# If this fails, run: az login --scope https://dev.teams.microsoft.com/.default +$token = az account get-access-token --resource https://dev.teams.microsoft.com --query accessToken -o tsv +if ([string]::IsNullOrEmpty($token)) { + throw "Failed to acquire access token for https://dev.teams.microsoft.com. Try: az login --scope https://dev.teams.microsoft.com/.default" +} + +$url = "https://dev.teams.microsoft.com/api/v1.0/agentblueprints/$blueprintId/backendConfiguration" + +# Bot ID is the same as the agent blueprint ID (see readme Step 4). +$body = @{ + type = "botBased" + botBased = @{ + botId = $blueprintId + } +} | ConvertTo-Json -Depth 5 + +Write-Host "PUT $url" +Write-Host "Body:" +Write-Host $body + +try { + $response = Invoke-RestMethod -Uri $url ` + -Method Put ` + -Headers @{ + "Content-Type" = "application/json" + "Accept" = "application/json" + "Authorization" = "Bearer $token" + } ` + -Body $body + + Write-Host "" + Write-Host "Response:" + if ($response) { + $response | ConvertTo-Json -Depth 5 | Write-Host + } else { + Write-Host "(empty response)" + } +} +catch { + Write-Host "Failed to configure blueprint backend: $($_.Exception.Message)" + if ($_.ErrorDetails -and $_.ErrorDetails.Message) { + Write-Host "Error details: $($_.ErrorDetails.Message)" + } + throw +} + +Write-Host "Blueprint backend configuration completed for blueprint $blueprintId." diff --git a/samples/csharp/FoundryA365/scripts/create-blueprintsp-oauth2-grants.ps1 b/samples/csharp/foundry-ai-teammate/scripts/create-blueprintsp-oauth2-grants.ps1 similarity index 100% rename from samples/csharp/FoundryA365/scripts/create-blueprintsp-oauth2-grants.ps1 rename to samples/csharp/foundry-ai-teammate/scripts/create-blueprintsp-oauth2-grants.ps1 diff --git a/samples/csharp/FoundryA365/scripts/post-provision.ps1 b/samples/csharp/foundry-ai-teammate/scripts/post-provision.ps1 similarity index 52% rename from samples/csharp/FoundryA365/scripts/post-provision.ps1 rename to samples/csharp/foundry-ai-teammate/scripts/post-provision.ps1 index b9d3b089f..d34514be1 100644 --- a/samples/csharp/FoundryA365/scripts/post-provision.ps1 +++ b/samples/csharp/foundry-ai-teammate/scripts/post-provision.ps1 @@ -2,24 +2,28 @@ Write-Host "Starting post-provision script..." # AZURE_LOCATION is a default azd environment variable -Write-Host "Resources were deployed to: location $env:AZURE_LOCATION blueprintId $env:AZURE_AGENT_IDENTITY_BLUEPRINT_ID subscriptionId $env:AZURE_SUBSCRIPTION_ID applicationName $env:AZURE_APPLICATION_NAME" +Write-Host "Resources were deployed to: location $env:AZURE_LOCATION blueprintId $env:AZURE_AGENT_IDENTITY_BLUEPRINT_ID subscriptionId $env:AZURE_SUBSCRIPTION_ID agentName $env:AGENT_NAME" + +# Write-Host "===============Building and pushing Docker image===============" +& "$PSScriptRoot/build-docker-image-acr.ps1" + +Write-Host "===============Creating Agent Version===============" +$agentGuid = & "$PSScriptRoot/agent-creation-script.ps1" Write-Host "===============Publishing digital worker===============" -& "$PSScriptRoot/publish-digital-worker.ps1" +& "$PSScriptRoot/publish-digital-worker.ps1" -AgentGuid $agentGuid # TODO: temporary fix until service starts doing it. # oAuth2grants for blueprint SP for inheritable scopes to work. Write-Host "===============OAuth2 grants for blueprint SP===============" & "$PSScriptRoot/create-blueprintsp-oauth2-grants.ps1" +Write-Host "===============Adding current user as blueprint owner===============" +& "$PSScriptRoot/add-current-user-as-blueprint-owner.ps1" -Write-Host "===============Building and pushing Docker image===============" -& "$PSScriptRoot/build-docker-image-acr.ps1" - - -Write-Host "===============Creating Foundry container agent===============" -& "$PSScriptRoot/create-application-deployment.ps1" -AgentName $env:AGENT_NAME -AgentVersion $env:AGENT_VERSION +# Write-Host "===============Configuring blueprint backend in Teams Dev Portal===============" +# & "$PSScriptRoot/configure-blueprint-backend.ps1" Write-Host "" diff --git a/samples/csharp/FoundryA365/scripts/publish-digital-worker.ps1 b/samples/csharp/foundry-ai-teammate/scripts/publish-digital-worker.ps1 similarity index 92% rename from samples/csharp/FoundryA365/scripts/publish-digital-worker.ps1 rename to samples/csharp/foundry-ai-teammate/scripts/publish-digital-worker.ps1 index 02905add7..350b72c58 100644 --- a/samples/csharp/FoundryA365/scripts/publish-digital-worker.ps1 +++ b/samples/csharp/foundry-ai-teammate/scripts/publish-digital-worker.ps1 @@ -1,18 +1,24 @@ #!/usr/bin/env pwsh +param( + [Parameter(Mandatory = $true)] + [string]$AgentGuid +) + $ErrorActionPreference = "Stop" Write-Host "Starting post-provision script..." # AZURE_LOCATION is a default azd environment variable -Write-Host "Resources were deployed to: location $env:LOCATION blueprintId $env:AGENT_IDENTITY_BLUEPRINT_ID subscriptionId $env:SUBSCRIPTION_ID applicationName $env:APPLICATION_NAME" +Write-Host "Resources were deployed to: location $env:LOCATION blueprintId $env:AGENT_IDENTITY_BLUEPRINT_ID subscriptionId $env:SUBSCRIPTION_ID agentName $env:AGENT_NAME agentVersion $env:AGENT_VERSION" # Construct JSON body based on Microsoft365PublishRequest $body = @{ + agentGuid = $AgentGuid botId = $env:AGENT_IDENTITY_BLUEPRINT_ID publishAsDigitalWorker = $true appPublishScope = "Tenant" subscriptionId = $env:SUBSCRIPTION_ID - agentName = $env:APPLICATION_NAME + agentName = $env:AGENT_NAME appVersion = "1.0.0" shortDescription = "Foundry A365 Agent deployed via Azure Developer CLI" fullDescription = "A Foundry A365 agent example that demonstrates integration with Microsoft 365 and Azure Cognitive Services." diff --git a/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/.dockerignore b/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/.dockerignore new file mode 100644 index 000000000..3596ffdc4 --- /dev/null +++ b/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/.dockerignore @@ -0,0 +1,5 @@ +.vs/ +bin/ +obj/ +*.user +*.suo diff --git a/samples/csharp/FoundryA365/src/hello_world_a365_agent/.gitignore b/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/.gitignore similarity index 100% rename from samples/csharp/FoundryA365/src/hello_world_a365_agent/.gitignore rename to samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/.gitignore diff --git a/samples/csharp/FoundryA365/src/hello_world_a365_agent/AgentLogic/A365AgentApplication.cs b/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/AgentLogic/A365AgentApplication.cs similarity index 61% rename from samples/csharp/FoundryA365/src/hello_world_a365_agent/AgentLogic/A365AgentApplication.cs rename to samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/AgentLogic/A365AgentApplication.cs index d0c51cf81..0230bbec9 100644 --- a/samples/csharp/FoundryA365/src/hello_world_a365_agent/AgentLogic/A365AgentApplication.cs +++ b/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/AgentLogic/A365AgentApplication.cs @@ -1,200 +1,265 @@ -namespace HelloWorldA365.AgentLogic; - -using HelloWorldA365.Models; -using Microsoft.Agents.Builder.App; -using Microsoft.Agents.Core.Models; -using AgentNotification; -using Microsoft.Agents.A365.Notifications.Models; - -/// -/// This is main handler for incoming activities, and is linked to Agent SDK infrastructure. -/// This will need to resolve the incoming activity to the correct agent instance. -/// -public class A365AgentApplication : AgentApplication -{ - private readonly AgentLogicServiceFactory _factory; - private readonly IConfiguration _configuration; - - public A365AgentApplication( - AgentApplicationOptions options, - AgentLogicServiceFactory factory, - IConfiguration configuration) : base(options) - { - _factory = factory ?? throw new ArgumentNullException(nameof(factory)); - _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); - // Configure the agent to handle message activities - ConfigureMessageHandling(); - } - - /// - /// Configures message handling for the agent. - /// - private void ConfigureMessageHandling() - { - // Handle Email notifications using the AgentNotification extension - this.OnAgenticEmailNotification(async (turnContext, turnState, agentNotificationActivity, cancellationToken) => - { - var agent = await GetAgentFromRecipient(turnContext.Activity); - var agentService = await _factory.GetService(agent, turnContext); - if (agent.IsMessagingEnabled || true) - { - // Use the specific email notification handler - await agentService.HandleEmailNotificationAsync(turnContext, turnState, agentNotificationActivity); - } - else - { - await agentService.NewActivityReceived(turnContext, turnState, cancellationToken); - } - }); - - // Handle Word notifications - this.OnAgenticWordNotification(async (turnContext, turnState, agentNotificationActivity, cancellationToken) => - { - var agent = await GetAgentFromRecipient(turnContext.Activity); - var agentService = await _factory.GetService(agent, turnContext); - - if (agent.IsMessagingEnabled) - { - // Use the specific comment notification handler for Word documents - await agentService.HandleCommentNotificationAsync(turnContext, turnState, agentNotificationActivity); - } - else - { - await agentService.NewActivityReceived(turnContext, turnState, cancellationToken); - } - }); - - // Handle Excel notifications - this.OnAgenticExcelNotification(async (turnContext, turnState, agentNotificationActivity, cancellationToken) => - { - var agent = await GetAgentFromRecipient(turnContext.Activity); - var agentService = await _factory.GetService(agent, turnContext); - - if (agent.IsMessagingEnabled) - { - // Use the specific comment notification handler for Excel documents - await agentService.HandleCommentNotificationAsync(turnContext, turnState, agentNotificationActivity); - } - else - { - await agentService.NewActivityReceived(turnContext, turnState, cancellationToken); - } - }); - - // Handle PowerPoint notifications - this.OnAgenticPowerPointNotification(async (turnContext, turnState, agentNotificationActivity, cancellationToken) => - { - var agent = await GetAgentFromRecipient(turnContext.Activity); - var agentService = await _factory.GetService(agent, turnContext); - - if (agent.IsMessagingEnabled) - { - // Use the specific comment notification handler for PowerPoint documents - await agentService.HandleCommentNotificationAsync(turnContext, turnState, agentNotificationActivity); - } - else - { - await agentService.NewActivityReceived(turnContext, turnState, cancellationToken); - } - }); - - OnActivity(ActivityTypes.Message, async (turnContext, turnState, cancellationToken) => - { - // Based on the recipient, determine which agent to use - var agent = await GetAgentFromRecipient(turnContext.Activity); - - // Get agent logic service from factory - var agentService = await _factory.GetService(agent, turnContext); - - // Ignoring all other channel Ids to prevent duplicate notifications. - if (agent.IsMessagingEnabled && turnContext.Activity.ChannelId != "msteams") - { - return; - } - - // Execute logic - await agentService.NewActivityReceived(turnContext, turnState, cancellationToken); - }); - - // Keep existing handlers for backward compatibility - OnActivity(ActivityTypes.Event, async (turnContext, turnState, cancellationToken) => - { - var agent = await GetAgentFromRecipient(turnContext.Activity); - var agentService = await _factory.GetService(agent, turnContext); - - await agentService.NewActivityReceived(turnContext, turnState, cancellationToken); - }); - - OnActivity(ActivityTypes.InstallationUpdate, async (turnContext, turnState, cancellationToken) => - { - var agent = await GetAgentFromRecipient(turnContext.Activity); - var agentService = await _factory.GetService(agent, turnContext); - - if (agent.IsMessagingEnabled) - { - // Create AgentNotificationActivity for installation updates - var agentNotificationActivity = new AgentNotificationActivity(turnContext.Activity); - await agentService.HandleInstallationUpdateAsync(turnContext, turnState, agentNotificationActivity); - } - else - { - await agentService.NewActivityReceived(turnContext, turnState, cancellationToken); - } - }); - } - - private async Task GetAgentFromRecipient(IActivity activity) - { - ChannelAccount recipient = activity.Recipient; - ConversationAccount conversation = activity.Conversation; - - if (recipient == null) - { - throw new ArgumentNullException(nameof(recipient), "Recipient cannot be null."); - } - - // Recipient will have an ID, but this may not be sufficient to determine the agent. - // ChannelAccount recipient currently has an AadObjectId, which we can try using to identify the user. - // If activityProtocol and SDK is changed to pass a new field, we can update this code to use that instead. - var aadObjectId = Guid.TryParse(recipient.AadObjectId, out var parsedId) ? parsedId : Guid.Empty; - var id = recipient.Id; - var tenantId = Guid.TryParse(conversation.TenantId, out var parsedTenantId) ? parsedTenantId : Guid.Empty; - return ConstructAgentMetadataFromActivity(activity); - } - - private AgentMetadata ConstructAgentMetadataFromActivity(IActivity activity) - { - if (activity == null) - { - throw new ArgumentNullException(nameof(activity), "Activity cannot be null."); - } - - var recipient = activity.Recipient; - var conversation = activity.Conversation; - - if (recipient == null || conversation == null) - { - throw new ArgumentException("Activity must have a recipient and conversation."); - } - - var tenantId = Guid.TryParse(recipient.TenantId, out var parsedTenantId) ? parsedTenantId : Guid.Empty; - - // AAI - var agenticAppId = Guid.TryParse(recipient.AgenticAppId, out var parsedAgenticAppId) ? parsedAgenticAppId : Guid.Empty; - - // This could be agentic user email? - var agenticUserId = recipient.AgenticUserId ?? recipient.AadObjectId; - - - - return new AgentMetadata - { - UserId = Guid.Parse(agenticUserId), - EmailId = recipient.Id.Contains('@') ? recipient.Id : recipient.Name, - AgentId = agenticAppId, - AgentApplicationId = recipient.Properties.TryGetValue("agenticAppBlueprintId", out var agentAppBlueprintId) ? Guid.Parse(agentAppBlueprintId.ToString()) : Guid.TryParse(recipient.Id, out var parsedId) ? parsedId : Guid.Empty, - TenantId = tenantId, - AgentFriendlyName = recipient.Name, - }; - } -} +namespace HelloWorldA365.AgentLogic; + +using HelloWorldA365.AgentLogic.ResponsesApi; +using HelloWorldA365.Models; +using Microsoft.Agents.Builder.App; +using Microsoft.Agents.Core.Models; +using AgentNotification; +using Microsoft.Agents.A365.Notifications.Models; + +/// +/// This is main handler for incoming activities, and is linked to Agent SDK infrastructure. +/// This will need to resolve the incoming activity to the correct agent instance. +/// +public class A365AgentApplication : AgentApplication +{ + private readonly ResponsesApiAgentLogicServiceFactory _factory; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public A365AgentApplication( + AgentApplicationOptions options, + ResponsesApiAgentLogicServiceFactory factory, + ILogger logger, + IConfiguration configuration) : base(options) + { + _factory = factory ?? throw new ArgumentNullException(nameof(factory)); + _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + // Configure the agent to handle message activities + ConfigureMessageHandling(); + _logger = logger; + } + + /// + /// Configures message handling for the agent. + /// + private void ConfigureMessageHandling() + { + // Handle Email notifications using the AgentNotification extension + this.OnAgenticEmailNotification(async (turnContext, turnState, agentNotificationActivity, cancellationToken) => + { + var agent = await GetAgentFromRecipient(turnContext.Activity); + var agentService = await _factory.CreateAsync(agent, turnContext, UserAuthorization); + if (agent.IsMessagingEnabled || true) + { + // Use the specific email notification handler + await agentService.HandleEmailNotificationAsync(turnContext, turnState, agentNotificationActivity); + } + else + { + await agentService.NewActivityReceived(turnContext, turnState, cancellationToken); + } + }); + + // Handle Word notifications + this.OnAgenticWordNotification(async (turnContext, turnState, agentNotificationActivity, cancellationToken) => + { + var agent = await GetAgentFromRecipient(turnContext.Activity); + var agentService = await _factory.CreateAsync(agent, turnContext, UserAuthorization); + + if (agent.IsMessagingEnabled) + { + // Use the specific comment notification handler for Word documents + await agentService.HandleCommentNotificationAsync(turnContext, turnState, agentNotificationActivity); + } + else + { + await agentService.NewActivityReceived(turnContext, turnState, cancellationToken); + } + }); + + // Handle Excel notifications + this.OnAgenticExcelNotification(async (turnContext, turnState, agentNotificationActivity, cancellationToken) => + { + var agent = await GetAgentFromRecipient(turnContext.Activity); + var agentService = await _factory.CreateAsync(agent, turnContext, UserAuthorization); + + if (agent.IsMessagingEnabled) + { + // Use the specific comment notification handler for Excel documents + await agentService.HandleCommentNotificationAsync(turnContext, turnState, agentNotificationActivity); + } + else + { + await agentService.NewActivityReceived(turnContext, turnState, cancellationToken); + } + }); + + // Handle PowerPoint notifications + this.OnAgenticPowerPointNotification(async (turnContext, turnState, agentNotificationActivity, cancellationToken) => + { + var agent = await GetAgentFromRecipient(turnContext.Activity); + var agentService = await _factory.CreateAsync(agent, turnContext, UserAuthorization); + + if (agent.IsMessagingEnabled) + { + // Use the specific comment notification handler for PowerPoint documents + await agentService.HandleCommentNotificationAsync(turnContext, turnState, agentNotificationActivity); + } + else + { + await agentService.NewActivityReceived(turnContext, turnState, cancellationToken); + } + }); + + OnActivity(ActivityTypes.Message, async (turnContext, turnState, cancellationToken) => + { + var streamingStarted = false; + try + { + _logger.LogInformation("Received message activity: {ActivityId} from {UserId} activity {activity}", turnContext.Activity.Id, turnContext.Activity.From?.Id, System.Text.Json.JsonSerializer.Serialize(turnContext.Activity)); + // Based on the recipient, determine which agent to use + var agent = await GetAgentFromRecipient(turnContext.Activity); + + // Get agent logic service from factory + var agentService = await _factory.CreateAsync(agent, turnContext, UserAuthorization); + + // Ignoring all other channel Ids to prevent duplicate notifications. + if (agent.IsMessagingEnabled && turnContext.Activity.ChannelId != "msteams") + { + return; + } + + // Let the user know the agent is working on the prompt. + // This is a no-op on channels that don't support streaming/typing updates. + await turnContext.StreamingResponse.QueueInformativeUpdateAsync( + "Working on your request...", + cancellationToken); + streamingStarted = true; + + // Execute logic + await agentService.NewActivityReceived(turnContext, turnState, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing message activity"); + + var sessionId = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_SESSION_ID"); + var sessionLine = string.IsNullOrEmpty(sessionId) ? "(not set)" : sessionId; + var errorText = + $"Sorry, something went wrong while processing your message.{Environment.NewLine}" + + $"FOUNDRY_AGENT_SESSION_ID: {sessionLine}{Environment.NewLine}" + + $"Exception:{Environment.NewLine}{ex}"; + + if (streamingStarted) + { + try + { + turnContext.StreamingResponse.QueueTextChunk(errorText); + } + catch (Exception streamEx) + { + _logger.LogWarning(streamEx, "Failed to queue error text on streaming response"); + } + } + else + { + await turnContext.SendActivitiesAsync(new IActivity[] + { + new Activity + { + Type = ActivityTypes.Message, + Text = errorText + } + }, cancellationToken); + } + } + finally + { + if (streamingStarted) + { + try + { + // Always finalize the stream so the channel renders a final message + // instead of falling back to "No text was streamed". + await turnContext.StreamingResponse.EndStreamAsync(cancellationToken); + } + catch (Exception streamEx) + { + _logger.LogWarning(streamEx, "Failed to end streaming response"); + } + } + } + }); + + // Keep existing handlers for backward compatibility + OnActivity(ActivityTypes.Event, async (turnContext, turnState, cancellationToken) => + { + var agent = await GetAgentFromRecipient(turnContext.Activity); + var agentService = await _factory.CreateAsync(agent, turnContext, UserAuthorization); + + await agentService.NewActivityReceived(turnContext, turnState, cancellationToken); + }); + + OnActivity(ActivityTypes.InstallationUpdate, async (turnContext, turnState, cancellationToken) => + { + var agent = await GetAgentFromRecipient(turnContext.Activity); + var agentService = await _factory.CreateAsync(agent, turnContext, UserAuthorization); + + if (agent.IsMessagingEnabled) + { + // Create AgentNotificationActivity for installation updates + var agentNotificationActivity = new AgentNotificationActivity(turnContext.Activity); + await agentService.HandleInstallationUpdateAsync(turnContext, turnState, agentNotificationActivity); + } + else + { + await agentService.NewActivityReceived(turnContext, turnState, cancellationToken); + } + }); + } + + private async Task GetAgentFromRecipient(IActivity activity) + { + ChannelAccount recipient = activity.Recipient; + ConversationAccount conversation = activity.Conversation; + + if (recipient == null) + { + throw new ArgumentNullException(nameof(recipient), "Recipient cannot be null."); + } + + // Recipient will have an ID, but this may not be sufficient to determine the agent. + // ChannelAccount recipient currently has an AadObjectId, which we can try using to identify the user. + // If activityProtocol and SDK is changed to pass a new field, we can update this code to use that instead. + var aadObjectId = Guid.TryParse(recipient.AadObjectId, out var parsedId) ? parsedId : Guid.Empty; + var id = recipient.Id; + var tenantId = Guid.TryParse(conversation.TenantId, out var parsedTenantId) ? parsedTenantId : Guid.Empty; + return ConstructAgentMetadataFromActivity(activity); + } + + private AgentMetadata ConstructAgentMetadataFromActivity(IActivity activity) + { + if (activity == null) + { + throw new ArgumentNullException(nameof(activity), "Activity cannot be null."); + } + + var recipient = activity.Recipient; + var conversation = activity.Conversation; + + if (recipient == null || conversation == null) + { + throw new ArgumentException("Activity must have a recipient and conversation."); + } + + var tenantId = Guid.TryParse(recipient.TenantId, out var parsedTenantId) ? parsedTenantId : Guid.Empty; + + // AAI + var agenticAppId = Guid.TryParse(recipient.AgenticAppId, out var parsedAgenticAppId) ? parsedAgenticAppId : Guid.Empty; + + // This could be agentic user email? + var agenticUserId = recipient.AgenticUserId ?? recipient.AadObjectId; + + + + return new AgentMetadata + { + UserId = Guid.Parse(agenticUserId), + EmailId = recipient.Id.Contains('@') ? recipient.Id : recipient.Name, + AgentId = agenticAppId, + AgentApplicationId = recipient.Properties.TryGetValue("agenticAppBlueprintId", out var agentAppBlueprintId) ? Guid.Parse(agentAppBlueprintId.ToString()) : Guid.TryParse(recipient.Id, out var parsedId) ? parsedId : Guid.Empty, + TenantId = tenantId, + }; + } +} diff --git a/samples/csharp/FoundryA365/src/hello_world_a365_agent/AgentLogic/AgentInstructions.cs b/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/AgentLogic/AgentInstructions.cs similarity index 90% rename from samples/csharp/FoundryA365/src/hello_world_a365_agent/AgentLogic/AgentInstructions.cs rename to samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/AgentLogic/AgentInstructions.cs index 25d5bcfbd..7f76b2f76 100644 --- a/samples/csharp/FoundryA365/src/hello_world_a365_agent/AgentLogic/AgentInstructions.cs +++ b/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/AgentLogic/AgentInstructions.cs @@ -31,5 +31,7 @@ Help user achieve their objectives. - Use the SendEmail function to send any responses back - You can use AAD object ID inside the Activity context's 'From' Field to determine where to respond to emails from. + For teams messages, only use teams mcp tool when a user asks to send a teams message. Otherwise, do not use it. + """.Trim(); } \ No newline at end of file diff --git a/samples/csharp/FoundryA365/src/hello_world_a365_agent/AgentLogic/IAgentLogicService.cs b/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/AgentLogic/IAgentLogicService.cs similarity index 100% rename from samples/csharp/FoundryA365/src/hello_world_a365_agent/AgentLogic/IAgentLogicService.cs rename to samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/AgentLogic/IAgentLogicService.cs diff --git a/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/AgentLogic/ResponsesApi/ResponsesApiAgentLogicService.cs b/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/AgentLogic/ResponsesApi/ResponsesApiAgentLogicService.cs new file mode 100644 index 000000000..25ef29614 --- /dev/null +++ b/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/AgentLogic/ResponsesApi/ResponsesApiAgentLogicService.cs @@ -0,0 +1,328 @@ +namespace HelloWorldA365.AgentLogic.ResponsesApi; + +using Azure.Core; +using Azure.Identity; +using HelloWorldA365.Models; +using Microsoft.Agents.A365.Notifications.Models; +using Microsoft.Agents.Builder; +using Microsoft.Agents.Builder.State; +using Microsoft.Agents.Core.Models; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +/// +/// OpenAI Responses API-based implementation of AgentLogicService. +/// Uses MCP tool definitions directly via the Responses API's native MCP support. +/// +public class ResponsesApiAgentLogicService : IAgentLogicService +{ + private readonly AgentMetadata _agentMetadata; + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + private readonly string _accessToken; + private readonly List _mcpServers; + private readonly HttpClient _httpClient; + + public ResponsesApiAgentLogicService( + AgentMetadata agent, + IConfiguration configuration, + ILogger logger, + string accessToken, + List mcpServers) + { + _agentMetadata = agent ?? throw new ArgumentNullException(nameof(agent)); + _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _accessToken = accessToken; + _mcpServers = mcpServers; + + _httpClient = new HttpClient(); + } + + public async Task NewActivityReceived(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken) + { + var incomingText = turnContext.Activity.Text; + _logger.LogInformation("New activity received (Responses API): {IncomingText}", incomingText); + + var sender = turnContext.Activity.From; + + if (turnContext.Activity.ChannelId == "email" || turnContext.Activity.ChannelId == "agents:email") + { + var subject = string.Empty; + if (turnContext.Activity.ChannelData is JsonElement jsonElement && jsonElement.TryGetProperty("subject", out var subjectProperty)) + { + subject = subjectProperty.GetString() ?? string.Empty; + } + incomingText = $"Please respond to this email From: {sender!.Id}\nSubject: {subject}\nMessage: {incomingText}"; + } + else if (turnContext.Activity.ChannelId == "msteams") + { + incomingText = $"Respond to this chat message with chat id {turnContext.Activity.Conversation.Id} " + + $"From: {sender?.Name} ({sender?.Id})\n" + + $"Message: {incomingText}"; + } + else if (turnContext.Activity.Type == ActivityTypes.InstallationUpdate) + { + incomingText = $"You were just added as a digital worker. Please send an email to {sender!.Id} with information on what you can do."; + } + + var conversationId = turnContext.Activity.Conversation?.Id ?? "default"; + var response = await InvokeResponsesApiAsync(incomingText, conversationId); + + if (turnContext.Activity.Type == ActivityTypes.Message) + { + // The Message handler opens a StreamingResponse via QueueInformativeUpdateAsync + // and ends it with EndStreamAsync in a finally. We must queue a final text chunk + // here (even when extraction yielded no assistant text, e.g. tool-call-only + // outputs) so the channel doesn't render "No text was streamed". + var finalText = string.IsNullOrWhiteSpace(response) ? "Done." : response; + turnContext.StreamingResponse.QueueTextChunk(finalText); + } + else if (!string.IsNullOrEmpty(response)) + { + await turnContext.SendActivityAsync(MessageFactory.Text(response), cancellationToken); + } + } + + public async Task NewEmailReceived(string fromEmail, string subject, string messageBody) + { + var formattedMessage = $"Please respond to this email From: {fromEmail}\nSubject: {subject}\nMessage: {messageBody}"; + return await InvokeResponsesApiAsync(formattedMessage, $"email:{fromEmail}:{subject}"); + } + + public async Task NewChatReceived(string chatId, string fromUser, string messageBody) + { + var formattedMessage = $"Respond to this chat message with chat id {chatId} " + + $"From: {fromUser}\nMessage: {messageBody}"; + return await InvokeResponsesApiAsync(formattedMessage, chatId); + } + + public async Task HandleEmailNotificationAsync(ITurnContext turnContext, ITurnState turnState, AgentNotificationActivity emailEvent) + { + _logger.LogInformation("Processing email notification (Responses API) - NotificationType: {NotificationType}", emailEvent.NotificationType); + var conversationId = turnContext.Activity.Conversation?.Id ?? "email-notification"; + var response = await InvokeResponsesApiAsync(emailEvent.Text ?? string.Empty, conversationId); + var responseActivity = MessageFactory.Text("a"); + responseActivity.Entities.Add(new EmailResponse(response)); + await turnContext.SendActivityAsync(responseActivity); + } + + public Task HandleCommentNotificationAsync(ITurnContext turnContext, ITurnState turnState, AgentNotificationActivity commentEvent) + { + _logger.LogInformation("Processing comment notification (Responses API)"); + return Task.CompletedTask; + } + + public Task HandleTeamsMessageAsync(ITurnContext turnContext, ITurnState turnState, AgentNotificationActivity teamsEvent) + { + _logger.LogInformation("Processing Teams message (Responses API)"); + return Task.CompletedTask; + } + + public Task HandleInstallationUpdateAsync(ITurnContext turnContext, ITurnState turnState, AgentNotificationActivity installationEvent) + { + _logger.LogInformation("Processing installation update (Responses API)"); + return Task.CompletedTask; + } + + /// + /// Invokes the OpenAI Responses API with MCP tools from the manifest. + /// + private async Task InvokeResponsesApiAsync(string input, string conversationId) + { + var envVars = Environment.GetEnvironmentVariables(); + var envLines = new List(envVars.Count); + foreach (System.Collections.DictionaryEntry entry in envVars) + { + envLines.Add($"{entry.Key}={entry.Value}"); + } + envLines.Sort(StringComparer.OrdinalIgnoreCase); + _logger.LogInformation("Process environment variables ({Count}):{NewLine}{EnvVars}", envLines.Count, Environment.NewLine, string.Join(Environment.NewLine, envLines)); + + var endpoint = _configuration["AzureOpenAIEndpoint"] ?? throw new InvalidOperationException("AzureOpenAIEndpoint not configured"); + var deployment = _configuration["ModelDeployment"] ?? throw new InvalidOperationException("ModelDeployment not configured"); + var instructions = AgentInstructions.GetInstructions(_agentMetadata); + + // Build MCP tool definitions from discovered servers + var mcpTools = _mcpServers.Select(server => new + { + type = "mcp", + server_label = server.McpServerName, + server_url = server.Url, + server_description = $"MCP server: {server.McpServerName}", + require_approval = "never", + headers = new Dictionary + { + ["Authorization"] = $"Bearer {_accessToken}" + } + }).ToArray(); + + _logger.LogInformation("Invoking Responses API with {McpToolCount} MCP tool servers", mcpTools.Length); + + // Load previous_response_id for conversation continuity + var previousResponseId = LoadPreviousResponseId(conversationId); + if (previousResponseId != null) + { + _logger.LogInformation("Continuing conversation {ConversationId} with previous_response_id: {PreviousResponseId}", conversationId, previousResponseId); + } + + var requestBody = new Dictionary + { + ["model"] = deployment, + ["instructions"] = instructions, + ["input"] = input, + ["tools"] = mcpTools + }; + + if (previousResponseId != null) + { + requestBody["previous_response_id"] = previousResponseId; + } + + var json = JsonSerializer.Serialize(requestBody, new JsonSerializerOptions + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }); + + _logger.LogDebug("Responses API request: {Request}", json); + + // Use Azure AI Foundry Responses API endpoint (model specified in body) + var requestUrl = $"{endpoint.TrimEnd('/')}/openai/responses?api-version=2025-03-01-preview"; + + using var request = new HttpRequestMessage(HttpMethod.Post, requestUrl); + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + + // Fall back to Bearer token auth (e.g., with DefaultAzureCredential token) + var instanceClientId = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_DEFAULT_INSTANCE_CLIENT_ID") + ?? throw new InvalidOperationException("FOUNDRY_AGENT_DEFAULT_INSTANCE_CLIENT_ID environment variable is not set."); + var credential = new DefaultAzureCredential(new DefaultAzureCredentialOptions + { + ManagedIdentityClientId = instanceClientId, + }); + var token = await credential.GetTokenAsync(new TokenRequestContext(new[] { "https://cognitiveservices.azure.com/.default" }), CancellationToken.None); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token); + + var response = await _httpClient.SendAsync(request); + var responseContent = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + _logger.LogError("Responses API call failed with status {StatusCode}: {Response}", response.StatusCode, responseContent); + return $"I encountered an error processing your request. Status: {response.StatusCode}"; + } + + _logger.LogDebug("Responses API response: {Response}", responseContent); + + // Save the response id for conversation continuity + SaveResponseId(conversationId, responseContent); + + return ExtractOutputText(responseContent); + } + + private static string GetResponseStoreDir() + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return Path.Combine(home, ".a365agent"); + } + + private static string GetResponseIdFilePath(string conversationId) + { + // Sanitize conversation ID for use as filename + var safeId = Convert.ToBase64String(Encoding.UTF8.GetBytes(conversationId)) + .Replace('/', '_').Replace('+', '-').TrimEnd('='); + return Path.Combine(GetResponseStoreDir(), $"{safeId}.responseid"); + } + + private string? LoadPreviousResponseId(string conversationId) + { + try + { + var filePath = GetResponseIdFilePath(conversationId); + if (File.Exists(filePath)) + { + var id = File.ReadAllText(filePath).Trim(); + return string.IsNullOrEmpty(id) ? null : id; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load previous_response_id for conversation {ConversationId}", conversationId); + } + return null; + } + + private void SaveResponseId(string conversationId, string responseJson) + { + try + { + using var doc = JsonDocument.Parse(responseJson); + if (doc.RootElement.TryGetProperty("id", out var idProp)) + { + var responseId = idProp.GetString(); + if (!string.IsNullOrEmpty(responseId)) + { + var dir = GetResponseStoreDir(); + Directory.CreateDirectory(dir); + File.WriteAllText(GetResponseIdFilePath(conversationId), responseId); + _logger.LogDebug("Saved response_id {ResponseId} for conversation {ConversationId}", responseId, conversationId); + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to save response_id for conversation {ConversationId}", conversationId); + } + } + + /// + /// Extracts the final output text from the Responses API response JSON. + /// + private string ExtractOutputText(string responseJson) + { + try + { + using var doc = JsonDocument.Parse(responseJson); + var root = doc.RootElement; + + if (root.TryGetProperty("output", out var output) && output.ValueKind == JsonValueKind.Array) + { + var textParts = new StringBuilder(); + foreach (var item in output.EnumerateArray()) + { + if (item.TryGetProperty("type", out var type) && type.GetString() == "message") + { + if (item.TryGetProperty("content", out var content) && content.ValueKind == JsonValueKind.Array) + { + foreach (var contentItem in content.EnumerateArray()) + { + if (contentItem.TryGetProperty("type", out var contentType) && + contentType.GetString() == "output_text" && + contentItem.TryGetProperty("text", out var text)) + { + textParts.Append(text.GetString()); + } + } + } + } + } + return textParts.ToString(); + } + + // Fallback: try to get a simple text response + if (root.TryGetProperty("output_text", out var simpleText)) + { + return simpleText.GetString() ?? string.Empty; + } + + _logger.LogWarning("Could not extract output text from Responses API response"); + return string.Empty; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error parsing Responses API response"); + return string.Empty; + } + } +} diff --git a/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/AgentLogic/ResponsesApi/ResponsesApiAgentLogicServiceFactory.cs b/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/AgentLogic/ResponsesApi/ResponsesApiAgentLogicServiceFactory.cs new file mode 100644 index 000000000..fbf5070b3 --- /dev/null +++ b/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/AgentLogic/ResponsesApi/ResponsesApiAgentLogicServiceFactory.cs @@ -0,0 +1,101 @@ +namespace HelloWorldA365.AgentLogic.ResponsesApi; + +using System.Net.Http.Headers; +using System.Text.Json; +using Azure.Core; +using HelloWorldA365.Models; +using HelloWorldA365.Services; +using Microsoft.Agents.Builder; +using Microsoft.Agents.Builder.App.UserAuth; + +/// +/// Factory for creating ResponsesApiAgentLogicService instances. +/// Discovers MCP servers either from the Agent365 API or from a local ToolingManifest.json, +/// controlled by the "McpDiscoverySource" config setting ("API" or "Manifest"). +/// +public sealed class ResponsesApiAgentLogicServiceFactory( + IConfiguration configuration, + ILogger logger, + AgentTokenHelper tokenHelper) +{ + private static readonly HttpClient HttpClient = new(); + + public async Task CreateAsync(AgentMetadata agent, ITurnContext turnContext, UserAuthorization userAuthorization) + { + // Acquire token for MCP servers + var requestContext = new TokenRequestContext(["ea9ffc3e-8a23-4a7d-836d-234d7c7565c1/.default"]); + var tokenCredential = new AgentTokenCredential(tokenHelper, agent); + var accessToken = await tokenCredential.GetTokenAsync(requestContext, CancellationToken.None); + + logger.LogInformation("Acquired token for Responses API MCP tools. Expires at: {Expiration}", accessToken.ExpiresOn); + + var mcpServers = await GetMcpServersAsync(agent.AgentId, accessToken.Token); + + IAgentLogicService service = new ResponsesApiAgentLogicService( + agent, + configuration, + logger, + accessToken.Token, + mcpServers); + + return service; + } + + private async Task> GetMcpServersAsync(Guid agentInstanceId, string accessToken) + { + var source = configuration["McpDiscoverySource"] ?? "API"; + + if (source.Equals("Manifest", StringComparison.OrdinalIgnoreCase)) + { + logger.LogInformation("Loading MCP servers from ToolingManifest.json"); + return LoadFromManifest(); + } + + logger.LogInformation("Discovering MCP servers from API for agent {AgentId}", agentInstanceId); + return await DiscoverFromApiAsync(agentInstanceId, accessToken); + } + + private List LoadFromManifest() + { + var manifestPath = Path.Combine(AppContext.BaseDirectory, "ToolingManifest.json"); + if (!File.Exists(manifestPath)) + { + logger.LogWarning("ToolingManifest.json not found at {Path}", manifestPath); + return []; + } + + var json = File.ReadAllText(manifestPath); + var manifest = JsonSerializer.Deserialize(json); + var servers = manifest?.McpServers ?? []; + logger.LogInformation("Loaded {Count} MCP servers from ToolingManifest.json", servers.Count); + return servers; + } + + private async Task> DiscoverFromApiAsync(Guid agentInstanceId, string accessToken) + { + var url = $"https://agent365.svc.cloud.microsoft/agents/v2/{agentInstanceId}/mcpServers"; + logger.LogInformation("Discovering MCP servers from {Url}", url); + + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + var response = await HttpClient.SendAsync(request); + var responseContent = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + logger.LogError("Failed to discover MCP servers. Status: {StatusCode}, Response: {Response}", response.StatusCode, responseContent); + return []; + } + + var servers = JsonSerializer.Deserialize>(responseContent) ?? []; + logger.LogInformation("Discovered {Count} MCP servers for agent {AgentId}", servers.Count, agentInstanceId); + + foreach (var server in servers) + { + logger.LogInformation(" MCP Server: {Name} ({Url})", server.McpServerName, server.Url); + } + + return servers; + } +} diff --git a/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/Build.Common.core.props b/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/Build.Common.core.props new file mode 100644 index 000000000..08c196824 --- /dev/null +++ b/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/Build.Common.core.props @@ -0,0 +1,9 @@ + + + net8.0 + false + enable + enable + true + + diff --git a/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/HelloWorldA365Agent.csproj b/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/HelloWorldA365Agent.csproj new file mode 100644 index 000000000..3e309ae5c --- /dev/null +++ b/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/HelloWorldA365Agent.csproj @@ -0,0 +1,47 @@ +ο»Ώ + + + HelloWorldA365 + net9.0 + enable + enable + 6d3109d3-6ff5-438e-aa23-cddd3703b4cb + + + + + 1.15.3 + + + + + + + + + + + PreserveNewest + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/HelloWorldA365Agent.sln b/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/HelloWorldA365Agent.sln new file mode 100644 index 000000000..91db1963e --- /dev/null +++ b/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/HelloWorldA365Agent.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HelloWorldA365Agent", "HelloWorldA365Agent.csproj", "{036DE73A-2484-FA6D-3988-3D7DB76EB74E}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {036DE73A-2484-FA6D-3988-3D7DB76EB74E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {036DE73A-2484-FA6D-3988-3D7DB76EB74E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {036DE73A-2484-FA6D-3988-3D7DB76EB74E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {036DE73A-2484-FA6D-3988-3D7DB76EB74E}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {A62A6059-F7FB-4D65-A242-68CC4925C04A} + EndGlobalSection +EndGlobal diff --git a/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/Models/AgentMetadata.cs b/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/Models/AgentMetadata.cs new file mode 100644 index 000000000..5a95f181f --- /dev/null +++ b/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/Models/AgentMetadata.cs @@ -0,0 +1,11 @@ +namespace HelloWorldA365.Models; + +public class AgentMetadata +{ + public Guid UserId { get; set; } + public Guid AgentId { get; set; } + public Guid AgentApplicationId { get; set; } + public Guid TenantId { get; set; } + public string EmailId { get; set; } = string.Empty; + public bool IsMessagingEnabled { get; set; } = false; +} \ No newline at end of file diff --git a/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/Models/McpServerConfig.cs b/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/Models/McpServerConfig.cs new file mode 100644 index 000000000..7ed0d26bd --- /dev/null +++ b/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/Models/McpServerConfig.cs @@ -0,0 +1,30 @@ +namespace HelloWorldA365.Models; + +using System.Text.Json.Serialization; + +public class ToolingManifest +{ + [JsonPropertyName("mcpServers")] + public List McpServers { get; set; } = []; +} + +public class McpServerConfig +{ + [JsonPropertyName("mcpServerName")] + public string McpServerName { get; set; } = string.Empty; + + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("url")] + public string Url { get; set; } = string.Empty; + + [JsonPropertyName("scope")] + public string Scope { get; set; } = string.Empty; + + [JsonPropertyName("audience")] + public string Audience { get; set; } = string.Empty; + + [JsonPropertyName("publisher")] + public string Publisher { get; set; } = string.Empty; +} diff --git a/samples/csharp/FoundryA365/src/hello_world_a365_agent/Program.cs b/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/Program.cs similarity index 76% rename from samples/csharp/FoundryA365/src/hello_world_a365_agent/Program.cs rename to samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/Program.cs index 4adb48d70..66e3dbafe 100644 --- a/samples/csharp/FoundryA365/src/hello_world_a365_agent/Program.cs +++ b/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/Program.cs @@ -1,8 +1,6 @@ using Azure.Identity; using HelloWorldA365.AgentLogic; -using HelloWorldA365.AgentLogic.AuthCache; -using HelloWorldA365.AgentLogic.SemanticKernel; -using HelloWorldA365.Mcp; +using HelloWorldA365.AgentLogic.ResponsesApi; using HelloWorldA365.Services; using Microsoft.Agents.Builder; using Microsoft.Agents.Hosting.AspNetCore; @@ -10,11 +8,7 @@ using Microsoft.ApplicationInsights.Extensibility; using System.Text; -using Microsoft.Agents.A365.Tooling.Extensions.SemanticKernel.Services; -using Microsoft.Agents.A365.Tooling.Services; using Microsoft.Agents.A365.Observability.Runtime; -using Microsoft.Agents.A365.Observability.Extensions.SemanticKernel; -using Microsoft.Agents.A365.Observability.Runtime.Tracing.Exporters; var builder = WebApplication.CreateBuilder(args); @@ -49,21 +43,11 @@ builder.AddAgent(); // Uncomment this so you can get logs of activities. // builder.Services.AddSingleton([new TranscriptLoggerMiddleware(new FileTranscriptLogger())]); -// Register Agent Logic Service Factory as singleton -builder.Services.AddSingleton(); - -builder.Services - .AddSingleton() - .AddSingleton(); - -// Register Tooling services -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.Services.AddSingleton(); // Register auth helper builder.Services.AddSingleton(); -builder.Services.AddSingleton(); // Register OpenAPI for external agents builder.Services.AddOpenApi(); @@ -79,18 +63,7 @@ if (Environment.GetEnvironmentVariable("EnableKairoTracing") == "true") { - builder.Services.AddSingleton(sp => - { - var cache = sp.GetRequiredService(); - return new Agent365ExporterOptions - { - ClusterCategory = "preprod", - TokenResolver = (agentId, tenantId) => Task.FromResult(cache.GetObservabilityToken(agentId, tenantId)) // fast cached lookup - }; - }); - - builder.AddA365Tracing(config => config - .WithSemanticKernel()); + builder.AddA365Tracing(config => { }); } #endregion diff --git a/samples/csharp/FoundryA365/src/hello_world_a365_agent/Properties/launchSettings.json b/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/Properties/launchSettings.json similarity index 100% rename from samples/csharp/FoundryA365/src/hello_world_a365_agent/Properties/launchSettings.json rename to samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/Properties/launchSettings.json diff --git a/samples/csharp/FoundryA365/src/hello_world_a365_agent/Services/AgentTokenCredential.cs b/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/Services/AgentTokenCredential.cs similarity index 83% rename from samples/csharp/FoundryA365/src/hello_world_a365_agent/Services/AgentTokenCredential.cs rename to samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/Services/AgentTokenCredential.cs index c1a3de9dc..81341be97 100644 --- a/samples/csharp/FoundryA365/src/hello_world_a365_agent/Services/AgentTokenCredential.cs +++ b/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/Services/AgentTokenCredential.cs @@ -8,7 +8,7 @@ namespace HelloWorldA365.Services; /// TokenCredential implementation that calls AgentTokenHelper to acquire tokens. /// Includes token caching and expiry handling with thread-safe token refresh. /// -public class AgentTokenCredential(AgentTokenHelper agentTokenHelper, AgentMetadata agent, string certificateData) : TokenCredential +public class AgentTokenCredential(AgentTokenHelper agentTokenHelper, AgentMetadata agent) : TokenCredential { private AccessToken? cachedToken; private readonly SemaphoreSlim tokenSemaphore = new(1, 1); @@ -20,23 +20,19 @@ public override AccessToken GetToken(TokenRequestContext requestContext, Cancell public override async ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) { - // Check if we have a valid cached token (with 5-minute buffer before expiry) if (cachedToken.HasValue && DateTimeOffset.UtcNow.AddMinutes(5) < cachedToken.Value.ExpiresOn) { return cachedToken.Value; } - // Use semaphore to ensure only one token request at a time await tokenSemaphore.WaitAsync(cancellationToken); try { - // Double-check pattern: another thread might have refreshed the token if (cachedToken.HasValue && DateTimeOffset.UtcNow.AddMinutes(5) < cachedToken.Value.ExpiresOn) { return cachedToken.Value; } - // Use all scopes from the request context, or default if none var scopes = requestContext.Scopes.Length > 0 ? requestContext.Scopes : ["https://canary.graph.microsoft.com/.default"]; @@ -45,11 +41,9 @@ public override async ValueTask GetTokenAsync(TokenRequestContext r agent.AgentApplicationId.ToString(), agent.AgentId.ToString(), agent.EmailId ?? agent.UserId.ToString(), - certificateData, agent.TenantId.ToString(), scopes); - // Parse the JWT token to get expiry time, or default to 1 hour var expiresOn = GetTokenExpiryTime(token); var accessToken = new AccessToken(token, expiresOn); diff --git a/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/Services/AgentTokenHelper.cs b/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/Services/AgentTokenHelper.cs new file mode 100644 index 000000000..cec57f1ba --- /dev/null +++ b/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/Services/AgentTokenHelper.cs @@ -0,0 +1,109 @@ +namespace HelloWorldA365.Services; + +using Azure.Core; +using Azure.Identity; +using Microsoft.Identity.Client; +using System.Text.Json; + +public class AgentTokenHelper(ILogger logger) +{ + /// + /// Performs the three-step agentic user identity token acquisition process using managed identity. + /// + public async Task GetAgenticUserTokenAsync(string agentAppId, string agentAppInstanceId, string userUpn, string tenantId, string[] scopes) + { + try + { + // FIRST: Get blueprint token via managed identity + var blueprintToken = await GetBlueprintToken(agentAppId); + + // SECOND: Get AAD token for AgentAppInstanceId + var instanceApp = ConfidentialClientApplicationBuilder + .Create(agentAppInstanceId) + .WithClientAssertion((AssertionRequestOptions _) => Task.FromResult(blueprintToken.Token)) + .WithAuthority(new Uri($"https://login.microsoftonline.com/{tenantId}")) + .Build(); + + var instanceTokenResult = await instanceApp + .AcquireTokenForClient(["api://AzureAdTokenExchange/.default"]) + .ExecuteAsync(); + + // THIRD: Get combined user token + var userToken = await GetUserFederatedIdentityTokenAsync( + agentAppInstanceId, + tenantId, + blueprintToken.Token, + instanceTokenResult.AccessToken, + userUpn, + scopes); + + return userToken; + } + catch (Exception ex) + { + logger.LogError(ex, "Error acquiring agentic user token"); + throw; + } + } + + private async Task GetBlueprintToken(string clientId) + { + var credential = new DefaultAzureCredential(new DefaultAzureCredentialOptions + { + ManagedIdentityClientId = clientId, + }); + return await credential.GetTokenAsync(new TokenRequestContext(["api://AzureADTokenExchange/.default"])); + } + + private async Task GetUserFederatedIdentityTokenAsync( + string clientId, + string tenantId, + string clientAssertion, + string userFederatedIdentityCredential, + string username, + string[] scopes) + { + using var httpClient = new HttpClient(); + + var tokenEndpoint = $"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token"; + + var parameters = new Dictionary + { + { "client_id", clientId }, + { "scope", string.Join(" ", scopes) }, + { "client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" }, + { "client_assertion", clientAssertion }, + { "user_federated_identity_credential", userFederatedIdentityCredential }, + { "grant_type", "user_fic" } + }; + + if (username.Contains('@')) + { + parameters["username"] = username; + } + else + { + parameters["user_id"] = username; + } + + var content = new FormUrlEncodedContent(parameters); + var response = await httpClient.PostAsync(tokenEndpoint, content); + + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(); + throw new InvalidOperationException($"Failed to acquire user federated identity token: {errorContent}"); + } + + var responseContent = await response.Content.ReadAsStringAsync(); + var tokenResponse = JsonSerializer.Deserialize>(responseContent); + + if (tokenResponse != null && tokenResponse.TryGetValue("access_token", out var accessToken)) + { + var token = accessToken?.ToString(); + return token ?? throw new InvalidOperationException("Access token is null"); + } + + throw new InvalidOperationException("Failed to parse access token from response"); + } +} \ No newline at end of file diff --git a/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/ToolingManifest.json b/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/ToolingManifest.json new file mode 100644 index 000000000..c68475fd4 --- /dev/null +++ b/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/ToolingManifest.json @@ -0,0 +1,40 @@ +{ + "mcpServers": [ + { + "mcpServerName": "mcp_WordServer", + "url": "https://agent365.svc.cloud.microsoft/agents/servers/mcp_WordServer", + "scope": "McpServers.Word.All", + "audience": "ea9ffc3e-8a23-4a7d-836d-234d7c7565c1" + }, + { + "mcpServerName": "mcp_MailTools", + "url": "https://agent365.svc.cloud.microsoft/agents/servers/mcp_MailTools", + "scope": "McpServers.Mail.All", + "audience": "ea9ffc3e-8a23-4a7d-836d-234d7c7565c1" + }, + { + "mcpServerName": "mcp_ODSPRemoteServer", + "url": "https://agent365.svc.cloud.microsoft/agents/servers/mcp_ODSPRemoteServer", + "scope": "McpServers.OneDriveSharepoint.All", + "audience": "ea9ffc3e-8a23-4a7d-836d-234d7c7565c1" + }, + { + "mcpServerName": "mcp_TeamsServer", + "url": "https://agent365.svc.cloud.microsoft/agents/servers/mcp_TeamsServer", + "scope": "McpServers.Teams.All", + "audience": "ea9ffc3e-8a23-4a7d-836d-234d7c7565c1" + }, + { + "mcpServerName": "mcp_ExcelServer", + "url": "https://agent365.svc.cloud.microsoft/agents/servers/mcp_ExcelServer", + "scope": "McpServers.Excel.All", + "audience": "ea9ffc3e-8a23-4a7d-836d-234d7c7565c1" + }, + { + "mcpServerName": "mcp_CalendarTools", + "url": "https://agent365.svc.cloud.microsoft/agents/servers/mcp_CalendarTools", + "scope": "McpServers.Calendar.All", + "audience": "ea9ffc3e-8a23-4a7d-836d-234d7c7565c1" + } + ] +} diff --git a/samples/csharp/FoundryA365/src/hello_world_a365_agent/appsettings.json b/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/appsettings.json similarity index 82% rename from samples/csharp/FoundryA365/src/hello_world_a365_agent/appsettings.json rename to samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/appsettings.json index d25226478..c0df19e00 100644 --- a/samples/csharp/FoundryA365/src/hello_world_a365_agent/appsettings.json +++ b/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/appsettings.json @@ -18,12 +18,8 @@ "DetailedErrors": true, "AllowedHosts": "*", "AzureOpenAIEndpoint": "", - "ModelDeployment": "gpt-4o", - "Mem0ApiToken": "", - "ToolMCPServer": { - "EnableMCPFunctionLogging": false - }, - "GraphReadApplicationId": "dccc78f1-f30f-41ad-bda5-816458bf7e91", + "ModelDeployment": "gpt-5-chat", + "McpDiscoverySource": "Manifest", "AgentConfiguration": { }, "AgentApplication": { @@ -45,13 +41,12 @@ "Connections": { "ServiceConnection": { "Settings": { - "AuthType": "FederatedCredentials", - // BLueprinId + "AuthType": "UserManagedIdentity", + "EnableContainerIMDS": true, + // Blueprint Client ID "ClientId": "", // https://login.microsoftonline.com/{{BOT_TENANT_ID}} "AuthorityEndpoint": "https://login.microsoftonline.com/{{BOT_TENANT_ID}}", - // Project MSI Id - "FederatedClientId": "", "Scopes": [ // APX scope. "5a807f24-c9de-44ee-a3a7-329e88a00ffc/.default" diff --git a/samples/csharp/FoundryA365/src/hello_world_a365_agent/foundry-infra/Dockerfile b/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/foundry-infra/Dockerfile similarity index 85% rename from samples/csharp/FoundryA365/src/hello_world_a365_agent/foundry-infra/Dockerfile rename to samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/foundry-infra/Dockerfile index fecaa536b..f4486d46e 100644 --- a/samples/csharp/FoundryA365/src/hello_world_a365_agent/foundry-infra/Dockerfile +++ b/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/foundry-infra/Dockerfile @@ -3,10 +3,8 @@ WORKDIR /app ARG BLUEPRINT_CLIENT_ID ARG AUTHORITY_ENDPOINT -ARG FEDERATED_CLIENT_ID ARG AZURE_OPENAI_ENDPOINT ARG MODEL_DEPLOYMENT - COPY ./publish . # Expose the default port @@ -18,7 +16,6 @@ ENV MCP_PLATFORM_ENDPOINT=https://agent365.svc.cloud.microsoft ENV Connections__ServiceConnection__Settings__ClientId=${BLUEPRINT_CLIENT_ID} ENV Connections__ServiceConnection__Settings__AuthorityEndpoint=${AUTHORITY_ENDPOINT} -ENV Connections__ServiceConnection__Settings__FederatedClientId=${FEDERATED_CLIENT_ID} ENV AzureOpenAIEndpoint=${AZURE_OPENAI_ENDPOINT} ENV ModelDeployment=${MODEL_DEPLOYMENT} diff --git a/samples/csharp/FoundryA365/src/hello_world_a365_agent/global.json b/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/global.json similarity index 100% rename from samples/csharp/FoundryA365/src/hello_world_a365_agent/global.json rename to samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/global.json diff --git a/samples/csharp/FoundryA365/src/hello_world_a365_agent/wwwroot/favicon.png b/samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/wwwroot/favicon.png similarity index 100% rename from samples/csharp/FoundryA365/src/hello_world_a365_agent/wwwroot/favicon.png rename to samples/csharp/foundry-ai-teammate/src/hello_world_a365_agent/wwwroot/favicon.png diff --git a/samples/csharp/hosted-agents/README.md b/samples/csharp/hosted-agents/README.md index bdbea3c3e..201589b5e 100644 --- a/samples/csharp/hosted-agents/README.md +++ b/samples/csharp/hosted-agents/README.md @@ -96,6 +96,7 @@ The platform manages conversation history, streaming lifecycle, and background e | Use client-side or server-side MCP patterns | [MCP Tools](agent-framework/mcp-tools/) | Tools, Knowledge Grounding | | Answer questions from my own documents | [Knowledge Grounding](agent-framework/text-search-rag/) | Tools | | Build a multi-agent workflow with routing | [Workflows](agent-framework/workflows/) | Agent-to-Agent | +| Ground answers in files bundled with the agent or uploaded per session | [File Tools](agent-framework/file-tools/) | Tools | #### Learning path @@ -110,6 +111,7 @@ The platform manages conversation history, streaming lifecycle, and background e 4. **[Knowledge Grounding](agent-framework/text-search-rag/)** β€” Ground answers in your own documents with TextSearchProvider. 5. **[MCP Tools](agent-framework/mcp-tools/)** β€” Connect to MCP servers using client-side and server-side MCP patterns. 6. **[Workflows](agent-framework/workflows/)** β€” Compose multiple agents into sequential pipelines. +7. **[File Tools](agent-framework/file-tools/)** β€” Ground answers in files bundled with the agent (image-baked) or uploaded per session (`$HOME`), with security-scoped tool pairs per source. ### Invocations protocol @@ -167,6 +169,7 @@ Already built an agent with your own .NET code? The protocol SDKs (`Azure.AI.Age | Local function tools | [Tools](agent-framework/local-tools/) | β€” | | RAG / knowledge grounding | [Knowledge Grounding](agent-framework/text-search-rag/) | β€” | | Multi-agent workflow | [Workflows](agent-framework/workflows/) | β€” | +| Bundled + session file knowledge | [File Tools](agent-framework/file-tools/) | β€” | | BYO agent (any framework) | [BYO Hello World](bring-your-own/responses/HelloWorld/) | [BYO Hello World](bring-your-own/invocations/HelloWorld/) | | Observability (App Insights, OpenTelemetry, traces) | Every sample β€” enabled by default | Every sample β€” enabled by default | @@ -206,6 +209,7 @@ samples/dotnet/hosted-agents/ β”‚ β”œβ”€β”€ mcp-tools/ β”‚ β”œβ”€β”€ text-search-rag/ β”‚ β”œβ”€β”€ workflows/ +β”‚ β”œβ”€β”€ file-tools/ β”‚ └── invocations-echo-agent/ └── bring-your-own/ β”œβ”€β”€ responses/ diff --git a/samples/csharp/hosted-agents/agent-framework/azure-search-rag/.dockerignore b/samples/csharp/hosted-agents/agent-framework/azure-search-rag/.dockerignore new file mode 100644 index 000000000..94b29c826 --- /dev/null +++ b/samples/csharp/hosted-agents/agent-framework/azure-search-rag/.dockerignore @@ -0,0 +1,12 @@ +**/bin/ +**/obj/ +**/.vs/ +**/.vscode/ +**/.azure/ +**/.idea/ +**/*.user +**/.env +**/.env.local +.dockerignore +.gitignore +README.md diff --git a/samples/csharp/hosted-agents/agent-framework/azure-search-rag/.env.example b/samples/csharp/hosted-agents/agent-framework/azure-search-rag/.env.example new file mode 100644 index 000000000..fb4ca67e5 --- /dev/null +++ b/samples/csharp/hosted-agents/agent-framework/azure-search-rag/.env.example @@ -0,0 +1,18 @@ +# Foundry project endpoint. Auto-injected in hosted containers. +# Only set manually if running without `azd ai agent run`. +# FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ + +# Chat model deployment name. Must match a deployment in your Foundry project. +AZURE_AI_MODEL_DEPLOYMENT_NAME= + +# Azure AI Search service endpoint. Auto-derived from AZURE_AI_SEARCH_SERVICE_NAME +# by `azd ai agent run`. Set manually only when running without azd. +# AZURE_SEARCH_ENDPOINT=https://.search.windows.net + +# Search index name. Defaults to contoso-outdoors. The sample creates the index +# on startup if it does not exist and seeds three Contoso Outdoors documents. +AZURE_SEARCH_INDEX_NAME=contoso-outdoors + +# Application Insights. Auto-injected in hosted containers. +# Set for local telemetry (optional but recommended). +# APPLICATIONINSIGHTS_CONNECTION_STRING=InstrumentationKey=... diff --git a/samples/csharp/toolbox/maf/ToolboxMafAgent/Dockerfile b/samples/csharp/hosted-agents/agent-framework/azure-search-rag/Dockerfile similarity index 76% rename from samples/csharp/toolbox/maf/ToolboxMafAgent/Dockerfile rename to samples/csharp/hosted-agents/agent-framework/azure-search-rag/Dockerfile index cbb46c8e0..f3396df98 100644 --- a/samples/csharp/toolbox/maf/ToolboxMafAgent/Dockerfile +++ b/samples/csharp/hosted-agents/agent-framework/azure-search-rag/Dockerfile @@ -5,13 +5,11 @@ ENV ASPNETCORE_URLS=http://+:8088 FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src -COPY NuGet.Config ./ -COPY *.csproj ./ +COPY . . RUN dotnet restore -COPY . ./ RUN dotnet publish -c Release -o /app/publish FROM base AS final WORKDIR /app COPY --from=build /app/publish . -ENTRYPOINT ["dotnet", "ToolboxMafAgent.dll"] +ENTRYPOINT ["dotnet", "azure-search-rag.dll"] diff --git a/samples/csharp/hosted-agents/agent-framework/azure-search-rag/Program.cs b/samples/csharp/hosted-agents/agent-framework/azure-search-rag/Program.cs new file mode 100644 index 000000000..5acfacd1e --- /dev/null +++ b/samples/csharp/hosted-agents/agent-framework/azure-search-rag/Program.cs @@ -0,0 +1,81 @@ +ο»Ώ// Copyright (c) Microsoft. All rights reserved. + +using Azure; +using Azure.AI.AgentServer.Core; +using Azure.AI.Projects; +using Azure.Identity; +using Azure.Search.Documents; +using Azure.Search.Documents.Models; +using DotNetEnv; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry.Hosting; +using Microsoft.Extensions.AI; + +Env.TraversePath().Load(); + +var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT environment variable is not set.")); +var deployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4.1-mini"; +var searchEndpoint = new Uri(Environment.GetEnvironmentVariable("AZURE_SEARCH_ENDPOINT") + ?? throw new InvalidOperationException("AZURE_SEARCH_ENDPOINT environment variable is not set.")); +var indexName = Environment.GetEnvironmentVariable("AZURE_SEARCH_INDEX_NAME") ?? "contoso-outdoors"; + +var credential = new DefaultAzureCredential(); + +// The index is expected to exist and be populated before the agent runs. See README.md for the +// schema and seed content. Provisioning the index is a one-time setup step, not part of the +// agent runtime. +var searchClient = new SearchClient(searchEndpoint, indexName, credential); + +var textSearchOptions = new TextSearchProviderOptions +{ + SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke, + RecentMessageMemoryLimit = 6, +}; + +AIAgent agent = new AIProjectClient(projectEndpoint, credential) + .AsAIAgent(new ChatClientAgentOptions + { + Name = "azure-search-rag", + ChatOptions = new ChatOptions + { + ModelId = deployment, + Instructions = "You are a helpful customer support assistant for Contoso Outdoors. " + + "Answer questions using the provided context and cite the source document when available. " + + "If you cannot find relevant information in the provided context, let the customer know.", + }, + AIContextProviders = [new TextSearchProvider(CreateSearchAdapter(searchClient), textSearchOptions)] + }); + +var builder = AgentHost.CreateBuilder(args); +builder.Services.AddFoundryResponses(agent); +builder.RegisterProtocol("responses", endpoints => endpoints.MapFoundryResponses()); + +var app = builder.Build(); +app.Run(); + +// Wraps a SearchClient as the delegate TextSearchProvider expects. Keyword/full-text search; +// no embeddings. Returns the top results and projects them into TextSearchResult entries +// the provider will inject into the model context. +static Func>> + CreateSearchAdapter(SearchClient client, int top = 3) => + async (query, cancellationToken) => + { + var options = new SearchOptions { Size = top }; + Response> response = + await client.SearchAsync(query, options, cancellationToken).ConfigureAwait(false); + + var results = new List(); + await foreach (SearchResult hit in response.Value.GetResultsAsync().WithCancellation(cancellationToken).ConfigureAwait(false)) + { + results.Add(new TextSearchProvider.TextSearchResult + { + SourceName = hit.Document.TryGetValue("sourceName", out var name) ? name?.ToString() ?? string.Empty : string.Empty, + SourceLink = hit.Document.TryGetValue("sourceLink", out var link) ? link?.ToString() ?? string.Empty : string.Empty, + Text = hit.Document.TryGetValue("content", out var content) ? content?.ToString() ?? string.Empty : string.Empty, + RawRepresentation = hit + }); + } + + return results; + }; \ No newline at end of file diff --git a/samples/csharp/hosted-agents/agent-framework/azure-search-rag/README.md b/samples/csharp/hosted-agents/agent-framework/azure-search-rag/README.md new file mode 100644 index 000000000..e42a8d5a3 --- /dev/null +++ b/samples/csharp/hosted-agents/agent-framework/azure-search-rag/README.md @@ -0,0 +1,329 @@ +# What this sample demonstrates + +A support specialist agent with Retrieval Augmented Generation (RAG) backed by **Azure AI Search**, the **Azure AI Search RAG Agent (Responses Protocol)** sample shows how to ground agent answers in a real keyword-indexed knowledge base using `TextSearchProvider` over `Azure.Search.Documents` via [Agent Framework](https://github.com/microsoft/agent-framework). + +## How It Works + +The agent uses a `TextSearchProvider` wired to an `Azure.Search.Documents.SearchClient`. Before each model invocation the framework runs a full-text search against the configured Azure AI Search index, retrieves the top three matching documents, injects them as context for the LLM, and the model composes an answer grounded in the retrieved information, reducing hallucination and ensuring responses reflect the actual product documentation. + +> [!IMPORTANT] +> The agent assumes the search index already exists and is populated. It does **not** create or seed the index at runtime. See [Provisioning the search index](#provisioning-the-search-index) below for a one-time setup script that creates `contoso-outdoors` and seeds it with three Contoso Outdoors documents (return policy, shipping guide, tent care). + +> [!NOTE] +> Provisioning of the Foundry project, model deployment, and the Azure AI Search service is handled by the [`azd-ai-starter-basic`](https://github.com/Azure-Samples/azd-ai-starter-basic) template, which `azd ai agent init` pulls in automatically. The chat model under `resources:` in `agent.manifest.yaml` flows into the starter's `AI_PROJECT_DEPLOYMENTS` parameter, and the `kind: tool` `id: azure_ai_search` entry flows into `AI_PROJECT_DEPENDENT_RESOURCES` to provision the Azure AI Search service plus a project-scoped connection. + +> [!IMPORTANT] +> The starter's Azure AI Search bicep currently requires a co-provisioned storage account, but `azd ai agent init` does not auto-prompt for storage. After running `azd ai agent init`, manually edit the generated `azure.yaml` and add a `storage` entry to the agent's `resources:` array so both resources get provisioned together. See [Provisioning workaround](#provisioning-workaround-storage-dependency) below. + +See [Program.cs](Program.cs) for the full implementation. + +## Running the Agent Locally + +### Prerequisites + +Before running this sample, ensure you have: + +1. **Azure Developer CLI (`azd`)** (recommended) + - [Install azd](https://learn.microsoft.com/en-us/azure/developer/azure-developer-cli/install-azd) and the AI agent extension: `azd ext install azure.ai.agents` + - Authenticated: `azd auth login` + +2. **Azure CLI** + - Installed and authenticated: `az login` + +3. **.NET 10.0 SDK or later** + - Verify your version: `dotnet --version` + - Download from [https://dotnet.microsoft.com/download](https://dotnet.microsoft.com/download) + +> [!NOTE] +> You do **not** need an existing [Microsoft Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/what-is-foundry?view=foundry) project, model deployment, or Azure AI Search service to get started, `azd provision` creates them all for you. If you already have some of these, see the [note below](#using-azd-recommended-for-cli-workflows) on how to target them. + +### Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `FOUNDRY_PROJECT_ENDPOINT` | Yes | Foundry project endpoint. Auto-injected in hosted containers; set automatically by `azd ai agent run` locally. | +| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | Yes | Chat model deployment name. Declared in `agent.manifest.yaml`. | +| `AZURE_SEARCH_ENDPOINT` | Yes | Azure AI Search service endpoint. Derived from `AZURE_AI_SEARCH_SERVICE_NAME` (auto-injected by the starter) via the binding in `agent.yaml`. Set manually only when running without `azd`. | +| `AZURE_SEARCH_INDEX_NAME` | Yes | Search index name. Defaults to `contoso-outdoors`. **Must exist before the agent starts** (see [Provisioning the search index](#provisioning-the-search-index)). | +| `APPLICATIONINSIGHTS_CONNECTION_STRING` | Recommended | Enables telemetry. Auto-injected in hosted containers; set manually for local dev. | + +**Local development (without `azd`):** + +```bash +# Set env vars directly. .NET does not natively read .env files. +export FOUNDRY_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" +export AZURE_AI_MODEL_DEPLOYMENT_NAME="" +export AZURE_SEARCH_ENDPOINT="https://.search.windows.net" +export AZURE_SEARCH_INDEX_NAME="contoso-outdoors" +``` + +> [!NOTE] +> When using `azd ai agent run`, environment variables are handled automatically. No manual setup needed. + +### Installing Dependencies + +> [!NOTE] +> If using `azd ai agent run`, dependencies are restored automatically. Skip to [Running the Sample](#running-the-sample). + +Dependencies are restored automatically when building the project: + +```bash +dotnet restore +``` + +### Running the Sample + +The recommended way to run and test hosted agents locally is with the Azure Developer CLI (`azd`) or the Foundry VS Code extension. + +#### Using the Foundry VS Code Extension + +The [Foundry VS Code extension](https://learn.microsoft.com/en-us/azure/foundry/agents/quickstarts/quickstart-hosted-agent?view=foundry&pivots=vscode) has a built-in sample gallery. You can open this sample directly from the extension without cloning the repository, it scaffolds the project into a new workspace, generates `agent.yaml`, `.env`, and `.vscode/tasks.json` + `launch.json` automatically, and configures a one-click **F5** debug experience. + +Follow the [VS Code quickstart](https://learn.microsoft.com/en-us/azure/foundry/agents/quickstarts/quickstart-hosted-agent?view=foundry&pivots=vscode) for a full step-by-step walkthrough. + +#### Using [`azd`](https://learn.microsoft.com/en-us/azure/foundry/agents/quickstarts/quickstart-hosted-agent?view=foundry&pivots=azd) (recommended for CLI workflows) + +No cloning required. Create a new folder, point `azd` at the manifest on GitHub, and it sets up the sample, generates Bicep infrastructure, `agent.yaml`, and env config: + +```bash +# Create a new folder for the agent and navigate into it +mkdir azure-search-rag-agent && cd azure-search-rag-agent + +# Initialize from the manifest. azd reads it, downloads the sample, +# and generates Bicep infrastructure, agent.yaml, and env config +azd ai agent init -m https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/csharp/hosted-agents/agent-framework/azure-search-rag/agent.manifest.yaml + +# IMPORTANT: apply the storage workaround documented below before provisioning. + +# Provision Azure resources (Foundry project, model deployment, Azure AI Search, storage, App Insights) +azd provision + +# Run the agent locally (handles env vars, build, and startup) +azd ai agent run +``` + +##### Provisioning workaround: storage dependency + +The starter's Azure AI Search bicep module currently requires a co-provisioned storage account, but `azd ai agent init` only auto-prompts for `azure_ai_search` and `bing_grounding` tool resources. After running `init`, open the generated `azure.yaml` and add a `storage` entry to the agent service's `resources:` array so both resources get provisioned together: + +```yaml +services: + azure-search-rag: + config: + resources: + - resource: azure_ai_search + connectionName: search + - resource: storage + connectionName: storage +``` + +Tracking issue: [Azure-Samples/azd-ai-starter-basic β€” make storage optional in azure_ai_search.bicep or auto-prompt](https://github.com/Azure-Samples/azd-ai-starter-basic/issues). + +> [!NOTE] +> If you've already cloned this repository, pass a local path to the manifest instead: +> `azd ai agent init -m /samples/csharp/hosted-agents/agent-framework/azure-search-rag/agent.manifest.yaml` + +> [!NOTE] +> If you already have a Foundry project, model deployment, and Azure AI Search service, add `-p -d ` to `azd ai agent init` to target existing resources. You can also skip provisioning entirely and configure env vars manually, see [Without `azd`](#without-azd). + +The agent starts on `http://localhost:8088/`. To invoke it: + +```bash +azd ai agent invoke --local "What is your return policy?" +``` + +Or use curl directly: + +```bash +curl -sS -X POST http://localhost:8088/responses \ + -H "Content-Type: application/json" \ + -d '{"input": "What is your return policy?", "stream": false}' | jq . + +curl -sS -X POST http://localhost:8088/responses \ + -H "Content-Type: application/json" \ + -d '{"input": "How long does shipping take?", "stream": false}' | jq . + +curl -sS -X POST http://localhost:8088/responses \ + -H "Content-Type: application/json" \ + -d '{"input": "How do I clean my tent?", "stream": false}' | jq . +``` + +#### Without `azd` + +If running without `azd`, set environment variables manually (see [Environment Variables](#environment-variables)), then: + +```bash +dotnet run +``` + +### Deploying the Agent to Microsoft Foundry + +Once you've tested locally, deploy to Microsoft Foundry: + +```bash +# Provision Azure resources (skip if already done during local setup) +azd provision + +# Build, push, and deploy the agent to Foundry +azd deploy +``` + +After deploying, invoke the agent running in Foundry: + +```bash +azd ai agent invoke "What is your return policy?" +``` + +To stream logs from the running agent: + +```bash +azd ai agent monitor +``` + +For the full deployment guide, see [Azure AI Foundry hosted agents](https://aka.ms/azdaiagent/docs). + +## Provisioning the search index + +The agent reads from a pre-existing index. Provision it once, before the first run, with the script below. The script is idempotent: it skips creation if the index already exists, and skips seeding if the index already has documents. + +### Required schema + +| Field | Type | Attributes | +|-------|------|------------| +| `id` | `Edm.String` | key, filterable | +| `content` | `Edm.String` | searchable | +| `sourceName` | `Edm.String` | filterable | +| `sourceLink` | `Edm.String` | (none) | + +### Required search service authentication mode + +The script and the agent runtime both authenticate to Azure AI Search via Entra ID (AAD) bearer tokens, **not** API keys. The search service must therefore have RBAC enabled. Services created before May 2024, or services explicitly provisioned with `disableLocalAuth=false` and `authOptions=null`, default to **API-key-only** auth and will return `403 Forbidden` to every AAD token regardless of RBAC role assignments. + +Verify the current auth mode: + +```bash +az search service show -g -n \ + --query "{authOptions:authOptions, disableLocalAuth:disableLocalAuth}" -o json +``` + +The expected output is one of: + +```json +{ "authOptions": { "aadOrApiKey": { "aadAuthFailureMode": "http403" } }, "disableLocalAuth": false } +``` + +or, for AAD-only: + +```json +{ "authOptions": null, "disableLocalAuth": true } +``` + +If you see `"authOptions": null` together with `"disableLocalAuth": false`, RBAC is **off** and you must enable it before the script (or the agent) can authenticate. Flip the service to accept both AAD and API keys (safest, no breaking change for existing key consumers): + +```bash +az search service update -g -n \ + --auth-options aadOrApiKey --aad-auth-failure-mode http403 +``` + +Or go AAD-only (rejects all API keys): + +```bash +az search service update -g -n --disable-local-auth true +``` + +Either change takes effect immediately on the control plane; allow ~1 minute for the data plane to pick it up. + +### Required RBAC for the user running the script + +Grant your user `Search Index Data Contributor` on the search service scope. This single role covers both index management (create) and document write (upload) for the bootstrap. + +> [!IMPORTANT] +> Subscription `Owner`, `Contributor`, or `User Access Administrator` are **not sufficient on their own**. Those roles cover the management plane (deploy/scale/grant) but contain no `dataActions`, so REST calls to `/indexes/...` return `403`. The data-plane Search role must be granted explicitly even for subscription Owners. + +```bash +SEARCH_ID=$(az search service show -g -n --query id -o tsv) +USER_OID=$(az ad signed-in-user show --query id -o tsv) +az role assignment create --assignee-object-id $USER_OID --assignee-principal-type User \ + --role "Search Index Data Contributor" --scope $SEARCH_ID +``` + +### Bash one-shot using `curl` + `az` token + +```bash +SEARCH_ENDPOINT="https://.search.windows.net" +INDEX_NAME="contoso-outdoors" +TOKEN=$(az account get-access-token --resource https://search.azure.com --query accessToken -o tsv) + +# Create the index (idempotent: 201 on create, 204 on update) +curl -sS -X PUT "${SEARCH_ENDPOINT}/indexes/${INDEX_NAME}?api-version=2024-07-01" \ + -H "Authorization: Bearer ${TOKEN}" -H "Content-Type: application/json" -d '{ + "name": "'"${INDEX_NAME}"'", + "fields": [ + { "name": "id", "type": "Edm.String", "key": true, "filterable": true }, + { "name": "content", "type": "Edm.String", "searchable": true }, + { "name": "sourceName", "type": "Edm.String", "filterable": true }, + { "name": "sourceLink", "type": "Edm.String" } + ] + }' + +# Seed three Contoso Outdoors documents +curl -sS -X POST "${SEARCH_ENDPOINT}/indexes/${INDEX_NAME}/docs/index?api-version=2024-07-01" \ + -H "Authorization: Bearer ${TOKEN}" -H "Content-Type: application/json" -d '{ + "value": [ + { + "@search.action": "mergeOrUpload", + "id": "return-policy", + "sourceName": "Contoso Outdoors Return Policy", + "sourceLink": "https://contoso.com/policies/returns", + "content": "Customers may return any item within 30 days of delivery. Items should be unused and include original packaging. Refunds are issued to the original payment method within 5 business days of inspection." + }, + { + "@search.action": "mergeOrUpload", + "id": "shipping-guide", + "sourceName": "Contoso Outdoors Shipping Guide", + "sourceLink": "https://contoso.com/help/shipping", + "content": "Standard shipping is free on orders over $50 and typically arrives in 3-5 business days within the continental United States. Expedited options are available at checkout." + }, + { + "@search.action": "mergeOrUpload", + "id": "tent-care", + "sourceName": "TrailRunner Tent Care Instructions", + "sourceLink": "https://contoso.com/manuals/trailrunner-tent", + "content": "Clean the tent fabric with lukewarm water and a non-detergent soap. Allow it to air dry completely before storage and avoid prolonged UV exposure to extend the lifespan of the waterproof coating." + } + ] + }' +``` + +### Required RBAC for the agent runtime + +The hosted agent runs under its own managed identity. Grant that identity `Search Index Data Reader` on the search service scope so it can query the index at runtime: + +```bash +# Look up the agent MI principal id from the deployed agent version. +TOK=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv) +MI=$(curl -sS -H "Authorization: Bearer $TOK" \ + "https://.services.ai.azure.com/api/projects//agents/azure-search-rag?api-version=v1" \ + | jq -r '.versions.latest.instance_identity.principal_id') + +az role assignment create --assignee-object-id $MI --assignee-principal-type ServicePrincipal \ + --role "Search Index Data Reader" --scope $SEARCH_ID +``` + +Wait ~3 minutes for AAD propagation before invoking the agent. + +## Troubleshooting + +### Images built on Apple Silicon or other ARM64 machines do not work on our service + +We **recommend deploying with `azd deploy`**, which uses ACR remote build and always produces images with the correct architecture. + +If you choose to **build locally**, and your machine is **not `linux/amd64`** (for example, an Apple Silicon Mac), the image will **not be compatible with our service**, causing runtime failures. + +**Fix for local builds:** + +```bash +docker build --platform=linux/amd64 -t image . +``` + +This forces the image to be built for the required `amd64` architecture. diff --git a/samples/csharp/hosted-agents/agent-framework/azure-search-rag/agent.manifest.yaml b/samples/csharp/hosted-agents/agent-framework/azure-search-rag/agent.manifest.yaml new file mode 100644 index 000000000..e70691b10 --- /dev/null +++ b/samples/csharp/hosted-agents/agent-framework/azure-search-rag/agent.manifest.yaml @@ -0,0 +1,43 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: azure-search-rag +displayName: "Azure AI Search RAG Agent" + +description: > + A support specialist agent with Retrieval Augmented Generation (RAG) backed by Azure AI Search. + Uses TextSearchProvider over a real SearchClient to ground answers in Contoso Outdoors product + documentation indexed in Azure AI Search. + +metadata: + tags: + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Agent Framework + - RAG + - Azure AI Search + +template: + name: azure-search-rag + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 + resources: + cpu: "0.25" + memory: 0.5Gi + environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" + - name: AZURE_SEARCH_ENDPOINT + value: "https://${AZURE_AI_SEARCH_SERVICE_NAME}.search.windows.net" + - name: AZURE_SEARCH_INDEX_NAME + value: "contoso-outdoors" +parameters: + properties: [] +resources: + - kind: model + id: gpt-4.1-mini + name: AZURE_AI_MODEL_DEPLOYMENT_NAME + - kind: tool + id: azure_ai_search + name: search diff --git a/samples/csharp/hosted-agents/agent-framework/azure-search-rag/agent.yaml b/samples/csharp/hosted-agents/agent-framework/azure-search-rag/agent.yaml new file mode 100644 index 000000000..26e311456 --- /dev/null +++ b/samples/csharp/hosted-agents/agent-framework/azure-search-rag/agent.yaml @@ -0,0 +1,16 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +kind: hosted +name: azure-search-rag +protocols: + - protocol: responses + version: 1.0.0 +resources: + cpu: "0.25" + memory: 0.5Gi +environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} + - name: AZURE_SEARCH_ENDPOINT + value: https://${AZURE_AI_SEARCH_SERVICE_NAME}.search.windows.net + - name: AZURE_SEARCH_INDEX_NAME + value: contoso-outdoors diff --git a/samples/csharp/hosted-agents/agent-framework/azure-search-rag/azure-search-rag.csproj b/samples/csharp/hosted-agents/agent-framework/azure-search-rag/azure-search-rag.csproj new file mode 100644 index 000000000..4ee5b7b51 --- /dev/null +++ b/samples/csharp/hosted-agents/agent-framework/azure-search-rag/azure-search-rag.csproj @@ -0,0 +1,19 @@ +ο»Ώ + + + net10.0 + AzureSearchRag + azure-search-rag + enable + enable + $(NoWarn);NU1903;NU1605 + + + + + + + + + + diff --git a/samples/csharp/hosted-agents/agent-framework/file-tools/.dockerignore b/samples/csharp/hosted-agents/agent-framework/file-tools/.dockerignore new file mode 100644 index 000000000..1a0b82c53 --- /dev/null +++ b/samples/csharp/hosted-agents/agent-framework/file-tools/.dockerignore @@ -0,0 +1,8 @@ +bin/ +obj/ +.vs/ +.vscode/ +.env +*.md +Dockerfile +.dockerignore diff --git a/samples/csharp/hosted-agents/agent-framework/file-tools/.env.example b/samples/csharp/hosted-agents/agent-framework/file-tools/.env.example new file mode 100644 index 000000000..04dccefe5 --- /dev/null +++ b/samples/csharp/hosted-agents/agent-framework/file-tools/.env.example @@ -0,0 +1,4 @@ +FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4.1-mini +ASPNETCORE_URLS=http://+:8088 +ASPNETCORE_ENVIRONMENT=Development diff --git a/samples/csharp/hosted-agents/agent-framework/file-tools/Dockerfile b/samples/csharp/hosted-agents/agent-framework/file-tools/Dockerfile new file mode 100644 index 000000000..63e4f8a41 --- /dev/null +++ b/samples/csharp/hosted-agents/agent-framework/file-tools/Dockerfile @@ -0,0 +1,15 @@ +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +WORKDIR /app +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY . . +RUN dotnet restore +RUN dotnet publish -c Release -o /app/publish + +FROM base AS final +WORKDIR /app +COPY --from=build /app/publish . +ENTRYPOINT ["dotnet", "file-tools.dll"] diff --git a/samples/csharp/hosted-agents/agent-framework/file-tools/Program.cs b/samples/csharp/hosted-agents/agent-framework/file-tools/Program.cs new file mode 100644 index 000000000..26d6d6443 --- /dev/null +++ b/samples/csharp/hosted-agents/agent-framework/file-tools/Program.cs @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ComponentModel; +using Azure.AI.AgentServer.Core; +using Azure.AI.Projects; +using Azure.Identity; +using DotNetEnv; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry.Hosting; +using Microsoft.Extensions.AI; + +Env.TraversePath().Load(); + +var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT environment variable is not set.")); + +var deployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4.1-mini"; + +// Bundled root: files copied into the published output via csproj . +// In the container this resolves to /app/resources/. +string bundledRoot = Path.GetFullPath( + Environment.GetEnvironmentVariable("BUNDLED_FILES_DIR") + ?? Path.Combine(AppContext.BaseDirectory, "resources")); + +// Session root: per-session $HOME volume managed by the Foundry platform. +// Files uploaded via `azd ai agent files upload ` land at $HOME/. +string sessionRoot = Path.GetFullPath( + Environment.GetEnvironmentVariable("HOME") + ?? "/home/session"); + +AIAgent agent = new AIProjectClient(projectEndpoint, new DefaultAzureCredential()) + .AsAIAgent( + model: deployment, + instructions: """ + You are a friendly assistant that answers questions over two file sources: + + - Bundled files: built-in knowledge that ships with the agent image + (e.g., reference reports the author packaged with you). Tools: + ListBundledFiles, ReadBundledFile. + + - Session files: user-uploaded data for this session only (e.g., notes + or a CSV the user wants you to analyse). Tools: ListSessionFiles, + ReadSessionFile. + + Pick the tool pair by intent. If a name could match either source, list + both first. Always read the file before answering; do not guess. Quote + numbers and figures verbatim from the file. + """, + name: "file-tools", + description: "Hosted agent that answers questions over bundled (image-baked) and session-uploaded files via two scoped tool pairs.", + tools: + [ + AIFunctionFactory.Create(ListBundledFiles), + AIFunctionFactory.Create(ReadBundledFile), + AIFunctionFactory.Create(ListSessionFiles), + AIFunctionFactory.Create(ReadSessionFile), + ]); + +var builder = AgentHost.CreateBuilder(args); +builder.Services.AddFoundryResponses(agent); +builder.RegisterProtocol("responses", endpoints => endpoints.MapFoundryResponses()); + +var app = builder.Build(); +app.Run(); + +[Description("List the names of files bundled with the agent (built-in knowledge that ships with the image).")] +string ListBundledFiles() => SafeListNames(bundledRoot); + +[Description("Read the full text contents of a bundled file by name.")] +string ReadBundledFile( + [Description("Name of the bundled file (no directory components). Must be one of the names returned by ListBundledFiles.")] string fileName) + => SafeRead(bundledRoot, fileName, "bundled files"); + +[Description("List the names of files the user uploaded into the current session (e.g., via 'azd ai agent files upload').")] +string ListSessionFiles() => SafeListNames(sessionRoot); + +[Description("Read the full text contents of a file uploaded into the current session by name.")] +string ReadSessionFile( + [Description("Name of the session file (no directory components). Must be one of the names returned by ListSessionFiles.")] string fileName) + => SafeRead(sessionRoot, fileName, "session files"); + +// Path-safe helpers: GetFileName strip + canonicalize + StartsWith(root) check enforces the boundary +// per tool. The model cannot escape its own root, even via crafted input or indirect prompt injection. + +static string SafeListNames(string root) +{ + try + { + if (!Directory.Exists(root)) + { + return string.Empty; + } + + return string.Join( + Environment.NewLine, + Directory.EnumerateFiles(root).Select(Path.GetFileName)); + } + catch (Exception ex) + { + return $"Error listing files: {ex.Message}"; + } +} + +static string SafeRead(string root, string fileName, string scope) +{ + try + { + string safeName = Path.GetFileName(fileName); + if (string.IsNullOrEmpty(safeName)) + { + return $"File '{fileName}' not found in {scope}."; + } + + string fullPath = Path.GetFullPath(Path.Combine(root, safeName)); + + string rootPrefix = root.EndsWith(Path.DirectorySeparatorChar) + ? root + : root + Path.DirectorySeparatorChar; + if (!fullPath.StartsWith(rootPrefix, StringComparison.Ordinal)) + { + return $"File '{fileName}' not found in {scope}."; + } + + return File.Exists(fullPath) + ? File.ReadAllText(fullPath) + : $"File '{fileName}' not found in {scope}."; + } + catch (Exception ex) + { + return $"Error reading '{fileName}': {ex.Message}"; + } +} diff --git a/samples/csharp/hosted-agents/agent-framework/file-tools/README.md b/samples/csharp/hosted-agents/agent-framework/file-tools/README.md new file mode 100644 index 000000000..7dc7e58b4 --- /dev/null +++ b/samples/csharp/hosted-agents/agent-framework/file-tools/README.md @@ -0,0 +1,202 @@ +# What this sample demonstrates + +A hosted agent that answers questions over **two distinct file knowledge sources** through scoped, security-hardened tools β€” the **Agent with File Tools (Responses Protocol)** sample built on the [Agent Framework](https://github.com/microsoft/agent-framework). + +- **Bundled files** (image-baked, `/app/resources/`) β€” files the author packages with the agent at build time. Always available, identical to every session. +- **Session files** (per-session `$HOME` volume, `/home/session/`) β€” files the user uploads at runtime via `azd ai agent files upload`. Live for the lifetime of the session. + +## How It Works + +The agent registers four C# functions as tools, one tool pair per source: + +| Tool | Source | Root inside container | +|------|--------|------| +| `ListBundledFiles` | Bundled (image-baked) | `/app/resources/` | +| `ReadBundledFile` | Bundled (image-baked) | `/app/resources/` | +| `ListSessionFiles` | Session-uploaded | `$HOME` (`/home/session/`) | +| `ReadSessionFile` | Session-uploaded | `$HOME` (`/home/session/`) | + +Each `Read*` tool takes a `fileName` (no path components allowed) and enforces three layers of defence inside the implementation: + +1. **`Path.GetFileName(input)`** strips any directory parts from the model-supplied name. `"../../etc/passwd"` becomes `"passwd"`. +2. **`Path.GetFullPath(Combine(root, name))`** canonicalises the path. +3. **`fullPath.StartsWith(root + DirectorySeparatorChar)`** rejects anything that resolves outside the tool's root. + +Failures return a controlled `"File '' not found in ."` rather than throwing or exposing the canonical path. The model cannot read or list arbitrary container paths, even via indirect prompt injection in an uploaded file. + +See [Program.cs](Program.cs) for the full implementation. + +## Running the Agent Locally + +### Prerequisites + +Before running this sample, ensure you have: + +1. **Azure Developer CLI (`azd`)** (recommended) + - [Install azd](https://learn.microsoft.com/en-us/azure/developer/azure-developer-cli/install-azd) and the AI agent extension: `azd ext install azure.ai.agents` + - Authenticated: `azd auth login` + +2. **Azure CLI** + - Installed and authenticated: `az login` + +3. **.NET 10.0 SDK or later** + - Verify your version: `dotnet --version` + - Download from [https://dotnet.microsoft.com/download](https://dotnet.microsoft.com/download) + +> [!NOTE] +> You do **not** need an existing [Microsoft Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/what-is-foundry?view=foundry) project or model deployment to get started β€” `azd provision` creates them for you. If you already have a project, see the [note below](#using-azd-recommended-for-cli-workflows) on how to target it. + +### Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `FOUNDRY_PROJECT_ENDPOINT` | Yes | Foundry project endpoint. Auto-injected in hosted containers; set automatically by `azd ai agent run` locally. | +| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | Yes | Model deployment name β€” must match your Foundry project deployment. Declared in `agent.manifest.yaml`. | +| `BUNDLED_FILES_DIR` | No | Override the bundled-files root the tools read from. Defaults to `/resources` (`/app/resources/` in container). | +| `HOME` | No | The per-session sandbox volume root the session-files tools read from. Set by the Foundry platform; can be overridden for local testing. Defaults to `/home/session`. | +| `APPLICATIONINSIGHTS_CONNECTION_STRING` | Recommended | Enables telemetry. Auto-injected in hosted containers; set manually for local dev. | + +**Local development (without `azd`):** + +```bash +# Set env vars directly β€” .NET does not natively read .env files +export FOUNDRY_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" +export AZURE_AI_MODEL_DEPLOYMENT_NAME="" +``` + +> [!NOTE] +> When using `azd ai agent run`, environment variables are handled automatically β€” no manual setup needed. + +### Installing Dependencies + +> [!NOTE] +> If using `azd ai agent run`, dependencies are restored automatically β€” skip to [Running the Sample](#running-the-sample). + +Dependencies are restored automatically when building the project: + +```bash +dotnet restore +``` + +### Running the Sample + +The recommended way to run and test hosted agents locally is with the Azure Developer CLI (`azd`) or the Foundry VS Code extension. + +#### Using the Foundry VS Code Extension + +The [Foundry VS Code extension](https://learn.microsoft.com/en-us/azure/foundry/agents/quickstarts/quickstart-hosted-agent?view=foundry&pivots=vscode) has a built-in sample gallery. You can open this sample directly from the extension without cloning the repository β€” it scaffolds the project into a new workspace, generates `agent.yaml`, `.env`, and `.vscode/tasks.json` + `launch.json` automatically, and configures a one-click **F5** debug experience. + +Follow the [VS Code quickstart](https://learn.microsoft.com/en-us/azure/foundry/agents/quickstarts/quickstart-hosted-agent?view=foundry&pivots=vscode) for a full step-by-step walkthrough. + +#### Using [`azd`](https://learn.microsoft.com/en-us/azure/foundry/agents/quickstarts/quickstart-hosted-agent?view=foundry&pivots=azd) (recommended for CLI workflows) + +No cloning required. Create a new folder, point `azd` at the manifest on GitHub, and it sets up the sample and generates Bicep infrastructure, `agent.yaml`, and env config automatically: + +```bash +# Create a new folder for the agent and navigate into it +mkdir file-tools-agent && cd file-tools-agent + +# Initialize from the manifest β€” azd reads it, downloads the sample, +# and generates Bicep infrastructure, agent.yaml, and env config +azd ai agent init -m https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/csharp/hosted-agents/agent-framework/file-tools/agent.manifest.yaml + +# Provision Azure resources (Foundry project, model deployment, App Insights) +azd provision + +# Run the agent locally (handles env vars, build, and startup) +azd ai agent run +``` + +> [!NOTE] +> If you've already cloned this repository, pass a local path to the manifest instead: +> `azd ai agent init -m /samples/csharp/hosted-agents/agent-framework/file-tools/agent.manifest.yaml` + +> [!NOTE] +> If you already have a Foundry project and model deployment, add `-p -d ` to `azd ai agent init` to target existing resources. You can also skip provisioning entirely and configure env vars manually β€” see [Without `azd`](#without-azd). + +The agent starts on `http://localhost:8088/`. + +##### Try the bundled-files path + +```bash +azd ai agent invoke --local "What is the headline total revenue in the contoso file?" +``` + +The agent calls `ListBundledFiles`, finds `contoso_q1_2026_report.txt`, calls `ReadBundledFile("contoso_q1_2026_report.txt")` (rooted at `/app/resources/`), and quotes the figure verbatim (`$1,482.6M`). + +##### Try the session-files path + +Upload the included demo file to the same session, then ask about it. `azd ai agent files upload` auto-resolves the session-id from the last invocation: + +```bash +azd ai agent files upload ./example-upload/user_notes.txt +azd ai agent invoke --local "What magic token is in user_notes.txt?" +``` + +The agent calls `ListSessionFiles`, finds `user_notes.txt`, calls `ReadSessionFile("user_notes.txt")` (rooted at `$HOME`), and quotes the token. + +##### Try a traversal attempt (it should be refused) + +```bash +azd ai agent invoke --local "Read the file at the path '../../../etc/passwd' from the bundled files." +``` + +The agent's tool schema only accepts a `fileName` (no `path`), and the `Path.GetFileName` + `StartsWith(root)` defence in depth rejects anything that resolves outside the tool's root. The agent will refuse and explain that only the bundled files are available. + +#### Without `azd` + +If running without `azd`, set environment variables manually (see [Environment Variables](#environment-variables)), then: + +```bash +dotnet run +``` + +You can then upload session files via raw HTTP if needed (see [`POST /agents/{name}/endpoint/sessions/{id}/files/content`](https://learn.microsoft.com/en-us/azure/foundry/agents/) in the Foundry SDK docs). + +### Deploying the Agent to Microsoft Foundry + +Once you've tested locally, deploy to Microsoft Foundry: + +```bash +# Provision Azure resources (skip if already done during local setup) +azd provision + +# Build, push, and deploy the agent to Foundry +azd deploy +``` + +After deploying, invoke the agent running in Foundry: + +```bash +azd ai agent invoke "What is the headline total revenue in the contoso file?" +azd ai agent files upload ./example-upload/user_notes.txt +azd ai agent invoke "What magic token is in user_notes.txt?" +``` + +To stream logs from the running agent: + +```bash +azd ai agent monitor +``` + +For the full deployment guide, see [Azure AI Foundry hosted agents](https://aka.ms/azdaiagent/docs). + +## Adding more bundled files + +Drop additional text files into [`resources/`](./resources/). The csproj `` rule picks them up on the next `dotnet build` / `docker build`. + +## Troubleshooting + +### Images built on Apple Silicon or other ARM64 machines do not work on our service + +We **recommend deploying with `azd deploy`**, which uses ACR remote build and always produces images with the correct architecture. + +If you choose to **build locally**, and your machine is **not `linux/amd64`** (for example, an Apple Silicon Mac), the image will **not be compatible with our service**, causing runtime failures. + +**Fix for local builds:** + +```bash +docker build --platform=linux/amd64 -t image . +``` + +This forces the image to be built for the required `amd64` architecture. diff --git a/samples/python/toolbox/azd/azd-samples/web-search/agent.manifest.yaml b/samples/csharp/hosted-agents/agent-framework/file-tools/agent.manifest.yaml similarity index 55% rename from samples/python/toolbox/azd/azd-samples/web-search/agent.manifest.yaml rename to samples/csharp/hosted-agents/agent-framework/file-tools/agent.manifest.yaml index faf9fffaa..349dcf14a 100644 --- a/samples/python/toolbox/azd/azd-samples/web-search/agent.manifest.yaml +++ b/samples/csharp/hosted-agents/agent-framework/file-tools/agent.manifest.yaml @@ -1,36 +1,38 @@ # yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml -name: toolbox-azd-web-search -displayName: "LangGraph Web Search Toolbox Agent" + +name: file-tools +displayName: "File Tools Agent" + description: > - LangGraph ReAct agent with a Bing web search toolbox. The simplest toolbox - scenario β€” no connections or secrets required. + A hosted agent that answers questions over two file knowledge sources: image-bundled + reference files and per-session uploaded files. Demonstrates security-scoped tool + pairs (one tool per source, no path-typed parameters). + metadata: tags: - AI Agent Hosting - - LangGraph - - Web Search - - Microsoft Foundry + - Azure AI AgentServer + - Responses Protocol + - Agent Framework + - File Tools + template: - name: toolbox-azd + name: file-tools kind: hosted protocols: - protocol: responses version: 1.0.0 - environment_variables: - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} resources: cpu: "0.25" memory: 0.5Gi + environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" + parameters: properties: [] + resources: - kind: model - id: gpt-4o + id: gpt-4.1-mini name: AZURE_AI_MODEL_DEPLOYMENT_NAME - - kind: toolbox - name: agent-tools - tools: - - type: web_search diff --git a/samples/csharp/toolbox/maf/ToolboxMafAgent/agent.yaml b/samples/csharp/hosted-agents/agent-framework/file-tools/agent.yaml similarity index 62% rename from samples/csharp/toolbox/maf/ToolboxMafAgent/agent.yaml rename to samples/csharp/hosted-agents/agent-framework/file-tools/agent.yaml index 21a375e93..cea0518a0 100644 --- a/samples/csharp/toolbox/maf/ToolboxMafAgent/agent.yaml +++ b/samples/csharp/hosted-agents/agent-framework/file-tools/agent.yaml @@ -1,6 +1,6 @@ # yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml kind: hosted -name: toolbox-maf-dotnet +name: file-tools protocols: - protocol: responses version: 1.0.0 @@ -8,7 +8,5 @@ resources: cpu: "0.25" memory: 0.5Gi environment_variables: - - name: MODEL_DEPLOYMENT_NAME - value: ${MODEL_DEPLOYMENT_NAME=gpt-4.1} - - name: TOOLBOX_ENDPOINT - value: ${TOOLBOX_ENDPOINT} + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} diff --git a/samples/csharp/hosted-agents/agent-framework/file-tools/example-upload/user_notes.txt b/samples/csharp/hosted-agents/agent-framework/file-tools/example-upload/user_notes.txt new file mode 100644 index 000000000..8e69f253c --- /dev/null +++ b/samples/csharp/hosted-agents/agent-framework/file-tools/example-upload/user_notes.txt @@ -0,0 +1,2 @@ +GREENFIELD-7421 - magic token used to verify session-file round trip. +Add whatever notes you like here; ask the agent to read them after uploading via 'azd ai agent files upload'. \ No newline at end of file diff --git a/samples/csharp/hosted-agents/agent-framework/file-tools/file-tools.csproj b/samples/csharp/hosted-agents/agent-framework/file-tools/file-tools.csproj new file mode 100644 index 000000000..c3d333abd --- /dev/null +++ b/samples/csharp/hosted-agents/agent-framework/file-tools/file-tools.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + FileTools + file-tools + enable + enable + $(NoWarn);NU1903;NU1605 + + + + + + + + + + + + + diff --git a/samples/csharp/hosted-agents/agent-framework/file-tools/resources/contoso_q1_2026_report.txt b/samples/csharp/hosted-agents/agent-framework/file-tools/resources/contoso_q1_2026_report.txt new file mode 100644 index 000000000..858192a7d --- /dev/null +++ b/samples/csharp/hosted-agents/agent-framework/file-tools/resources/contoso_q1_2026_report.txt @@ -0,0 +1,121 @@ +Contoso Corporation +Quarterly Report β€” Q1 2026 (Three months ended March 31, 2026) + +DISCLAIMER +This document contains fictional data for sample/demo purposes only. +Contoso is a fictional company; all figures below are fabricated. + +------------------------------------------------------------ +1. EXECUTIVE SUMMARY +------------------------------------------------------------ +Contoso delivered a solid first quarter, with total revenue of +$1,482.6M, up 11.4% year-over-year. Growth was led by the Cloud +Services segment (+22.7% YoY) and continued double-digit expansion +in International markets. Operating margin expanded 140 basis points +to 23.8% on disciplined cost management and improved gross margin. + +Key highlights: + - Revenue: $1,482.6M (YoY +11.4%) + - Gross profit: $912.0M (gross margin 61.5%) + - Operating income: $352.9M (operating margin 23.8%) + - Net income: $268.4M (net margin 18.1%) + - Diluted EPS: $1.27 (vs. $1.04 prior year) + - Free cash flow: $311.5M + - Cash & equivalents: $2,140.8M + +------------------------------------------------------------ +2. INCOME STATEMENT (USD millions, unaudited) +------------------------------------------------------------ + Q1 2026 Q1 2025 YoY % +Revenue 1,482.6 1,330.7 +11.4% +Cost of revenue 570.6 538.9 +5.9% +Gross profit 912.0 791.8 +15.2% + Gross margin 61.5% 59.5% +200 bps +Operating expenses + Research & development 241.4 220.5 +9.5% + Sales & marketing 218.7 205.1 +6.6% + General & administrative 99.0 88.6 +11.7% +Total operating expenses 559.1 514.2 +8.7% +Operating income 352.9 277.6 +27.1% + Operating margin 23.8% 20.9% +290 bps +Other income / (expense), net 8.4 5.1 +Income before taxes 361.3 282.7 +Provision for income taxes 92.9 72.6 +Net income 268.4 210.1 +27.7% +Diluted EPS (USD) 1.27 1.04 +22.1% + +------------------------------------------------------------ +3. REVENUE BY SEGMENT (USD millions) +------------------------------------------------------------ +Segment Q1 2026 Q1 2025 YoY % +Cloud Services 612.4 499.1 +22.7% +Productivity Software 448.9 422.6 +6.2% +Devices & Hardware 267.0 260.4 +2.5% +Professional Services 154.3 148.6 +3.8% +Total revenue 1,482.6 1,330.7 +11.4% + +------------------------------------------------------------ +4. REVENUE BY GEOGRAPHY (USD millions) +------------------------------------------------------------ +Region Q1 2026 Q1 2025 YoY % +North America 812.1 756.0 +7.4% +EMEA 388.5 340.2 +14.2% +Asia-Pacific 221.7 183.4 +20.9% +Latin America 60.3 51.1 +18.0% +Total revenue 1,482.6 1,330.7 +11.4% + +------------------------------------------------------------ +5. SELECTED BALANCE SHEET ITEMS (USD millions) +------------------------------------------------------------ + Mar 31, Dec 31, + 2026 2025 +Cash & equivalents 2,140.8 1,902.3 +Short-term investments 845.6 820.4 +Accounts receivable, net 1,012.7 988.5 +Total current assets 4,510.2 4,190.6 +Goodwill & intangibles 2,330.1 2,338.9 +Total assets 9,884.5 9,512.0 +Total current liabilities 2,118.4 2,054.7 +Long-term debt 1,750.0 1,750.0 +Total liabilities 4,402.6 4,310.5 +Total stockholders' equity 5,481.9 5,201.5 + +------------------------------------------------------------ +6. CASH FLOW HIGHLIGHTS (USD millions) +------------------------------------------------------------ + Q1 2026 Q1 2025 +Net cash from operating activities 382.0 298.7 +Capital expenditures (70.5) (62.1) +Free cash flow 311.5 236.6 +Share repurchases (120.0) (90.0) +Dividends paid (54.2) (48.6) + +------------------------------------------------------------ +7. KEY OPERATING METRICS +------------------------------------------------------------ +Cloud paid seats (millions) 48.6 39.7 +22.4% +Cloud net revenue retention 118% 114% +Active enterprise customers 18,420 16,905 +9.0% +Headcount (end of period) 22,140 20,610 +7.4% + +------------------------------------------------------------ +8. OUTLOOK β€” Q2 2026 GUIDANCE +------------------------------------------------------------ +Revenue: $1,520M – $1,560M (YoY +10% to +13%) +Operating margin: 23.5% – 24.5% +Diluted EPS: $1.30 – $1.36 +Capital expenditures: ~$80M + +Management remains confident in the full-year plan and reiterates +fiscal-year 2026 revenue growth of 10–12% and operating-margin +expansion of 100–150 basis points versus FY 2025. + +------------------------------------------------------------ +9. NOTES +------------------------------------------------------------ +- All figures are unaudited and rounded to one decimal place. +- Year-over-year comparisons are versus the same period in 2025. +- "Free cash flow" is defined as net cash from operating activities + less capital expenditures, and is a non-GAAP measure. +- This sample report is intended solely for demonstration of an + agent-driven document analysis pipeline. diff --git a/samples/csharp/hosted-agents/agent-framework/foundry-memory-rag/.dockerignore b/samples/csharp/hosted-agents/agent-framework/foundry-memory-rag/.dockerignore new file mode 100644 index 000000000..858b784fb --- /dev/null +++ b/samples/csharp/hosted-agents/agent-framework/foundry-memory-rag/.dockerignore @@ -0,0 +1,20 @@ +# Build artefacts +**/bin/ +**/obj/ + +# IDE settings +.vscode/ +.idea/ + +# Version control +.git/ +.gitignore + +# Docker files +.dockerignore + +# Docs +README.md + +# Local environment (never bake credentials into the image) +.env diff --git a/samples/csharp/hosted-agents/agent-framework/foundry-memory-rag/.env.example b/samples/csharp/hosted-agents/agent-framework/foundry-memory-rag/.env.example new file mode 100644 index 000000000..16d5313f1 --- /dev/null +++ b/samples/csharp/hosted-agents/agent-framework/foundry-memory-rag/.env.example @@ -0,0 +1,18 @@ +# Foundry project endpoint β€” auto-injected in hosted containers. +# Only set manually if running without `azd ai agent run`. +# FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ + +# Chat model deployment name β€” must match a deployment in your Foundry project. +AZURE_AI_MODEL_DEPLOYMENT_NAME= + +# Embedding model deployment name β€” used by FoundryMemoryProvider. +# Must match an embedding deployment in your Foundry project. +AZURE_AI_EMBEDDING_DEPLOYMENT_NAME= + +# Foundry Memory store name β€” created on startup if it does not exist. +# Defaults to "foundry-memory-rag-store" when unset. +# AZURE_AI_MEMORY_STORE_ID=foundry-memory-rag-store + +# Application Insights β€” auto-injected in hosted containers. +# Set for local telemetry (optional but recommended). +# APPLICATIONINSIGHTS_CONNECTION_STRING=InstrumentationKey=... diff --git a/samples/csharp/hosted-agents/agent-framework/foundry-memory-rag/Dockerfile b/samples/csharp/hosted-agents/agent-framework/foundry-memory-rag/Dockerfile new file mode 100644 index 000000000..ffda3545d --- /dev/null +++ b/samples/csharp/hosted-agents/agent-framework/foundry-memory-rag/Dockerfile @@ -0,0 +1,15 @@ +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +WORKDIR /app +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY . . +RUN dotnet restore +RUN dotnet publish -c Release -o /app/publish + +FROM base AS final +WORKDIR /app +COPY --from=build /app/publish . +ENTRYPOINT ["dotnet", "foundry-memory-rag.dll"] diff --git a/samples/csharp/hosted-agents/agent-framework/foundry-memory-rag/Program.cs b/samples/csharp/hosted-agents/agent-framework/foundry-memory-rag/Program.cs new file mode 100644 index 000000000..25dfed258 --- /dev/null +++ b/samples/csharp/hosted-agents/agent-framework/foundry-memory-rag/Program.cs @@ -0,0 +1,69 @@ +ο»Ώ// Copyright (c) Microsoft. All rights reserved. + +// Foundry Memory RAG Agent +// +// Demonstrates how to host an agent that uses FoundryMemoryProvider so user-private memories +// persist across requests and across sessions. The agent plays a personal coach who remembers +// the user's training goals, dietary preferences, and constraints, and uses them in later turns. +// +// Memory store creation: EnsureMemoryStoreCreatedAsync runs once at startup and is idempotent. +// The store name and embedding model are environment-driven so azd provisioning can wire them. + +#pragma warning disable MAAI001 // Microsoft.Agents.AI.Foundry experimental APIs (FoundryMemoryProvider, FoundryMemoryProviderScope) + +using Azure.AI.Projects; +using Azure.Identity; +using DotNetEnv; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry; +using Microsoft.Agents.AI.Foundry.Hosting; +using Microsoft.Extensions.AI; + +Env.TraversePath().Load(); + +var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT environment variable is not set.")); +var deployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4.1-mini"; +var embeddingDeployment = Environment.GetEnvironmentVariable("AZURE_AI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-3-small"; +var memoryStoreName = Environment.GetEnvironmentVariable("AZURE_AI_MEMORY_STORE_ID") ?? "foundry-memory-rag-store"; + +var projectClient = new AIProjectClient(projectEndpoint, new DefaultAzureCredential()); + +// Per-user memory scoping is the production pattern. This sample uses a single shared scope +// because per-user identity from the platform isolation headers is not yet exposed by the +// released hosting package. Once the HostedSessionContext API ships, replace the constant +// below with: session?.GetHostedContext()?.UserId ?? throw new InvalidOperationException(...) +// See microsoft/agent-framework PR #5702 for the contributor reference implementation. +var memoryProvider = new FoundryMemoryProvider( + projectClient, + memoryStoreName, + stateInitializer: _ => new(new FoundryMemoryProviderScope("foundry-memory-rag-user"))); + +// Create the memory store on startup if it does not already exist. Idempotent. +await memoryProvider.EnsureMemoryStoreCreatedAsync(deployment, embeddingDeployment, "Memory store for the personal-coach RAG sample."); + +const string Instructions = """ + You are a friendly personal coach. When the user shares training goals, dietary preferences, + injuries, equipment, or scheduling constraints, remember them and use them in later turns. + Use known memories about the user when responding, and do not invent details. When you are + unsure, ask one clarifying question rather than guessing. + """; + +var agent = projectClient.AsAIAgent(new ChatClientAgentOptions +{ + Name = "foundry-memory-rag", + Description = "A personal coach that remembers your training goals across sessions.", + ChatOptions = new ChatOptions + { + ModelId = deployment, + Instructions = Instructions + }, + AIContextProviders = [memoryProvider] +}); + +var builder = AgentHost.CreateBuilder(args); +builder.Services.AddFoundryResponses(agent); +builder.RegisterProtocol("responses", endpoints => endpoints.MapFoundryResponses()); + +var app = builder.Build(); +app.Run(); diff --git a/samples/csharp/hosted-agents/agent-framework/foundry-memory-rag/README.md b/samples/csharp/hosted-agents/agent-framework/foundry-memory-rag/README.md new file mode 100644 index 000000000..c0845b99b --- /dev/null +++ b/samples/csharp/hosted-agents/agent-framework/foundry-memory-rag/README.md @@ -0,0 +1,175 @@ +# What this sample demonstrates + +A personal coach hosted agent with persistent per-user memory, the **Foundry Memory RAG Agent (Responses Protocol)** sample shows how to ground answers in user-private memories that survive across requests and across sessions, using `FoundryMemoryProvider` from [Agent Framework](https://github.com/microsoft/agent-framework) on top of the [Foundry Memory](https://learn.microsoft.com/azure/ai-foundry/) service. + +## How It Works + +The agent registers a `FoundryMemoryProvider` as an `AIContextProvider`. When the user shares training goals, dietary preferences, injuries, or scheduling constraints, the framework writes those facts to a project-scoped Foundry Memory store. On every subsequent turn (and on requests in brand new sessions) the framework retrieves the most relevant memories and injects them as context for the model, which composes its answer grounded in what it already knows about the user. + +The store is created on startup via `EnsureMemoryStoreCreatedAsync` (idempotent), so a fresh `azd provision` produces a fully working agent on first invocation. + +> [!NOTE] +> Provisioning of the Foundry project, model deployments, and supporting Azure resources is handled by the [`azd-ai-starter-basic`](https://github.com/Azure-Samples/azd-ai-starter-basic) template, which `azd ai agent init` pulls in automatically. The chat and embedding deployments declared under `resources:` in `agent.manifest.yaml` flow into the starter's `AI_PROJECT_DEPLOYMENTS` parameter. + +> [!NOTE] +> This sample uses a single shared memory scope so any caller writes to and reads from the same partition. Production agents should partition memory per end user using the platform-injected isolation headers. See the comment near `stateInitializer` in [Program.cs](Program.cs) for the pattern that becomes available once the `HostedSessionContext` API ships in a future `Microsoft.Agents.AI.Foundry.Hosting` release. + +See [Program.cs](Program.cs) for the full implementation. + +## Running the Agent Locally + +### Prerequisites + +Before running this sample, ensure you have: + +1. **Azure Developer CLI (`azd`)** (recommended) + - [Install azd](https://learn.microsoft.com/en-us/azure/developer/azure-developer-cli/install-azd) and the AI agent extension: `azd ext install azure.ai.agents` + - Authenticated: `azd auth login` + +2. **Azure CLI** + - Installed and authenticated: `az login` + +3. **.NET 10.0 SDK or later** + - Verify your version: `dotnet --version` + - Download from [https://dotnet.microsoft.com/download](https://dotnet.microsoft.com/download) + +> [!NOTE] +> You do **not** need an existing [Microsoft Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/what-is-foundry?view=foundry) project or model deployment to get started, `azd provision` creates them for you. If you already have a project, see the [note below](#using-azd-recommended-for-cli-workflows) on how to target it. + +### Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `FOUNDRY_PROJECT_ENDPOINT` | Yes | Foundry project endpoint. Auto-injected in hosted containers; set automatically by `azd ai agent run` locally. | +| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | Yes | Chat model deployment name. Declared in `agent.manifest.yaml`. | +| `AZURE_AI_EMBEDDING_DEPLOYMENT_NAME` | Yes | Embedding model deployment name (used by Foundry Memory). Declared in `agent.manifest.yaml`. | +| `AZURE_AI_MEMORY_STORE_ID` | No | Memory store name. Defaults to `foundry-memory-rag-store`. The store is created on startup if it does not exist. | +| `APPLICATIONINSIGHTS_CONNECTION_STRING` | Recommended | Enables telemetry. Auto-injected in hosted containers; set manually for local dev. | + +**Local development (without `azd`):** + +```bash +# Set env vars directly. .NET does not natively read .env files. +export FOUNDRY_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" +export AZURE_AI_MODEL_DEPLOYMENT_NAME="" +export AZURE_AI_EMBEDDING_DEPLOYMENT_NAME="" +``` + +> [!NOTE] +> When using `azd ai agent run`, environment variables are handled automatically. No manual setup needed. + +### Installing Dependencies + +> [!NOTE] +> If using `azd ai agent run`, dependencies are restored automatically. Skip to [Running the Sample](#running-the-sample). + +Dependencies are restored automatically when building the project: + +```bash +dotnet restore +``` + +### Running the Sample + +The recommended way to run and test hosted agents locally is with the Azure Developer CLI (`azd`) or the Foundry VS Code extension. + +#### Using the Foundry VS Code Extension + +The [Foundry VS Code extension](https://learn.microsoft.com/en-us/azure/foundry/agents/quickstarts/quickstart-hosted-agent?view=foundry&pivots=vscode) has a built-in sample gallery. You can open this sample directly from the extension without cloning the repository, it scaffolds the project into a new workspace, generates `agent.yaml`, `.env`, and `.vscode/tasks.json` + `launch.json` automatically, and configures a one-click **F5** debug experience. + +Follow the [VS Code quickstart](https://learn.microsoft.com/en-us/azure/foundry/agents/quickstarts/quickstart-hosted-agent?view=foundry&pivots=vscode) for a full step-by-step walkthrough. + +#### Using [`azd`](https://learn.microsoft.com/en-us/azure/foundry/agents/quickstarts/quickstart-hosted-agent?view=foundry&pivots=azd) (recommended for CLI workflows) + +No cloning required. Create a new folder, point `azd` at the manifest on GitHub, and it sets up the sample, generates Bicep infrastructure, `agent.yaml`, and env config: + +```bash +# Create a new folder for the agent and navigate into it +mkdir foundry-memory-rag-agent && cd foundry-memory-rag-agent + +# Initialize from the manifest. azd reads it, downloads the sample, +# and generates Bicep infrastructure, agent.yaml, and env config +azd ai agent init -m https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/csharp/hosted-agents/agent-framework/foundry-memory-rag/agent.manifest.yaml + +# Provision Azure resources (Foundry project, chat + embedding deployments, App Insights) +azd provision + +# Run the agent locally (handles env vars, build, and startup) +azd ai agent run +``` + +> [!NOTE] +> If you've already cloned this repository, pass a local path to the manifest instead: +> `azd ai agent init -m /samples/csharp/hosted-agents/agent-framework/foundry-memory-rag/agent.manifest.yaml` + +> [!NOTE] +> If you already have a Foundry project and model deployments, add `-p -d ` to `azd ai agent init` to target existing resources. You also need an embedding deployment (default `text-embedding-3-small`); set its name via `AZURE_AI_EMBEDDING_DEPLOYMENT_NAME` if it differs from the default. + +The agent starts on `http://localhost:8088/`. Run a few turns to seed memory, then ask the agent to recall: + +```bash +azd ai agent invoke --local "Remember that I want to run my first 5k in October and I prefer morning workouts." +azd ai agent invoke --local "I have a sensitive left knee, please avoid high-impact exercises." +azd ai agent invoke --local "What do you already know about my training goals?" +``` + +Or use curl directly: + +```bash +curl -sS -X POST http://localhost:8088/responses \ + -H "Content-Type: application/json" \ + -d '{"input": "Remember that I want to run my first 5k in October and I prefer morning workouts.", "stream": false}' | jq . + +curl -sS -X POST http://localhost:8088/responses \ + -H "Content-Type: application/json" \ + -d '{"input": "What do you already know about my training goals?", "stream": false}' | jq . +``` + +Memory extraction is asynchronous server-side, expect a few seconds between the teaching turn and the recall turn. + +#### Without `azd` + +If running without `azd`, set environment variables manually (see [Environment Variables](#environment-variables)), then: + +```bash +dotnet run +``` + +### Deploying the Agent to Microsoft Foundry + +Once you've tested locally, deploy to Microsoft Foundry: + +```bash +# Build, push, and deploy the agent to Foundry (also runs provisioning if needed) +azd deploy +``` + +After deploying, invoke the agent running in Foundry: + +```bash +azd ai agent invoke "What do you already know about my training goals?" +``` + +To stream logs from the running agent: + +```bash +azd ai agent monitor +``` + +For the full deployment guide, see [Azure AI Foundry hosted agents](https://aka.ms/azdaiagent/docs). + +## Troubleshooting + +### Images built on Apple Silicon or other ARM64 machines do not work on our service + +We **recommend deploying with `azd deploy`**, which uses ACR remote build and always produces images with the correct architecture. + +If you choose to **build locally**, and your machine is **not `linux/amd64`** (for example, an Apple Silicon Mac), the image will **not be compatible with our service**, causing runtime failures. + +**Fix for local builds:** + +```bash +docker build --platform=linux/amd64 -t image . +``` + +This forces the image to be built for the required `amd64` architecture. diff --git a/samples/csharp/hosted-agents/agent-framework/foundry-memory-rag/agent.manifest.yaml b/samples/csharp/hosted-agents/agent-framework/foundry-memory-rag/agent.manifest.yaml new file mode 100644 index 000000000..817f1016e --- /dev/null +++ b/samples/csharp/hosted-agents/agent-framework/foundry-memory-rag/agent.manifest.yaml @@ -0,0 +1,41 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: foundry-memory-rag +displayName: "Foundry Memory RAG Agent" + +description: > + A personal coach agent that remembers your training goals, preferences, and constraints + across sessions. Uses FoundryMemoryProvider for per-user-private memory grounded in a + Foundry Memory store. + +metadata: + tags: + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Agent Framework + - Foundry Memory + - RAG + +template: + name: foundry-memory-rag + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 + resources: + cpu: "0.25" + memory: 0.5Gi + environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" + - name: AZURE_AI_EMBEDDING_DEPLOYMENT_NAME + value: "{{AZURE_AI_EMBEDDING_DEPLOYMENT_NAME}}" +parameters: + properties: [] +resources: + - kind: model + id: gpt-4.1-mini + name: AZURE_AI_MODEL_DEPLOYMENT_NAME + - kind: model + id: text-embedding-3-small + name: AZURE_AI_EMBEDDING_DEPLOYMENT_NAME diff --git a/samples/csharp/hosted-agents/agent-framework/foundry-memory-rag/agent.yaml b/samples/csharp/hosted-agents/agent-framework/foundry-memory-rag/agent.yaml new file mode 100644 index 000000000..ed005baa5 --- /dev/null +++ b/samples/csharp/hosted-agents/agent-framework/foundry-memory-rag/agent.yaml @@ -0,0 +1,14 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +kind: hosted +name: foundry-memory-rag +protocols: + - protocol: responses + version: 1.0.0 +resources: + cpu: "0.25" + memory: 0.5Gi +environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} + - name: AZURE_AI_EMBEDDING_DEPLOYMENT_NAME + value: ${AZURE_AI_EMBEDDING_DEPLOYMENT_NAME} diff --git a/samples/csharp/hosted-agents/agent-framework/foundry-memory-rag/foundry-memory-rag.csproj b/samples/csharp/hosted-agents/agent-framework/foundry-memory-rag/foundry-memory-rag.csproj new file mode 100644 index 000000000..df5f5494c --- /dev/null +++ b/samples/csharp/hosted-agents/agent-framework/foundry-memory-rag/foundry-memory-rag.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + FoundryMemoryRag + foundry-memory-rag + enable + enable + $(NoWarn);NU1903;NU1605;MAAI001 + + + + + + + + + + diff --git a/samples/csharp/hosted-agents/agent-framework/foundry-toolbox-server-side/agent.manifest.yaml b/samples/csharp/hosted-agents/agent-framework/foundry-toolbox-server-side/agent.manifest.yaml index 95158337b..4c8e3bc25 100644 --- a/samples/csharp/hosted-agents/agent-framework/foundry-toolbox-server-side/agent.manifest.yaml +++ b/samples/csharp/hosted-agents/agent-framework/foundry-toolbox-server-side/agent.manifest.yaml @@ -30,14 +30,12 @@ template: value: "agent-tools" resources: - kind: model - id: gpt-4.1-mini + id: gpt-4.1 name: AZURE_AI_MODEL_DEPLOYMENT_NAME - kind: toolbox name: agent-tools tools: + - type: web_search + name: web_search - type: code_interpreter - container: - type: auto - - type: mcp - server_label: api_specs - server_url: "https://gitmcp.io/Azure/azure-rest-api-specs" + name: code_interpreter diff --git a/samples/csharp/hosted-agents/agent-framework/foundry-toolbox-server-side/agent.yaml b/samples/csharp/hosted-agents/agent-framework/foundry-toolbox-server-side/agent.yaml index dbf942ab1..36ad8e31a 100644 --- a/samples/csharp/hosted-agents/agent-framework/foundry-toolbox-server-side/agent.yaml +++ b/samples/csharp/hosted-agents/agent-framework/foundry-toolbox-server-side/agent.yaml @@ -10,4 +10,6 @@ resources: environment_variables: - name: AZURE_AI_MODEL_DEPLOYMENT_NAME value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} + - name: TOOLBOX_NAME + value: ${TOOLBOX_NAME} diff --git a/samples/csharp/hosted-agents/agent-framework/foundry-toolbox-server-side/test-payload.txt b/samples/csharp/hosted-agents/agent-framework/foundry-toolbox-server-side/test-payload.txt new file mode 100644 index 000000000..36042f28e --- /dev/null +++ b/samples/csharp/hosted-agents/agent-framework/foundry-toolbox-server-side/test-payload.txt @@ -0,0 +1,4 @@ +What tools do you have available? +Use the code interpreter to compute the 30th Fibonacci number. +Find the latest API version for Microsoft.CognitiveServices accounts in the azure-rest-api-specs repo. +Plot a sine wave from 0 to 2Ο€ using the code interpreter. diff --git a/samples/csharp/hosted-agents/agent-framework/hello-world/test-payload.txt b/samples/csharp/hosted-agents/agent-framework/hello-world/test-payload.txt new file mode 100644 index 000000000..7d22b4cea --- /dev/null +++ b/samples/csharp/hosted-agents/agent-framework/hello-world/test-payload.txt @@ -0,0 +1,4 @@ +What is Microsoft Foundry? +Explain how hosted agents work in Foundry. +Give me three ideas for a weekend project. +Summarize the benefits of using the Agent Framework. diff --git a/samples/csharp/hosted-agents/agent-framework/invocations-echo-agent/test-payload.txt b/samples/csharp/hosted-agents/agent-framework/invocations-echo-agent/test-payload.txt new file mode 100644 index 000000000..a9596f8bf --- /dev/null +++ b/samples/csharp/hosted-agents/agent-framework/invocations-echo-agent/test-payload.txt @@ -0,0 +1,4 @@ +Hello there +Testing 1, 2, 3 +Echo this message back to me +The quick brown fox jumps over the lazy dog diff --git a/samples/csharp/hosted-agents/agent-framework/local-tools/test-payload.txt b/samples/csharp/hosted-agents/agent-framework/local-tools/test-payload.txt new file mode 100644 index 000000000..44561a535 --- /dev/null +++ b/samples/csharp/hosted-agents/agent-framework/local-tools/test-payload.txt @@ -0,0 +1,5 @@ +What hotels are available in Seattle? +I need a pet-friendly hotel near the waterfront. +Show me hotels with a pool and spa under $300 per night. +Which hotel near Pike Place Market has the best rating? +Find a hotel for 2 guests checking in next Friday for two nights. diff --git a/samples/csharp/hosted-agents/agent-framework/mcp-tools/test-payload.txt b/samples/csharp/hosted-agents/agent-framework/mcp-tools/test-payload.txt new file mode 100644 index 000000000..5e3113b8e --- /dev/null +++ b/samples/csharp/hosted-agents/agent-framework/mcp-tools/test-payload.txt @@ -0,0 +1,4 @@ +How do I authenticate to Azure using DefaultAzureCredential in C#? +Search Microsoft Learn for guidance on deploying an Azure Container App. +What is Microsoft Foundry and how do I get started? +Find documentation on the Agent Framework Responses protocol. diff --git a/samples/csharp/hosted-agents/agent-framework/simple-agent/test-payload.txt b/samples/csharp/hosted-agents/agent-framework/simple-agent/test-payload.txt new file mode 100644 index 000000000..c8141a18a --- /dev/null +++ b/samples/csharp/hosted-agents/agent-framework/simple-agent/test-payload.txt @@ -0,0 +1,4 @@ +What can you help me with? +Brainstorm names for a coffee shop. +Explain the difference between a stack and a queue. +Give me a quick recipe for chicken stir-fry. diff --git a/samples/csharp/hosted-agents/agent-framework/text-search-rag/test-payload.txt b/samples/csharp/hosted-agents/agent-framework/text-search-rag/test-payload.txt new file mode 100644 index 000000000..04e90b382 --- /dev/null +++ b/samples/csharp/hosted-agents/agent-framework/text-search-rag/test-payload.txt @@ -0,0 +1,5 @@ +What is your return policy? +How long does shipping take? +How should I care for my tent after a camping trip? +What does the warranty cover on my hiking boots? +How do I break in new hiking boots? diff --git a/samples/csharp/hosted-agents/agent-framework/workflows/test-payload.txt b/samples/csharp/hosted-agents/agent-framework/workflows/test-payload.txt new file mode 100644 index 000000000..594e62750 --- /dev/null +++ b/samples/csharp/hosted-agents/agent-framework/workflows/test-payload.txt @@ -0,0 +1,4 @@ +Hello, how are you today? +The weather is beautiful this morning. +I love learning new programming languages. +Coffee is the best way to start the day. diff --git a/samples/csharp/hosted-agents/bring-your-own/invocations/human-in-the-loop/README.md b/samples/csharp/hosted-agents/bring-your-own/invocations/human-in-the-loop/README.md index 86cfc43b8..c5167570c 100644 --- a/samples/csharp/hosted-agents/bring-your-own/invocations/human-in-the-loop/README.md +++ b/samples/csharp/hosted-agents/bring-your-own/invocations/human-in-the-loop/README.md @@ -156,7 +156,6 @@ human-in-the-loop/ β”œβ”€β”€ .env.example # Example environment variables β”œβ”€β”€ agent.yaml # Agent deployment configuration β”œβ”€β”€ agent.manifest.yaml # Agent manifest with metadata and resources -β”œβ”€β”€ test-payload.txt # Sample request payload for testing └── README.md # This file ``` @@ -173,7 +172,7 @@ Error calling Azure OpenAI: Error code: 401 - {'error': {'code': 'PermissionDeni The identity running the agent does not have the required RBAC roles on the Azure AI Foundry project. Assign the following roles: - **Cognitive Services OpenAI User** -- **Azure AI User** +- **Foundry User** Use the Azure CLI to assign them: @@ -190,10 +189,10 @@ az role assignment create \ --role "Cognitive Services OpenAI User" \ --scope "/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.MachineLearningServices/workspaces/$PROJECT_NAME" -# Assign "Azure AI User" role +# Assign "Foundry User" role az role assignment create \ --assignee "$PRINCIPAL_ID" \ - --role "Azure AI User" \ + --role "Foundry User" \ --scope "/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.MachineLearningServices/workspaces/$PROJECT_NAME" ``` diff --git a/samples/csharp/hosted-agents/bring-your-own/invocations/human-in-the-loop/agent.yaml b/samples/csharp/hosted-agents/bring-your-own/invocations/human-in-the-loop/agent.yaml index 5133e653a..69fd05eed 100644 --- a/samples/csharp/hosted-agents/bring-your-own/invocations/human-in-the-loop/agent.yaml +++ b/samples/csharp/hosted-agents/bring-your-own/invocations/human-in-the-loop/agent.yaml @@ -7,3 +7,6 @@ protocols: resources: cpu: "0.25" memory: 0.5Gi +environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} diff --git a/samples/csharp/hosted-agents/bring-your-own/invocations/human-in-the-loop/test-payload.txt b/samples/csharp/hosted-agents/bring-your-own/invocations/human-in-the-loop/test-payload.txt deleted file mode 100644 index a574fc404..000000000 --- a/samples/csharp/hosted-agents/bring-your-own/invocations/human-in-the-loop/test-payload.txt +++ /dev/null @@ -1 +0,0 @@ -{"task":"analyze dataset"} diff --git a/samples/csharp/hosted-agents/bring-your-own/invocations/notetaking-agent/Program.cs b/samples/csharp/hosted-agents/bring-your-own/invocations/notetaking-agent/Program.cs index 260f84cce..3b63d947a 100644 --- a/samples/csharp/hosted-agents/bring-your-own/invocations/notetaking-agent/Program.cs +++ b/samples/csharp/hosted-agents/bring-your-own/invocations/notetaking-agent/Program.cs @@ -2,13 +2,13 @@ using System.Text.Json; using Azure.AI.AgentServer.Invocations; -using Azure.AI.OpenAI; +using Azure.AI.Extensions.OpenAI; +using Azure.AI.Projects; using Azure.Identity; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; -using OpenAI.Chat; +using OpenAI.Responses; -// Derive Azure OpenAI endpoint from the auto-injected Foundry project endpoint if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("APPLICATIONINSIGHTS_CONNECTION_STRING"))) Console.Error.WriteLine( "[WARNING] APPLICATIONINSIGHTS_CONNECTION_STRING not set β€” traces will not be sent " + @@ -17,18 +17,19 @@ var foundryEndpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT environment variable is not set."); -var azureOpenAIEndpoint = new Uri(foundryEndpoint).GetLeftPart(UriPartial.Authority); var deployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? throw new InvalidOperationException("AZURE_AI_MODEL_DEPLOYMENT_NAME environment variable is not set."); -var aoaiClient = new AzureOpenAIClient( - new Uri(azureOpenAIEndpoint), - new DefaultAzureCredential()); -var chatClient = aoaiClient.GetChatClient(deployment); +var projectClient = new AIProjectClient(new Uri(foundryEndpoint), new DefaultAzureCredential()); + +// Use the Responses API via the Foundry project client β€” replaces the legacy +// Azure.AI.OpenAI / AzureOpenAIClient pattern. +var responsesClient = projectClient.ProjectOpenAIClient + .GetProjectResponsesClientForModel(deployment); InvocationsServer.Run(configure: builder => { - builder.Services.AddSingleton(chatClient); + builder.Services.AddSingleton(responsesClient); }); // ────────────────────────────────────────────────────────────────── @@ -36,14 +37,54 @@ // ────────────────────────────────────────────────────────────────── /// -/// Note-taking agent using the invocations protocol with Azure OpenAI function calling. -/// Streams responses as SSE events with per-session JSONL persistence. +/// Note-taking agent using the invocations protocol with the Foundry Responses +/// API for function calling. Streams the final reply as SSE events with +/// per-session JSONL persistence. /// public class NoteTakingHandler : InvocationHandler { - private readonly ChatClient _chatClient; + // Maximum number of tool-call rounds before giving up. Bounds API cost and + // request latency if the model gets stuck in a tool-call feedback loop. + private const int MaxToolRounds = 5; + + private const string SystemPrompt = + "You are a helpful note-taking assistant. You can save notes and retrieve them. " + + "When the user asks to save a note, extract the note content and call save_note. " + + "When the user asks to see their notes, call get_notes. " + + "Always respond in a friendly, concise manner."; + + private static readonly OpenAI.Responses.FunctionTool s_saveNoteTool = ResponseTool.CreateFunctionTool( + functionName: "save_note", + functionDescription: "Save a note with the current timestamp. Use this when the user asks to save, add, or create a note.", + functionParameters: BinaryData.FromString(""" + { + "type": "object", + "properties": { + "note": { + "type": "string", + "description": "The note text to save" + } + }, + "required": ["note"] + } + """), + strictModeEnabled: false); + + private static readonly OpenAI.Responses.FunctionTool s_getNotesTool = ResponseTool.CreateFunctionTool( + functionName: "get_notes", + functionDescription: "Retrieve all saved notes. Use this when the user asks to get, list, show, or view their notes.", + functionParameters: BinaryData.FromString(""" + { + "type": "object", + "properties": {}, + "required": [] + } + """), + strictModeEnabled: false); + + private readonly ProjectResponsesClient _responsesClient; - public NoteTakingHandler(ChatClient chatClient) => _chatClient = chatClient; + public NoteTakingHandler(ProjectResponsesClient responsesClient) => _responsesClient = responsesClient; public override async Task HandleAsync( HttpRequest request, @@ -78,135 +119,77 @@ await response.WriteAsJsonAsync( response.ContentType = "text/event-stream"; response.Headers.CacheControl = "no-cache"; - // Define tools for Azure OpenAI function calling - var tools = new ChatTool[] + var options = new CreateResponseOptions { Instructions = SystemPrompt }; + options.Tools.Add(s_saveNoteTool); + options.Tools.Add(s_getNotesTool); + options.InputItems.Add(ResponseItem.CreateUserMessageItem(userMessage)); + + // Function-call loop: non-streaming rounds while the model emits tool + // calls; once tools have been executed, the final reply is streamed + // token-by-token to the client as SSE events. + string finalText = ""; + bool toolsExecuted = false; + for (int round = 0; round < MaxToolRounds; round++) { - ChatTool.CreateFunctionTool( - "save_note", - "Save a note with the current timestamp. Use this when the user asks to save, add, or create a note.", - BinaryData.FromString(""" - { - "type": "object", - "properties": { - "note": { - "type": "string", - "description": "The note text to save" - } - }, - "required": ["note"] - } - """)), - ChatTool.CreateFunctionTool( - "get_notes", - "Retrieve all saved notes. Use this when the user asks to get, list, show, or view their notes.", - BinaryData.FromString(""" + if (toolsExecuted) + { + await foreach (var update in _responsesClient.CreateResponseStreamingAsync(options, cancellationToken)) { - "type": "object", - "properties": {}, - "required": [] + if (update is StreamingResponseOutputTextDeltaUpdate delta + && !string.IsNullOrEmpty(delta.Delta)) + { + finalText += delta.Delta; + var tokenEvent = JsonSerializer.Serialize(new { type = "token", content = delta.Delta }); + await response.WriteAsync($"data: {tokenEvent}\n\n", cancellationToken); + await response.Body.FlushAsync(cancellationToken); + } } - """)) - }; - - var messages = new List - { - new SystemChatMessage( - "You are a helpful note-taking assistant. You can save notes and retrieve them. " + - "When the user asks to save a note, extract the note content and call save_note. " + - "When the user asks to see their notes, call get_notes. " + - "Always respond in a friendly, concise manner."), - new UserChatMessage(userMessage) - }; - - var options = new ChatCompletionOptions(); - foreach (var tool in tools) - options.Tools.Add(tool); - - // First call β€” may return tool calls - var completion = await _chatClient.CompleteChatAsync(messages, options, cancellationToken); - - // If tool calls are requested, execute them and send results back - if (completion.Value.FinishReason == ChatFinishReason.ToolCalls) - { - messages.Add(new AssistantChatMessage(completion.Value)); - - foreach (var toolCall in completion.Value.ToolCalls) - { - var result = ExecuteToolCall(toolCall.FunctionName, toolCall.FunctionArguments, sessionId); - messages.Add(new ToolChatMessage(toolCall.Id, result)); + break; } - // Second call β€” stream natural language response - await StreamResponseAsync(messages, options, response, context, cancellationToken); - } - else - { - // Direct text response (no tool calls) β€” stream it - var text = completion.Value.Content?.FirstOrDefault()?.Text ?? ""; - await StreamTextAsync(text, response, context, cancellationToken); - } - } - - // ── Streaming helpers ── + var result = await _responsesClient.CreateResponseAsync(options, cancellationToken); + bool functionCalled = false; - private async Task StreamResponseAsync( - List messages, - ChatCompletionOptions options, - HttpResponse response, - InvocationContext context, - CancellationToken cancellationToken) - { - var fullText = ""; - - await foreach (var update in _chatClient.CompleteChatStreamingAsync(messages, options, cancellationToken)) - { - foreach (var part in update.ContentUpdate) + foreach (var responseItem in result.Value.OutputItems) { - if (!string.IsNullOrEmpty(part.Text)) + options.InputItems.Add(responseItem); + if (responseItem is FunctionCallResponseItem functionCall) { - fullText += part.Text; - var tokenEvent = JsonSerializer.Serialize(new { type = "token", content = part.Text }); - await response.WriteAsync($"data: {tokenEvent}\n\n", cancellationToken); - await response.Body.FlushAsync(cancellationToken); + var toolOutput = ExecuteToolCall( + functionCall.FunctionName, + functionCall.FunctionArguments, + sessionId); + options.InputItems.Add( + ResponseItem.CreateFunctionCallOutputItem(functionCall.CallId, toolOutput)); + functionCalled = true; } } - } - // Send completion event - var doneEvent = JsonSerializer.Serialize(new - { - type = "done", - invocation_id = context.InvocationId, - session_id = context.SessionId, - full_text = fullText - }); - await response.WriteAsync($"data: {doneEvent}\n\n", cancellationToken); - await response.Body.FlushAsync(cancellationToken); - } + if (!functionCalled) + { + // No tool calls β€” emit the complete first-round reply as one token event. + finalText = result.Value.GetOutputText() ?? ""; + var tokenEvent = JsonSerializer.Serialize(new { type = "token", content = finalText }); + await response.WriteAsync($"data: {tokenEvent}\n\n", cancellationToken); + break; + } - private static async Task StreamTextAsync( - string text, - HttpResponse response, - InvocationContext context, - CancellationToken cancellationToken) - { - var words = text.Split(' '); - for (int i = 0; i < words.Length; i++) + toolsExecuted = true; + } + + if (string.IsNullOrEmpty(finalText)) { - var token = i == 0 ? words[i] : $" {words[i]}"; - var tokenEvent = JsonSerializer.Serialize(new { type = "token", content = token }); + finalText = "(No final response produced β€” tool-call loop may have exceeded the limit.)"; + var tokenEvent = JsonSerializer.Serialize(new { type = "token", content = finalText }); await response.WriteAsync($"data: {tokenEvent}\n\n", cancellationToken); - await response.Body.FlushAsync(cancellationToken); - await Task.Delay(30, cancellationToken); } - // Send completion event var doneEvent = JsonSerializer.Serialize(new { type = "done", invocation_id = context.InvocationId, session_id = context.SessionId, - full_text = text + full_text = finalText }); await response.WriteAsync($"data: {doneEvent}\n\n", cancellationToken); await response.Body.FlushAsync(cancellationToken); diff --git a/samples/csharp/hosted-agents/bring-your-own/invocations/notetaking-agent/README.md b/samples/csharp/hosted-agents/bring-your-own/invocations/notetaking-agent/README.md index 6c90a816d..e9743cde2 100644 --- a/samples/csharp/hosted-agents/bring-your-own/invocations/notetaking-agent/README.md +++ b/samples/csharp/hosted-agents/bring-your-own/invocations/notetaking-agent/README.md @@ -159,7 +159,7 @@ Error calling Azure OpenAI: Error code: 401 - {'error': {'code': 'PermissionDeni The identity running the agent does not have the required RBAC roles on the Azure AI Foundry project. Assign the following roles: - **Cognitive Services OpenAI User** -- **Azure AI User** +- **Foundry User** Use the Azure CLI to assign them: @@ -176,10 +176,10 @@ az role assignment create \ --role "Cognitive Services OpenAI User" \ --scope "/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.MachineLearningServices/workspaces/$PROJECT_NAME" -# Assign "Azure AI User" role +# Assign "Foundry User" role az role assignment create \ --assignee "$PRINCIPAL_ID" \ - --role "Azure AI User" \ + --role "Foundry User" \ --scope "/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.MachineLearningServices/workspaces/$PROJECT_NAME" ``` diff --git a/samples/csharp/hosted-agents/bring-your-own/invocations/notetaking-agent/agent.yaml b/samples/csharp/hosted-agents/bring-your-own/invocations/notetaking-agent/agent.yaml index f7df54885..19e52c2b7 100644 --- a/samples/csharp/hosted-agents/bring-your-own/invocations/notetaking-agent/agent.yaml +++ b/samples/csharp/hosted-agents/bring-your-own/invocations/notetaking-agent/agent.yaml @@ -7,3 +7,6 @@ protocols: resources: cpu: "0.25" memory: 0.5Gi +environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} diff --git a/samples/csharp/hosted-agents/bring-your-own/invocations/notetaking-agent/notetaking-agent.csproj b/samples/csharp/hosted-agents/bring-your-own/invocations/notetaking-agent/notetaking-agent.csproj index 8b548dacf..6acf982b9 100644 --- a/samples/csharp/hosted-agents/bring-your-own/invocations/notetaking-agent/notetaking-agent.csproj +++ b/samples/csharp/hosted-agents/bring-your-own/invocations/notetaking-agent/notetaking-agent.csproj @@ -4,13 +4,20 @@ net10.0 enable enable + + $(NoWarn);OPENAI001 - - + + + + diff --git a/samples/csharp/hosted-agents/bring-your-own/invocations/notetaking-agent/test-payload.txt b/samples/csharp/hosted-agents/bring-your-own/invocations/notetaking-agent/test-payload.txt deleted file mode 100644 index 8cf3e2de0..000000000 --- a/samples/csharp/hosted-agents/bring-your-own/invocations/notetaking-agent/test-payload.txt +++ /dev/null @@ -1 +0,0 @@ -{"message":"save a note - book reservation for dinner"} diff --git a/samples/csharp/hosted-agents/bring-your-own/responses/background-agent/Program.cs b/samples/csharp/hosted-agents/bring-your-own/responses/background-agent/Program.cs index 65d555195..fac02aeea 100644 --- a/samples/csharp/hosted-agents/bring-your-own/responses/background-agent/Program.cs +++ b/samples/csharp/hosted-agents/bring-your-own/responses/background-agent/Program.cs @@ -3,10 +3,11 @@ using System.Runtime.CompilerServices; using Azure.AI.AgentServer.Responses; using Azure.AI.AgentServer.Responses.Models; -using Azure.AI.OpenAI; +using Azure.AI.Extensions.OpenAI; +using Azure.AI.Projects; using Azure.Identity; using Microsoft.Extensions.DependencyInjection; -using OpenAI.Chat; +using OpenAI.Responses; if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("APPLICATIONINSIGHTS_CONNECTION_STRING"))) Console.Error.WriteLine( @@ -14,21 +15,21 @@ "to Application Insights. Set it to enable local telemetry. " + "(This variable is auto-injected in hosted Foundry containers β€” do not declare it in agent.manifest.yaml.)"); -// Derive Azure OpenAI endpoint from the auto-injected Foundry project endpoint var foundryEndpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") - ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT environment variable is required."); -var azureOpenAIEndpoint = new Uri(foundryEndpoint).GetLeftPart(UriPartial.Authority); + ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT environment variable is not set."); var deployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") - ?? throw new InvalidOperationException("AZURE_AI_MODEL_DEPLOYMENT_NAME environment variable is required."); + ?? throw new InvalidOperationException("AZURE_AI_MODEL_DEPLOYMENT_NAME environment variable is not set."); -var aoaiClient = new AzureOpenAIClient( - new Uri(azureOpenAIEndpoint), - new DefaultAzureCredential()); -var chatClient = aoaiClient.GetChatClient(deployment); +var projectClient = new AIProjectClient(new Uri(foundryEndpoint), new DefaultAzureCredential()); + +// Use the Responses API via the Foundry project client β€” replaces the legacy +// Azure.AI.OpenAI / AzureOpenAIClient pattern. +var responsesClient = projectClient.ProjectOpenAIClient + .GetProjectResponsesClientForModel(deployment); ResponsesServer.Run(configure: builder => { - builder.Services.AddSingleton(chatClient); + builder.Services.AddSingleton(responsesClient); }); // ────────────────────────────────────────────────────────────────── @@ -36,9 +37,9 @@ // ────────────────────────────────────────────────────────────────── /// -/// Background research agent using the responses protocol with Azure OpenAI. -/// Processes requests asynchronously β€” the SDK handles background mode, -/// polling, and cancellation automatically. +/// Background research agent using the responses protocol with the Foundry +/// Responses API. Processes requests asynchronously β€” the SDK handles +/// background mode, polling, and cancellation automatically. /// public class BackgroundResearchHandler : ResponseHandler { @@ -52,9 +53,9 @@ public class BackgroundResearchHandler : ResponseHandler "5. Conclusion\n\n" + "Be detailed and substantive. Target 500-800 words."; - private readonly ChatClient _chatClient; + private readonly ProjectResponsesClient _responsesClient; - public BackgroundResearchHandler(ChatClient chatClient) => _chatClient = chatClient; + public BackgroundResearchHandler(ProjectResponsesClient responsesClient) => _responsesClient = responsesClient; public override IAsyncEnumerable CreateAsync( CreateResponse request, @@ -72,20 +73,15 @@ private async IAsyncEnumerable StreamResearchAsync( var userInput = await context.GetInputTextAsync(cancellationToken: cancellationToken) ?? "General AI trends analysis"; - var messages = new List - { - new SystemChatMessage(SystemPrompt), - new UserChatMessage($"Research topic: {userInput}") - }; + var options = new CreateResponseOptions { Instructions = SystemPrompt }; + options.InputItems.Add(ResponseItem.CreateUserMessageItem($"Research topic: {userInput}")); - await foreach (var update in _chatClient.CompleteChatStreamingAsync(messages, cancellationToken: cancellationToken)) + await foreach (var update in _responsesClient.CreateResponseStreamingAsync(options, cancellationToken)) { - foreach (var part in update.ContentUpdate) + if (update is StreamingResponseOutputTextDeltaUpdate delta + && !string.IsNullOrEmpty(delta.Delta)) { - if (!string.IsNullOrEmpty(part.Text)) - { - yield return part.Text; - } + yield return delta.Delta; } } } diff --git a/samples/csharp/hosted-agents/bring-your-own/responses/background-agent/agent.yaml b/samples/csharp/hosted-agents/bring-your-own/responses/background-agent/agent.yaml index f8411dab3..93329464c 100644 --- a/samples/csharp/hosted-agents/bring-your-own/responses/background-agent/agent.yaml +++ b/samples/csharp/hosted-agents/bring-your-own/responses/background-agent/agent.yaml @@ -7,3 +7,6 @@ protocols: resources: cpu: "0.25" memory: 0.5Gi +environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} diff --git a/samples/csharp/hosted-agents/bring-your-own/responses/background-agent/background-agent.csproj b/samples/csharp/hosted-agents/bring-your-own/responses/background-agent/background-agent.csproj index 02234754d..6f8151a33 100644 --- a/samples/csharp/hosted-agents/bring-your-own/responses/background-agent/background-agent.csproj +++ b/samples/csharp/hosted-agents/bring-your-own/responses/background-agent/background-agent.csproj @@ -4,13 +4,21 @@ net10.0 enable enable + + $(NoWarn);OPENAI001 - - + + + + diff --git a/samples/csharp/hosted-agents/bring-your-own/responses/notetaking-agent/Program.cs b/samples/csharp/hosted-agents/bring-your-own/responses/notetaking-agent/Program.cs index ba8c0abe5..4173c24ae 100644 --- a/samples/csharp/hosted-agents/bring-your-own/responses/notetaking-agent/Program.cs +++ b/samples/csharp/hosted-agents/bring-your-own/responses/notetaking-agent/Program.cs @@ -4,12 +4,12 @@ using System.Text.Json; using Azure.AI.AgentServer.Responses; using Azure.AI.AgentServer.Responses.Models; -using Azure.AI.OpenAI; +using Azure.AI.Extensions.OpenAI; +using Azure.AI.Projects; using Azure.Identity; using Microsoft.Extensions.DependencyInjection; -using OpenAI.Chat; +using OpenAI.Responses; -// Derive Azure OpenAI endpoint from the auto-injected Foundry project endpoint if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("APPLICATIONINSIGHTS_CONNECTION_STRING"))) Console.Error.WriteLine( "[WARNING] APPLICATIONINSIGHTS_CONNECTION_STRING not set β€” traces will not be sent " + @@ -18,18 +18,19 @@ var foundryEndpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT environment variable is not set."); -var azureOpenAIEndpoint = new Uri(foundryEndpoint).GetLeftPart(UriPartial.Authority); var deployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? throw new InvalidOperationException("AZURE_AI_MODEL_DEPLOYMENT_NAME environment variable is not set."); -var aoaiClient = new AzureOpenAIClient( - new Uri(azureOpenAIEndpoint), - new DefaultAzureCredential()); -var chatClient = aoaiClient.GetChatClient(deployment); +var projectClient = new AIProjectClient(new Uri(foundryEndpoint), new DefaultAzureCredential()); + +// Use the Responses API via the Foundry project client β€” replaces the legacy +// Azure.AI.OpenAI / AzureOpenAIClient pattern. +var responsesClient = projectClient.ProjectOpenAIClient + .GetProjectResponsesClientForModel(deployment); ResponsesServer.Run(configure: builder => { - builder.Services.AddSingleton(new LlmConfig(chatClient)); + builder.Services.AddSingleton(responsesClient); }); // ────────────────────────────────────────────────────────────────── @@ -38,9 +39,48 @@ public class NoteTakingHandler : ResponseHandler { - private readonly LlmConfig _llm; + // Maximum number of tool-call rounds before giving up. Bounds API cost and + // request latency if the model gets stuck in a tool-call feedback loop. + private const int MaxToolRounds = 5; + + private const string SystemPrompt = + "You are a helpful note-taking assistant. You can save notes and retrieve them. " + + "When the user asks to save a note, extract the note content and call save_note. " + + "When the user asks to see their notes, call get_notes. " + + "Always respond in a friendly, concise manner."; + + private static readonly OpenAI.Responses.FunctionTool s_saveNoteTool = ResponseTool.CreateFunctionTool( + functionName: "save_note", + functionDescription: "Save a note with the current timestamp. Use this when the user asks to save, add, or create a note.", + functionParameters: BinaryData.FromString(""" + { + "type": "object", + "properties": { + "note": { + "type": "string", + "description": "The note text to save" + } + }, + "required": ["note"] + } + """), + strictModeEnabled: false); - public NoteTakingHandler(LlmConfig llm) => _llm = llm; + private static readonly OpenAI.Responses.FunctionTool s_getNotesTool = ResponseTool.CreateFunctionTool( + functionName: "get_notes", + functionDescription: "Retrieve all saved notes. Use this when the user asks to get, list, show, or view their notes.", + functionParameters: BinaryData.FromString(""" + { + "type": "object", + "properties": {}, + "required": [] + } + """), + strictModeEnabled: false); + + private readonly ProjectResponsesClient _responsesClient; + + public NoteTakingHandler(ProjectResponsesClient responsesClient) => _responsesClient = responsesClient; public override IAsyncEnumerable CreateAsync( CreateResponse request, @@ -59,129 +99,69 @@ private async IAsyncEnumerable ProcessAsync( var userMessage = await context.GetInputTextAsync(cancellationToken: cancellationToken) ?? ""; var sessionId = request.AgentSessionId ?? "default"; - await foreach (var token in ProcessWithLlmAsync(userMessage, sessionId, cancellationToken)) - { - yield return token; - } - } - - // ── LLM mode: Azure OpenAI with function calling ── + var options = new CreateResponseOptions { Instructions = SystemPrompt }; + options.Tools.Add(s_saveNoteTool); + options.Tools.Add(s_getNotesTool); + options.InputItems.Add(ResponseItem.CreateUserMessageItem(userMessage)); - private async IAsyncEnumerable ProcessWithLlmAsync( - string userMessage, - string sessionId, - [EnumeratorCancellation] CancellationToken cancellationToken) - { - var tools = new ChatTool[] + // Function-call loop: keep asking the model until it returns a final + // assistant message with no further function calls. Each round, append + // the model's output items and any tool results to InputItems so the + // next call has the full context. + for (int round = 0; round < MaxToolRounds; round++) { - ChatTool.CreateFunctionTool( - "save_note", - "Save a note with the current timestamp. Use this when the user asks to save, add, or create a note.", - BinaryData.FromString(""" - { - "type": "object", - "properties": { - "note": { - "type": "string", - "description": "The note text to save" - } - }, - "required": ["note"] - } - """)), - ChatTool.CreateFunctionTool( - "get_notes", - "Retrieve all saved notes. Use this when the user asks to get, list, show, or view their notes.", - BinaryData.FromString(""" - { - "type": "object", - "properties": {}, - "required": [] - } - """)) - }; + var result = await _responsesClient.CreateResponseAsync(options, cancellationToken); + bool functionCalled = false; - var messages = new List - { - new SystemChatMessage( - "You are a helpful note-taking assistant. You can save notes and retrieve them. " + - "When the user asks to save a note, extract the note content and call save_note. " + - "When the user asks to see their notes, call get_notes. " + - "Always respond in a friendly, concise manner."), - new UserChatMessage(userMessage) - }; - - var options = new ChatCompletionOptions(); - foreach (var tool in tools) - options.Tools.Add(tool); - - // First call β€” may return tool calls - var completion = await _llm.ChatClient.CompleteChatAsync(messages, options, cancellationToken); - - // If tool calls are requested, execute them and send results back - if (completion.Value.FinishReason == ChatFinishReason.ToolCalls) - { - messages.Add(new AssistantChatMessage(completion.Value)); - - foreach (var toolCall in completion.Value.ToolCalls) + foreach (var responseItem in result.Value.OutputItems) { - var result = ExecuteToolCall(toolCall.FunctionName, toolCall.FunctionArguments, sessionId); - messages.Add(new ToolChatMessage(toolCall.Id, result)); + options.InputItems.Add(responseItem); + if (responseItem is FunctionCallResponseItem functionCall) + { + var toolOutput = ExecuteToolCall( + functionCall.FunctionName, + functionCall.FunctionArguments, + sessionId); + options.InputItems.Add( + ResponseItem.CreateFunctionCallOutputItem(functionCall.CallId, toolOutput)); + functionCalled = true; + } } - // Second call β€” get natural language response - var finalCompletion = await _llm.ChatClient.CompleteChatAsync(messages, options, cancellationToken); - - var response = finalCompletion.Value.Content[0].Text ?? ""; - foreach (var word in SplitIntoTokens(response)) - { - yield return word; - await Task.Delay(30, cancellationToken); - } - } - else - { - // Direct text response (no tool calls) - var response = completion.Value.Content[0].Text ?? ""; - foreach (var word in SplitIntoTokens(response)) + if (!functionCalled) { - yield return word; - await Task.Delay(30, cancellationToken); + yield return result.Value.GetOutputText() ?? ""; + yield break; } } - } - // ── Helpers ── + yield return $"(Tool-call loop exceeded {MaxToolRounds} rounds without producing a final response.)"; + } private static string ExecuteToolCall(string functionName, BinaryData arguments, string sessionId) { - if (functionName == "save_note") + try { - var args = JsonSerializer.Deserialize(arguments); - var noteText = args.GetProperty("note").GetString() ?? ""; - var entry = NoteStore.SaveNote(sessionId, noteText); - return JsonSerializer.Serialize(new { status = "saved", note = entry.Note, timestamp = entry.Timestamp }); - } - else if (functionName == "get_notes") - { - var notes = NoteStore.GetNotes(sessionId); - return JsonSerializer.Serialize(new { count = notes.Count, notes = notes.Select(n => new { n.Note, n.Timestamp }) }); - } - return JsonSerializer.Serialize(new { error = $"Unknown function: {functionName}" }); - } + if (functionName == "save_note") + { + var args = JsonSerializer.Deserialize(arguments); + if (!args.TryGetProperty("note", out var noteProp)) + return JsonSerializer.Serialize(new { error = "Missing required 'note' argument" }); - private static IEnumerable SplitIntoTokens(string text) - { - var words = text.Split(' '); - for (int i = 0; i < words.Length; i++) + var noteText = noteProp.GetString() ?? ""; + var entry = NoteStore.SaveNote(sessionId, noteText); + return JsonSerializer.Serialize(new { status = "saved", note = entry.Note, timestamp = entry.Timestamp }); + } + else if (functionName == "get_notes") + { + var notes = NoteStore.GetNotes(sessionId); + return JsonSerializer.Serialize(new { count = notes.Count, notes = notes.Select(n => new { n.Note, n.Timestamp }) }); + } + return JsonSerializer.Serialize(new { error = $"Unknown function: {functionName}" }); + } + catch (JsonException ex) { - yield return i == 0 ? words[i] : $" {words[i]}"; + return JsonSerializer.Serialize(new { error = $"Invalid tool arguments: {ex.Message}" }); } } } - -// ────────────────────────────────────────────────────────────────── -// Config record for DI -// ────────────────────────────────────────────────────────────────── - -public record LlmConfig(ChatClient ChatClient); diff --git a/samples/csharp/hosted-agents/bring-your-own/responses/notetaking-agent/README.md b/samples/csharp/hosted-agents/bring-your-own/responses/notetaking-agent/README.md index 958acd21f..117bc0191 100644 --- a/samples/csharp/hosted-agents/bring-your-own/responses/notetaking-agent/README.md +++ b/samples/csharp/hosted-agents/bring-your-own/responses/notetaking-agent/README.md @@ -151,7 +151,7 @@ Error calling Azure OpenAI: Error code: 401 - {'error': {'code': 'PermissionDeni The identity running the agent does not have the required RBAC roles on the Azure AI Foundry project. Assign the following roles: - **Cognitive Services OpenAI User** -- **Azure AI User** +- **Foundry User** Use the Azure CLI to assign them: @@ -168,10 +168,10 @@ az role assignment create \ --role "Cognitive Services OpenAI User" \ --scope "/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.MachineLearningServices/workspaces/$PROJECT_NAME" -# Assign "Azure AI User" role +# Assign "Foundry User" role az role assignment create \ --assignee "$PRINCIPAL_ID" \ - --role "Azure AI User" \ + --role "Foundry User" \ --scope "/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.MachineLearningServices/workspaces/$PROJECT_NAME" ``` diff --git a/samples/csharp/hosted-agents/bring-your-own/responses/notetaking-agent/agent.yaml b/samples/csharp/hosted-agents/bring-your-own/responses/notetaking-agent/agent.yaml index a446337ea..e8fc2e2b5 100644 --- a/samples/csharp/hosted-agents/bring-your-own/responses/notetaking-agent/agent.yaml +++ b/samples/csharp/hosted-agents/bring-your-own/responses/notetaking-agent/agent.yaml @@ -7,3 +7,6 @@ protocols: resources: cpu: "0.25" memory: 0.5Gi +environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} diff --git a/samples/csharp/hosted-agents/bring-your-own/responses/notetaking-agent/notetaking-agent.csproj b/samples/csharp/hosted-agents/bring-your-own/responses/notetaking-agent/notetaking-agent.csproj index f8bfd6271..3dbcebd8b 100644 --- a/samples/csharp/hosted-agents/bring-your-own/responses/notetaking-agent/notetaking-agent.csproj +++ b/samples/csharp/hosted-agents/bring-your-own/responses/notetaking-agent/notetaking-agent.csproj @@ -4,13 +4,20 @@ net10.0 enable enable + + $(NoWarn);OPENAI001 - - + + + + diff --git a/samples/csharp/quickstart/responses/quickstart-responses.cs b/samples/csharp/quickstart/responses/quickstart-responses.cs index 0efcb7cfa..3d1220622 100644 --- a/samples/csharp/quickstart/responses/quickstart-responses.cs +++ b/samples/csharp/quickstart/responses/quickstart-responses.cs @@ -14,7 +14,7 @@ tokenProvider: new DefaultAzureCredential()); // Run a responses API call -ProjectResponsesClient responseClient = projectClient.ProjectOpenAIClient.GetProjectResponsesClientForModel("gpt-5-mini"); // supports all Foundry direct models +ProjectResponsesClient responseClient = projectClient.ProjectOpenAIClient.GetProjectResponsesClientForModel("gpt-5-mini"); ResponseResult response = await responseClient.CreateResponseAsync( "What is the size of France in square miles?"); Console.WriteLine(response.GetOutputText()); diff --git a/samples/csharp/toolbox/README.md b/samples/csharp/toolbox/README.md deleted file mode 100644 index fdde286d0..000000000 --- a/samples/csharp/toolbox/README.md +++ /dev/null @@ -1,146 +0,0 @@ - -**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. - - -# .NET Toolbox Samples - -.NET samples for running Microsoft Foundry agents connected to a **Toolbox** via the -MCP Streamable HTTP protocol. Two agent framework options are provided β€” pick the one -that matches your stack. - -## Why Toolboxes? - -Building an AI agent is only half the story. The real magic happens when your agent can **do things** β€” search the web, read emails, query databases, call APIs. But wiring up each tool individually is tedious, fragile, and hard to manage across agents. - -**A toolbox is a reusable bundle of tools, managed in Foundry, that agents consume through a single, consistent interface.** - -| Without Toolbox | With Toolbox | -|---|---| -| Each agent manages its own tool connections | Tools are shared across agents from a central place | -| Auth tokens, retries, and schemas are your problem | Platform handles auth, versioning, and schema validation | -| Adding a tool means redeploying your agent | Add tools to a toolbox β€” agents discover them automatically | -| No standard protocol β€” every integration is custom | Industry-standard **MCP protocol** for all tools | - -### What You Can Put in a Toolbox - -| Tool Type | What It Does | Example | -|-----------|-------------|---------| -| **MCP Tool** | Connect to any MCP-compatible server | GitHub Copilot, custom APIs | -| **Web Search** | Search the internet for fresh information | Bing-powered web search | -| **File Search** | Search your uploaded documents (RAG) | Vector store search | -| **Azure AI Search** | Query Azure AI Search indexes | Enterprise knowledge bases | -| **OpenAPI Tool** | Call any REST API with an OpenAPI spec | Internal microservices | -| **Code Interpreter** | Run Python in a sandboxed environment | Data analysis, calculations | - -## Which sample should I use? - -| I want to… | Use | -|-------------|-----| -| Build a .NET agent with a custom ReAct loop | [`maf/`](./maf/) | -| Create, list, and delete toolbox resources from code | [`crud-sample/`](./crud-sample/) | - -## Sample Comparison - -| Capability | `maf/` | -|-----------|:---:| -| Multi-turn conversation | βœ… | -| Streaming (SSE) | βœ… | -| Tool schema sanitization | βœ… | -| SDK | Agent Framework | - -All agent samples: -- Serve the **Responses Protocol** on port `8088` -- Authenticate to the Toolbox endpoint using `DefaultAzureCredential` (bearer token, auto-refreshed) -- Send the `Foundry-Features: Toolboxes=V1Preview` header on every MCP request (required) - -## Prerequisites (all samples) - -- [.NET 10 SDK](https://dotnet.microsoft.com/download) -- A [Microsoft Foundry](https://ai.azure.com) account and project -- A toolbox already created in that project (see [`crud-sample/`](./crud-sample/)) -- Azure CLI installed and logged in: - - ```bash - az login - ``` - -## What is a Toolbox? - -A **Toolbox** is a named collection of tools (MCP, OpenAPI, Azure AI Search, Web Search, -File Search, Code Interpreter, A2A) hosted in your Microsoft Foundry project. Agents -connect to a toolbox via its MCP endpoint and dynamically discover available tools at startup. - -The toolbox MCP endpoint URL supports two forms: - -``` -# Latest version: -https://.services.ai.azure.com/api/projects//toolboxes//mcp?api-version=v1 - -# Pinned to a specific version: -https://.services.ai.azure.com/api/projects//toolboxes//versions//mcp?api-version=v1 -``` - -> **Note:** The `?api-version=v1` query parameter is **required**. Requests without it return HTTP 400. - -## How to Get Your Project Endpoint - -1. Go to [ai.azure.com](https://ai.azure.com) and open your project. -2. Navigate to **Settings** β†’ **Project details**. -3. Copy the **Project endpoint** value β€” it looks like: - - ``` - https://.services.ai.azure.com/api/projects/ - ``` - -## Troubleshooting - -| Symptom | Cause | Fix | -|---------|-------|-----| -| HTTP 400 on MCP endpoint | Missing `?api-version=v1` in URL | Add `?api-version=v1` to toolbox endpoint | -| HTTP 401 on agent invoke | Agent's managed identity lacks RBAC | Assign "Cognitive Services OpenAI User" role to the agent's `instance_identity.principal_id` | -| "Multiple tools without identifiers" | More than one unnamed tool in a toolbox | Use `MCPTool` with `server_label` for named tools; only one unnamed tool (WebSearch, FileSearch, etc.) per toolbox | -| Agent returns empty response | RBAC propagation delay | Wait 2–5 minutes after role assignment, then retry | -| `session_not_ready` error | Container startup failure | Check `azd ai agent monitor --session-id ` for crash logs | -| Tool schemas rejected by OpenAI | MCP server returns malformed schemas | Sanitize schemas β€” add empty `properties` to `object` types missing them | - -## Key Concepts Reference - -### MCP Protocol - -Toolboxes use **Model Context Protocol (MCP)** β€” an open standard for tool communication: - -- **`tools/list`** β€” Returns all available tools with their names, descriptions, and input schemas -- **`tools/call`** β€” Invokes a specific tool with arguments and returns structured results - -All requests use JSON-RPC 2.0 format over HTTP POST. - -### Authentication - -- **Agent β†’ Toolbox:** Azure AD bearer token (scope: `https://ai.azure.com/.default`) -- **Toolbox β†’ External Services:** Managed by the platform via project connections (API keys, OAuth, managed identity) -- **Required header:** `Foundry-Features: Toolboxes=V1Preview` - -### Toolbox Endpoint Format - -``` -https://.services.ai.azure.com/api/projects//toolboxes//mcp?api-version=v1 -``` - -## Related Python Samples - -The equivalent Python samples are in [`../../python/toolbox/`](../../python/toolbox/). -For toolbox creation SDK examples, see [`../../python/toolbox/sample_toolboxes_crud.py`](../../python/toolbox/sample_toolboxes_crud.py). - -## Contributing - -This project welcomes contributions and suggestions. - -## Trademarks - -This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft trademarks or logos is subject to and must follow [Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. Any use of third-party trademarks or logos are subject to those third-party's policies. diff --git a/samples/csharp/toolbox/crud-sample/README.md b/samples/csharp/toolbox/crud-sample/README.md deleted file mode 100644 index 7ae88333a..000000000 --- a/samples/csharp/toolbox/crud-sample/README.md +++ /dev/null @@ -1,229 +0,0 @@ - -**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. - - -# .NET Toolbox CRUD Sample - -SDK sample for creating, listing, updating, and deleting Microsoft Foundry **Toolbox** -resources using .NET. - -This is the .NET counterpart to the Python sample at -[`../../python/toolbox/sample_toolboxes_crud.py`](../../python/toolbox/sample_toolboxes_crud.py). - -## What This Sample Does - -`ToolboxesCrud/Program.cs` demonstrates the full toolbox lifecycle via the -`Azure.AI.Projects` SDK: - -- **Create** toolbox versions with different tool types -- **List** all toolboxes and their versions -- **Get** a specific toolbox or version -- **Update** a toolbox (promote a version to default) -- **Delete** toolbox versions and entire toolboxes -- **Validate** a live toolbox via MCP `tools/list` and `tools/call` - -Tool types demonstrated: -- MCP (no-auth, key-auth, OAuth, filtered tool list) -- Code Interpreter -- File Search -- Azure AI Search -- Web Search -- OpenAPI (no-auth, project-connection auth) -- A2A (agent-to-agent) -- Multi-tool combinations - -## Prerequisites - -- [.NET 10 SDK](https://dotnet.microsoft.com/download) -- A [Microsoft Foundry](https://ai.azure.com) project -- Azure CLI installed and logged in: - - ```bash - az login - ``` - -- Your account needs at least **Contributor** role on the Foundry project. - -## Quick Start - -### 1. Set environment variables - -**Bash / macOS / Linux:** -```bash -# Required -export FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ - -# Optional β€” needed for key-based and OAuth MCP scenarios -export MCP_CONNECTION_ID=/subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//projects//connections/ -export MCP_OAUTH_CONNECTION_ID=/subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//projects//connections/ - -# Optional β€” needed for File Search scenario -export VECTOR_STORE_ID=vs_... - -# Optional β€” needed for Azure AI Search scenario -export AI_SEARCH_CONNECTION_ID=/subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//projects//connections/ -export AI_SEARCH_INDEX_NAME=my-index - -# Optional β€” needed for OpenAPI with connection auth scenario -export OPENAPI_CONNECTION_ID=/subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//projects//connections/ - -# Optional β€” needed for A2A scenario -export A2A_BASE_URL=https:// -export A2A_CONNECTION_ID=/subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//projects//connections/ -``` - -**PowerShell (Windows):** -```powershell -# Required -$env:FOUNDRY_PROJECT_ENDPOINT = "https://.services.ai.azure.com/api/projects/" - -# Optional β€” needed for key-based and OAuth MCP scenarios -$env:MCP_CONNECTION_ID = "/subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//projects//connections/" -$env:MCP_OAUTH_CONNECTION_ID = "/subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//projects//connections/" - -# Optional β€” needed for File Search scenario -$env:VECTOR_STORE_ID = "vs_..." - -# Optional β€” needed for Azure AI Search scenario -$env:AI_SEARCH_CONNECTION_ID = "/subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//projects//connections/" -$env:AI_SEARCH_INDEX_NAME = "my-index" - -# Optional β€” needed for OpenAPI with connection auth scenario -$env:OPENAPI_CONNECTION_ID = "/subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//projects//connections/" - -# Optional β€” needed for A2A scenario -$env:A2A_BASE_URL = "https://" -$env:A2A_CONNECTION_ID = "/subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//projects//connections/" -``` - -### 2. Run the sample - -Run all samples: -```bash -cd ToolboxesCrud -dotnet run -- all -``` - -Run a specific sample: -```bash -dotnet run -- mcp-noauth # MCP (no auth) -dotnet run -- mcp-keyauth # MCP (key auth) -dotnet run -- mcp-oauth # MCP (OAuth) -dotnet run -- mcp-filtered # MCP (filtered tools) -dotnet run -- code-interpreter # Code Interpreter -dotnet run -- filesearch # File Search -dotnet run -- azure-ai-search # Azure AI Search -dotnet run -- websearch # Web Search -dotnet run -- openapi-noauth # OpenAPI (no auth) -dotnet run -- openapi-conn # OpenAPI (project connection auth) -dotnet run -- a2a # A2A (agent-to-agent) -dotnet run -- multi # Multi-tool (MCP + MCP) -dotnet run -- list # List all toolboxes -``` - -## Getting Your `FOUNDRY_PROJECT_ENDPOINT` - -1. Go to [ai.azure.com](https://ai.azure.com) and open your project. -2. Navigate to **Settings** β†’ **Project details**. -3. Copy the **Project endpoint** β€” it looks like: - - ``` - https://.services.ai.azure.com/api/projects/ - ``` - -## Getting a Connection ID - -Connection IDs are required for MCP tools that use key-based or OAuth authentication. - -1. In [ai.azure.com](https://ai.azure.com), go to your project β†’ **Settings** β†’ **Connected resources**. -2. Click an existing connection to view its details and resource ID. -3. The connection resource ID follows the pattern: - - ``` - /subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//projects//connections/ - ``` - -To create a new connection, go to **Settings** β†’ **Connected resources** β†’ **New connection**. - -## Expected Output - -The sample prints each operation with its result: - -``` -=== Creating toolbox: mcp-noauth === - tools/list β†’ 5 tool(s) - - fetch - - search - ... - -=== Creating toolbox: file-search === - Created version 1 - -=== Listing all toolboxes === - mcp-noauth (default: 1) - file-search (default: 1) - ... - -=== Deleting all toolboxes === - Deleted mcp-noauth - Deleted file-search -``` - -## Troubleshooting - -### `401 Unauthorized` - -Run `az login` and ensure your account has **Contributor** role on the Foundry project. - -### `404 Not Found` on toolbox MCP endpoint - -Ensure the URL includes `?api-version=v1`. Two URL forms are supported: -``` -# Latest version: -https://.services.ai.azure.com/api/projects//toolboxes//mcp?api-version=v1 - -# Pinned to a specific version: -https://.services.ai.azure.com/api/projects//toolboxes//versions//mcp?api-version=v1 -``` - -### `InvalidOperationException: Set FOUNDRY_PROJECT_ENDPOINT` - -The `FOUNDRY_PROJECT_ENDPOINT` environment variable is not set. Follow Step 1 above. - -### NuGet restore fails - -The `nuget.config` file references a private feed required during preview. -Ensure you have been granted access to the feed. - -## SDK Reference - -The sample uses these NuGet packages: -- `Azure.AI.Projects` β€” `AIProjectClient`, `AgentAdministrationClient`, `AgentToolboxes` -- `Azure.AI.Projects.Agents` β€” toolbox tool types -- `Azure.Identity` β€” `DefaultAzureCredential` - -API surface used: -```csharp -var toolboxClient = projectClient.AgentAdministrationClient.GetAgentToolboxes(); -await toolboxClient.CreateToolboxVersionAsync(name, tools, description, metadata); -await toolboxClient.GetToolboxAsync(name); -await toolboxClient.GetToolboxVersionAsync(name, version); -await toolboxClient.GetToolboxesAsync(); -await toolboxClient.UpdateToolboxAsync(name, defaultVersion: version); -await toolboxClient.DeleteToolboxVersionAsync(name, version); -await toolboxClient.DeleteToolboxAsync(name); -``` - -## Contributing - -This project welcomes contributions and suggestions. - -## Trademarks - -This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft trademarks or logos is subject to and must follow [Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. Any use of third-party trademarks or logos are subject to those third-party's policies. diff --git a/samples/csharp/toolbox/crud-sample/ToolboxesCrud/Program.cs b/samples/csharp/toolbox/crud-sample/ToolboxesCrud/Program.cs deleted file mode 100644 index f061e5bde..000000000 --- a/samples/csharp/toolbox/crud-sample/ToolboxesCrud/Program.cs +++ /dev/null @@ -1,597 +0,0 @@ -// Comprehensive SDK sample for toolbox in Microsoft Foundry CRUD operations. -// -// Tested with: -// Azure.AI.Projects 2.1.0-alpha.20260406.1 -// Azure.AI.Projects.Agents 2.1.0-alpha.20260406.1 -// -// API: AIProjectClient β†’ AgentAdministrationClient β†’ GetAgentToolboxes() β†’ AgentToolboxes -// - CreateToolboxVersion(toolboxName, tools, description, metadata) -// - GetToolbox(toolboxName) -// - GetToolboxVersion(toolboxName, version) -// - GetToolboxVersions(toolboxName) -// - GetToolboxes() -// - UpdateToolbox(toolboxName, defaultVersion) -// - DeleteToolboxVersion(toolboxName, version) -// - DeleteToolbox(toolboxName) -// -// Tool types demonstrated: -// - MCP (no-auth, key-auth, OAuth, filtered) -// - Code Interpreter -// - File Search -// - Azure AI Search -// - Web Search -// - OpenAPI (no-auth, project-connection auth) -// - A2A (agent-to-agent) -// - Multi-tool combinations -// -// Prerequisites: -// dotnet add package Azure.AI.Projects --version 2.1.0-alpha.20260406.1 -// dotnet add package Azure.AI.Projects.Agents --version 2.1.0-alpha.20260406.1 -// dotnet add package Azure.Identity -// -// Set environment variables: -// FOUNDRY_PROJECT_ENDPOINT β€” Microsoft Foundry project endpoint -// MCP_CONNECTION_ID β€” Connection ID for key-based MCP (key-auth, filtered, multi) -// MCP_OAUTH_CONNECTION_ID β€” Connection ID for OAuth MCP -// VECTOR_STORE_ID β€” Vector store ID for File Search -// AI_SEARCH_CONNECTION_ID β€” Project connection ID for Azure AI Search index -// AI_SEARCH_INDEX_NAME β€” Azure AI Search index name -// OPENAPI_CONNECTION_ID β€” Project connection ID for OpenAPI key auth -// A2A_BASE_URL β€” Base URL of the remote A2A agent -// A2A_CONNECTION_ID β€” Project connection ID for A2A agent (optional) - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Text; -using System.Text.Json; -using System.Threading.Tasks; -using Azure.AI.Projects; -using Azure.AI.Projects.Agents; -using Azure.Identity; -using OpenAI.Responses; - -// ═══════════════════════════════════════════════════════════════════════════ -// Configuration -// ═══════════════════════════════════════════════════════════════════════════ -var projectEndpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") - ?? throw new InvalidOperationException("Set FOUNDRY_PROJECT_ENDPOINT"); - -var credential = new DefaultAzureCredential(); -var projectClient = new AIProjectClient(endpoint: new Uri(projectEndpoint), tokenProvider: credential); -var toolboxClient = projectClient.AgentAdministrationClient.GetAgentToolboxes(); - -// ═══════════════════════════════════════════════════════════════════════════ -// MCP Validation Helpers (tools/list + tools/call via REST) -// ═══════════════════════════════════════════════════════════════════════════ -string McpEndpoint(string toolboxName) - => $"{projectEndpoint}/toolboxes/{toolboxName}/mcp?api-version=v1"; - -async Task GetBearerTokenAsync() -{ - var tokenResult = await credential.GetTokenAsync( - new Azure.Core.TokenRequestContext(new[] { "https://ai.azure.com/.default" })); - return tokenResult.Token; -} - -async Task> McpToolsListAsync(string toolboxName) -{ - using var http = new HttpClient(); - var token = await GetBearerTokenAsync(); - http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); - http.DefaultRequestHeaders.Add("Foundry-Features", "Toolboxes=V1Preview"); - - var payload = JsonSerializer.Serialize(new - { - jsonrpc = "2.0", - id = 1, - method = "tools/list", - @params = new { } - }); - - var resp = await http.PostAsync( - McpEndpoint(toolboxName), - new StringContent(payload, Encoding.UTF8, "application/json")); - resp.EnsureSuccessStatusCode(); - - var body = await resp.Content.ReadAsStringAsync(); - var doc = JsonDocument.Parse(body); - - // Handle JSON-RPC error responses (e.g. auth failures) - if (doc.RootElement.TryGetProperty("error", out var err)) - { - var msg = err.TryGetProperty("message", out var m) ? m.GetString() : "unknown error"; - Console.WriteLine($" tools/list β†’ ERROR: {msg}"); - return new List(); - } - - var tools = doc.RootElement - .GetProperty("result") - .GetProperty("tools") - .EnumerateArray() - .ToList(); - - Console.WriteLine($" tools/list β†’ {tools.Count} tool(s)"); - foreach (var t in tools.Take(5)) - { - var name = t.TryGetProperty("name", out var n) ? n.GetString() : "?"; - Console.WriteLine($" - {name}"); - } - return tools; -} - -async Task McpToolsCallAsync(string toolboxName, string toolName, object arguments) -{ - using var http = new HttpClient(); - var token = await GetBearerTokenAsync(); - http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); - http.DefaultRequestHeaders.Add("Foundry-Features", "Toolboxes=V1Preview"); - - var payload = JsonSerializer.Serialize(new - { - jsonrpc = "2.0", - id = 2, - method = "tools/call", - @params = new { name = toolName, arguments } - }); - - var resp = await http.PostAsync( - McpEndpoint(toolboxName), - new StringContent(payload, Encoding.UTF8, "application/json")); - resp.EnsureSuccessStatusCode(); - - var body = await resp.Content.ReadAsStringAsync(); - var doc = JsonDocument.Parse(body); - - // Handle JSON-RPC error responses - if (doc.RootElement.TryGetProperty("error", out var callErr)) - { - var msg = callErr.TryGetProperty("message", out var m) ? m.GetString() : "unknown error"; - Console.WriteLine($" tools/call({toolName}) β†’ ERROR: {msg}"); - return; - } - - var content = doc.RootElement - .GetProperty("result") - .GetProperty("content") - .EnumerateArray() - .ToList(); - - Console.WriteLine($" tools/call({toolName}) β†’ {content.Count} content block(s)"); - if (content.Count > 0) - { - var text = content[0].TryGetProperty("text", out var t) ? t.GetString() ?? "" : ""; - Console.WriteLine($" preview: {text[..Math.Min(200, text.Length)]}..."); - } -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Full CRUD lifecycle helper -// ═══════════════════════════════════════════════════════════════════════════ -async Task FullLifecycleAsync( - string toolboxName, - IEnumerable tools, - (string toolName, object args)? validateCall = null) -{ - Console.WriteLine($"\n{"".PadRight(60, '=')}"); - Console.WriteLine($"LIFECYCLE: {toolboxName}"); - Console.WriteLine($"{"".PadRight(60, '=')}"); - - // 0. Clean up if leftover from a previous run - try { await toolboxClient.DeleteToolboxAsync(toolboxName); } catch { } - - try - { - // 1. Create version 1 - var v1Result = await toolboxClient.CreateToolboxVersionAsync( - toolboxName: toolboxName, - tools: tools, - description: $"{toolboxName} v1"); - ToolboxVersion v1 = v1Result.Value; - Console.WriteLine($" 1. CreateToolboxVersion β†’ version={v1.Version}, name={v1.Name}"); - - // 2. Get toolbox record - var recordResult = await toolboxClient.GetToolboxAsync(toolboxName); - ToolboxRecord record = recordResult.Value; - Console.WriteLine($" 2. GetToolbox β†’ name={record.Name}, default_version={record.DefaultVersion}"); - - // 3. MCP tools/list validation - var listed = await McpToolsListAsync(toolboxName); - - // 4. Optional tools/call - if (validateCall is not null && listed.Count > 0) - { - var match = listed.FirstOrDefault(t => - t.TryGetProperty("name", out var n) && n.GetString() == validateCall.Value.toolName); - if (match.ValueKind != JsonValueKind.Undefined) - { - await McpToolsCallAsync(toolboxName, validateCall.Value.toolName, validateCall.Value.args); - } - else - { - Console.WriteLine($" ⚠ tool '{validateCall.Value.toolName}' not found β€” skipping call"); - } - } - - // 5. Create version 2 - var v2Result = await toolboxClient.CreateToolboxVersionAsync( - toolboxName: toolboxName, - tools: tools, - description: $"{toolboxName} v2 (promoted)"); - ToolboxVersion v2 = v2Result.Value; - Console.WriteLine($" 5. CreateToolboxVersion β†’ version={v2.Version}"); - - // 6. List versions - var versions = new List(); - await foreach (var v in toolboxClient.GetToolboxVersionsAsync(toolboxName)) - versions.Add(v); - Console.WriteLine($" 6. ListVersions β†’ {versions.Count} version(s): [{string.Join(", ", versions.Select(v => v.Version))}]"); - - // 7. Promote v2 to default - await toolboxClient.UpdateToolboxAsync(toolboxName, v2.Version); - var updatedResult = await toolboxClient.GetToolboxAsync(toolboxName); - ToolboxRecord updated = updatedResult.Value; - Console.WriteLine($" 7. UpdateToolbox (promote) β†’ default_version={updated.DefaultVersion}"); - - // 8. Get version v2 detail - var v2DetailResult = await toolboxClient.GetToolboxVersionAsync(toolboxName, v2.Version); - ToolboxVersion v2Detail = v2DetailResult.Value; - Console.WriteLine($" 8. GetToolboxVersion β†’ version={v2Detail.Version}, desc={v2Detail.Description}"); - - // 9. Delete v1 (non-default) - await toolboxClient.DeleteToolboxVersionAsync(toolboxName, v1.Version); - Console.WriteLine($" 9. DeleteToolboxVersion v1 β†’ OK"); - } - finally - { - // 10. Delete the entire toolbox (always clean up) - try - { - await toolboxClient.DeleteToolboxAsync(toolboxName); - Console.WriteLine($" 10. DeleteToolbox β†’ OK"); - } - catch { Console.WriteLine($" 10. DeleteToolbox β†’ already gone or failed"); } - } - - return true; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Individual tool samples -// ═══════════════════════════════════════════════════════════════════════════ - -// --------------------------------------------------------------------------- -// 1. MCP β€” No Auth (public server, e.g. gitmcp.io) -// --------------------------------------------------------------------------- -async Task SampleMcpNoAuth() -{ - var tool = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateMcpTool( - serverLabel: "gitmcp", - serverUri: new Uri("https://gitmcp.io/Azure-Samples/agent-openai-python-prompty"))); - - await FullLifecycleAsync("mcp-noauth-sample", new[] { tool }); -} - -// --------------------------------------------------------------------------- -// 2. MCP β€” Key Auth -// --------------------------------------------------------------------------- -async Task SampleMcpKeyAuth() -{ - var connId = Environment.GetEnvironmentVariable("MCP_CONNECTION_ID") - ?? throw new InvalidOperationException("Set MCP_CONNECTION_ID"); - - var tool = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateMcpTool( - serverLabel: "github", - serverUri: new Uri("https://api.githubcopilot.com/mcp"), - headers: new Dictionary - { - ["project_connection_id"] = connId - })); - - await FullLifecycleAsync("mcp-keyauth-sample", new[] { tool }); -} - -// --------------------------------------------------------------------------- -// 3. MCP β€” OAuth -// --------------------------------------------------------------------------- -async Task SampleMcpOAuth() -{ - var connId = Environment.GetEnvironmentVariable("MCP_OAUTH_CONNECTION_ID") - ?? throw new InvalidOperationException("Set MCP_OAUTH_CONNECTION_ID"); - - var tool = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateMcpTool( - serverLabel: "github-oauth", - serverUri: new Uri("https://api.githubcopilot.com/mcp"), - headers: new Dictionary - { - ["project_connection_id"] = connId - })); - - await FullLifecycleAsync("mcp-oauth-sample", new[] { tool }); -} - -// --------------------------------------------------------------------------- -// 4. MCP β€” Filtered tools -// --------------------------------------------------------------------------- -async Task SampleMcpFiltered() -{ - var connId = Environment.GetEnvironmentVariable("MCP_CONNECTION_ID") - ?? throw new InvalidOperationException("Set MCP_CONNECTION_ID"); - - var tool = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateMcpTool( - serverLabel: "github-filtered", - serverUri: new Uri("https://api.githubcopilot.com/mcp"), - headers: new Dictionary - { - ["project_connection_id"] = connId - })); - - await FullLifecycleAsync("mcp-filtered-sample", new[] { tool }); -} - -// --------------------------------------------------------------------------- -// 5. File Search -// --------------------------------------------------------------------------- -async Task SampleFileSearch() -{ - var vectorStoreId = Environment.GetEnvironmentVariable("VECTOR_STORE_ID") - ?? throw new InvalidOperationException("Set VECTOR_STORE_ID"); - - var tool = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateFileSearchTool( - vectorStoreIds: new[] { vectorStoreId })); - - await FullLifecycleAsync("filesearch-sample", new[] { tool }); -} - -// --------------------------------------------------------------------------- -// 6. Web Search -// --------------------------------------------------------------------------- -async Task SampleWebSearch() -{ - var tool = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateWebSearchTool()); - await FullLifecycleAsync("websearch-sample", new[] { tool }); -} - -// --------------------------------------------------------------------------- -// 7. Code Interpreter -// --------------------------------------------------------------------------- -async Task SampleCodeInterpreter() -{ - var tool = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateCodeInterpreterTool()); - await FullLifecycleAsync("codeinterp-sample", new[] { tool }); -} - -// --------------------------------------------------------------------------- -// 8. Azure AI Search -// --------------------------------------------------------------------------- -async Task SampleAzureAiSearch() -{ - var connectionId = Environment.GetEnvironmentVariable("AI_SEARCH_CONNECTION_ID") - ?? throw new InvalidOperationException("Set AI_SEARCH_CONNECTION_ID"); - var indexName = Environment.GetEnvironmentVariable("AI_SEARCH_INDEX_NAME") - ?? throw new InvalidOperationException("Set AI_SEARCH_INDEX_NAME"); - - var tool = new AzureAISearchTool( - new AzureAISearchToolOptions(new[] - { - new AzureAISearchToolIndex( - projectConnectionId: connectionId, - indexName: indexName, - queryType: null, - topK: null, - filter: null, - indexAssetId: null, - additionalBinaryDataProperties: null) - })); - - await FullLifecycleAsync("aisearch-sample", new[] { tool }); -} - -// --------------------------------------------------------------------------- -// 9. OpenAPI β€” No Auth (public API, no credentials required) -// --------------------------------------------------------------------------- -async Task SampleOpenApiNoAuth() -{ - // Minimal OpenAPI 3.0 spec for JSON Placeholder (public REST API) - var spec = JsonSerializer.Serialize(new - { - openapi = "3.0.0", - info = new { title = "JSON Placeholder", version = "1.0" }, - servers = new[] { new { url = "https://jsonplaceholder.typicode.com" } }, - paths = new Dictionary - { - ["/posts/{id}"] = new - { - get = new - { - operationId = "getPost", - summary = "Get a post by ID", - parameters = new[] - { - new { name = "id", @in = "path", required = true, schema = new { type = "integer" } } - }, - responses = new Dictionary - { - ["200"] = new { description = "A post object" } - } - } - } - } - }); - - var tool = new OpenAPITool( - new OpenApiFunctionDefinition( - "jsonplaceholder", - BinaryData.FromString(spec), - new OpenAPIAnonymousAuthenticationDetails())); - - await FullLifecycleAsync("openapi-noauth-sample", new[] { tool }); -} - -// --------------------------------------------------------------------------- -// 10. OpenAPI β€” Project Connection Auth (API key stored in a Foundry connection) -// --------------------------------------------------------------------------- -async Task SampleOpenApiWithConnection() -{ - var connectionId = Environment.GetEnvironmentVariable("OPENAPI_CONNECTION_ID") - ?? throw new InvalidOperationException("Set OPENAPI_CONNECTION_ID"); - - // Example spec for TripAdvisor Location Search API (key via project connection) - var spec = JsonSerializer.Serialize(new - { - openapi = "3.0.1", - info = new { title = "TripAdvisor API", version = "1.0" }, - servers = new[] { new { url = "https://api.content.tripadvisor.com/api/v1" } }, - paths = new Dictionary - { - ["/location/search"] = new - { - get = new - { - operationId = "searchLocations", - summary = "Search for locations", - parameters = new[] - { - new { name = "searchQuery", @in = "query", required = true, schema = new { type = "string" } }, - new { name = "language", @in = "query", schema = new { type = "string" } } - }, - responses = new Dictionary - { - ["200"] = new { description = "Search results" } - }, - security = new[] { new Dictionary { ["apiKey"] = new string[0] } } - } - } - }, - components = new - { - securitySchemes = new Dictionary - { - ["apiKey"] = new { type = "apiKey", name = "key", @in = "query" } - } - } - }); - - var tool = new OpenAPITool( - new OpenApiFunctionDefinition( - "tripadvisor", - BinaryData.FromString(spec), - new OpenApiProjectConnectionAuthenticationDetails( - new OpenApiProjectConnectionSecurityScheme(connectionId)))); - - await FullLifecycleAsync("openapi-connection-sample", new[] { tool }); -} - -// --------------------------------------------------------------------------- -// 11. A2A β€” Agent-to-Agent -// --------------------------------------------------------------------------- -async Task SampleA2A() -{ - var baseUrl = Environment.GetEnvironmentVariable("A2A_BASE_URL") - ?? throw new InvalidOperationException("Set A2A_BASE_URL to the remote agent base URL"); - var connectionId = Environment.GetEnvironmentVariable("A2A_CONNECTION_ID"); - - var tool = new A2APreviewTool(new Uri(baseUrl)); - if (!string.IsNullOrEmpty(connectionId)) - tool.ProjectConnectionId = connectionId; - - await FullLifecycleAsync("a2a-sample", new[] { tool }); -} - -// --------------------------------------------------------------------------- -// 12. Multi-Tool (MCP + MCP) -// --------------------------------------------------------------------------- -async Task SampleMultiTool() -{ - var connId = Environment.GetEnvironmentVariable("MCP_CONNECTION_ID") - ?? throw new InvalidOperationException("Set MCP_CONNECTION_ID"); - - var tool1 = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateMcpTool( - serverLabel: "gitmcp", - serverUri: new Uri("https://gitmcp.io/Azure-Samples/agent-openai-python-prompty"))); - - var tool2 = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateMcpTool( - serverLabel: "github", - serverUri: new Uri("https://api.githubcopilot.com/mcp"), - headers: new Dictionary - { - ["project_connection_id"] = connId - })); - - await FullLifecycleAsync("multi-tool-sample", new[] { tool1, tool2 }); -} - -// --------------------------------------------------------------------------- -// 13. List all toolboxes -// --------------------------------------------------------------------------- -async Task SampleListAll() -{ - var toolboxes = new List(); - await foreach (var tb in toolboxClient.GetToolboxesAsync()) - toolboxes.Add(tb); - - Console.WriteLine($"\n{toolboxes.Count} toolbox(es):"); - foreach (var tb in toolboxes) - Console.WriteLine($" {tb.Name} default_version={tb.DefaultVersion}"); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Runner -// ═══════════════════════════════════════════════════════════════════════════ -var samples = new Dictionary> -{ - ["mcp-noauth"] = SampleMcpNoAuth, - ["mcp-keyauth"] = SampleMcpKeyAuth, - ["mcp-oauth"] = SampleMcpOAuth, - ["mcp-filtered"] = SampleMcpFiltered, - ["filesearch"] = SampleFileSearch, - ["websearch"] = SampleWebSearch, - ["code-interpreter"] = SampleCodeInterpreter, - ["azure-ai-search"] = SampleAzureAiSearch, - ["openapi-noauth"] = SampleOpenApiNoAuth, - ["openapi-conn"] = SampleOpenApiWithConnection, - ["a2a"] = SampleA2A, - ["multi"] = SampleMultiTool, - ["list"] = SampleListAll, -}; - -if (args.Length >= 1 && args[0] == "all") -{ - var results = new Dictionary(); - foreach (var (name, fn) in samples) - { - if (name == "list") continue; - try - { - await fn(); - results[name] = "PASS"; - } - catch (Exception ex) - { - results[name] = $"FAIL: {ex.Message}"; - Console.Error.WriteLine(ex); - } - } - - Console.WriteLine($"\n{"".PadRight(60, '=')}"); - Console.WriteLine("CRUD TEST REPORT"); - Console.WriteLine($"{"".PadRight(60, '=')}"); - foreach (var (name, status) in results) - { - var mark = status == "PASS" ? "βœ“" : "βœ—"; - Console.WriteLine($" {mark} {name}: {status}"); - } - var passed = results.Values.Count(v => v == "PASS"); - Console.WriteLine($"\n {passed}/{results.Count} passed"); -} -else if (args.Length >= 1 && samples.ContainsKey(args[0])) -{ - await samples[args[0]](); -} -else -{ - Console.WriteLine($"Usage: dotnet run -- "); - Console.WriteLine($"Samples: {string.Join(", ", samples.Keys)}"); -} diff --git a/samples/csharp/toolbox/crud-sample/ToolboxesCrud/ToolboxesCrud.csproj b/samples/csharp/toolbox/crud-sample/ToolboxesCrud/ToolboxesCrud.csproj deleted file mode 100644 index 5315b6ab2..000000000 --- a/samples/csharp/toolbox/crud-sample/ToolboxesCrud/ToolboxesCrud.csproj +++ /dev/null @@ -1,17 +0,0 @@ -ο»Ώ - - - Exe - net10.0 - enable - enable - $(NoWarn);OPENAI001;AAIP001 - - - - - - - - - diff --git a/samples/csharp/toolbox/crud-sample/ToolboxesCrud/nuget.config b/samples/csharp/toolbox/crud-sample/ToolboxesCrud/nuget.config deleted file mode 100644 index 9935e83a4..000000000 --- a/samples/csharp/toolbox/crud-sample/ToolboxesCrud/nuget.config +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/samples/csharp/toolbox/maf/README.md b/samples/csharp/toolbox/maf/README.md deleted file mode 100644 index d3d64034a..000000000 --- a/samples/csharp/toolbox/maf/README.md +++ /dev/null @@ -1,175 +0,0 @@ - -**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. - - -# Agent Framework Toolbox Agent (.NET) β€” Responses Protocol - -This sample deploys a .NET Agent Framework agent wired to toolbox MCP in Microsoft Foundry, -using the `Azure.AI.AgentServer.Responses` SDK with the **Responses** protocol. - -It is the .NET counterpart to the Python Agent Framework toolbox sample in -[../../python/toolbox/maf/](../../python/toolbox/maf/). - -## Features - -- **Toolbox MCP integration**: Connects to toolbox endpoints in Microsoft Foundry via MCP HTTP -- **Azure OpenAI function calling**: Discovered MCP tools are exposed as OpenAI function tools -- **Bearer token authentication**: Acquires an Azure AD token for the toolbox endpoint - using `DefaultAzureCredential` -- **Multi-round tool calling**: Supports up to 5 rounds of LLM ↔ tool interaction per request -- **Graceful error handling**: Reports toolbox configuration issues at startup - -## How It Works - -1. The agent connects to a toolbox MCP endpoint using a custom `ToolboxMcpClient` -2. It discovers available tools via MCP `tools/list` -3. Tools are converted to Azure OpenAI `ChatTool` function definitions -4. User messages are processed with the LLM; when the model requests a tool call, - it is forwarded to the toolbox MCP endpoint via `tools/call` -5. The final text response is streamed back via the Responses protocol - -## Prerequisites - -- [.NET 10 SDK](https://dotnet.microsoft.com/download) -- A [Microsoft Foundry](https://ai.azure.com) project -- A toolbox already created in that project β€” see [`../crud-sample/`](../crud-sample/) or the - Python SDK sample [`../../python/toolbox/sample_toolboxes_crud.py`](../../python/toolbox/sample_toolboxes_crud.py) -- Azure CLI installed and logged in: - - ```bash - az login - ``` - -## Environment Variables - -| Variable | Required | Description | -|----------|----------|-------------| -| `FOUNDRY_PROJECT_ENDPOINT` | **Yes** | Foundry project endpoint URL β€” platform-injected at runtime | -| `MODEL_DEPLOYMENT_NAME` | **Yes** | Model deployment name (e.g. `gpt-4.1`) | -| `TOOLBOX_ENDPOINT` | **Yes** | Full toolbox MCP URL **including `?api-version=v1`** | - -The toolbox MCP endpoint URL supports two forms: - -``` -# Latest version: -https://.services.ai.azure.com/api/projects//toolboxes//mcp?api-version=v1 - -# Pinned to a specific version: -https://.services.ai.azure.com/api/projects//toolboxes//versions//mcp?api-version=v1 -``` - -## Running Locally - -**Bash / macOS / Linux:** -```bash -export FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ -export MODEL_DEPLOYMENT_NAME=gpt-4.1 -export TOOLBOX_ENDPOINT=https://.services.ai.azure.com/api/projects//toolboxes//mcp?api-version=v1 - -dotnet run -``` - -**PowerShell (Windows):** -```powershell -$env:FOUNDRY_PROJECT_ENDPOINT = "https://.services.ai.azure.com/api/projects/" -$env:MODEL_DEPLOYMENT_NAME = "gpt-4.1" -$env:TOOLBOX_ENDPOINT = "https://.services.ai.azure.com/api/projects//toolboxes//mcp?api-version=v1" - -dotnet run -``` - -## Testing - -```bash -curl -N -X POST http://localhost:8088/responses \ - -H "Content-Type: application/json" \ - -d '{"model": "chat", "input": "What tools do you have available?"}' -``` - -## Deploying as a Hosted Agent - -### Prerequisites for deployment - -- [Azure Developer CLI (`azd`)](https://learn.microsoft.com/azure/developer/azure-developer-cli/install-azd) installed -- The `azure.ai.agents` azd extension installed: - - ```bash - azd extension install azure.ai.agents - ``` - -### Deploy steps - -```bash -# 1. Log in to Azure -azd auth login - -# 2. Create a new directory and initialize an agent project -mkdir my-dotnet-maf-agent && cd my-dotnet-maf-agent -azd ai agent init \ - -m https://github.com/microsoft/hosted-agents-vnext-private-preview/blob/main/samples/dotnet/toolbox/maf/ToolboxMafAgent/agent.manifest.yaml \ - --project-id /subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//projects/ -``` - -After `azd ai agent init` completes, set required environment variables: - -```bash -# Enable vNext features (required during private preview) -azd env set enableHostedAgentVNext "true" - -# Set the model deployment name (must match a deployment in your Foundry project) -azd env set MODEL_DEPLOYMENT_NAME "gpt-4.1" - -# Set the toolbox endpoint -azd env set TOOLBOX_ENDPOINT "https://.services.ai.azure.com/api/projects//toolboxes//mcp?api-version=v1" -``` - -```bash -# 3. Provision Azure infrastructure -azd provision - -# 4. Build and deploy the container -azd deploy - -# 5. Invoke the deployed agent -azd ai agent invoke --new-session "What tools do you have?" --timeout 120 -``` - -## Protocol - -This sample uses the **Responses Protocol** via `Azure.AI.AgentServer.Responses`, which provides: - -- OpenAI-compatible `/responses` endpoint -- Streaming SSE support -- Multi-turn conversation via `previous_response_id` -- Tool calling through Azure OpenAI function tools - -## Troubleshooting - -### 401 Unauthorized from Azure OpenAI - -Ensure your identity has the **Cognitive Services OpenAI User** role on the Azure OpenAI resource: - -```bash -az role assignment create \ - --role "Cognitive Services OpenAI User" \ - --assignee \ - --scope /subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts/ -``` - -### Toolbox MCP endpoint returns 400 - -Ensure the endpoint URL includes `?api-version=v1`. - -## Contributing - -This project welcomes contributions and suggestions. - -## Trademarks - -This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft trademarks or logos is subject to and must follow [Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. Any use of third-party trademarks or logos are subject to those third-party's policies. diff --git a/samples/csharp/toolbox/maf/ToolboxMafAgent/.dockerignore b/samples/csharp/toolbox/maf/ToolboxMafAgent/.dockerignore deleted file mode 100644 index 13b6509b3..000000000 --- a/samples/csharp/toolbox/maf/ToolboxMafAgent/.dockerignore +++ /dev/null @@ -1,3 +0,0 @@ -bin/ -obj/ -.foundry-agent-build.log diff --git a/samples/csharp/toolbox/maf/ToolboxMafAgent/NuGet.Config b/samples/csharp/toolbox/maf/ToolboxMafAgent/NuGet.Config deleted file mode 100644 index 5d2a1ce79..000000000 --- a/samples/csharp/toolbox/maf/ToolboxMafAgent/NuGet.Config +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/samples/csharp/toolbox/maf/ToolboxMafAgent/Program.cs b/samples/csharp/toolbox/maf/ToolboxMafAgent/Program.cs deleted file mode 100644 index eb73e697a..000000000 --- a/samples/csharp/toolbox/maf/ToolboxMafAgent/Program.cs +++ /dev/null @@ -1,296 +0,0 @@ -// Agent Framework toolbox agent using toolbox MCP endpoint in Microsoft Foundry. -// -// Connects to an toolbox MCP endpoint in Microsoft Foundry, discovers tools via -// tools/list, and exposes them through Azure OpenAI function calling. Incoming -// user messages are processed with the LLM, and when the model requests a tool -// call, it is forwarded to the toolbox MCP endpoint via tools/call. -// -// Usage: -// export FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ -// export MODEL_DEPLOYMENT_NAME=gpt-4.1 -// export TOOLBOX_ENDPOINT=https://.services.ai.azure.com/api/projects//toolboxes//mcp?api-version=v1 -// dotnet run - -using System.Net.Http.Headers; -using System.Runtime.CompilerServices; -using System.Text; -using System.Text.Json; -using Azure.AI.AgentServer.Responses; -using Azure.AI.AgentServer.Responses.Models; -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Extensions.DependencyInjection; -using OpenAI.Chat; - -// ── Configuration ───────────────────────────────────────────────────────── - -var projectEndpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") - ?? throw new InvalidOperationException("Set FOUNDRY_PROJECT_ENDPOINT"); -var deployment = Environment.GetEnvironmentVariable("MODEL_DEPLOYMENT_NAME") - ?? throw new InvalidOperationException("Set MODEL_DEPLOYMENT_NAME"); -var toolboxEndpoint = Environment.GetEnvironmentVariable("TOOLBOX_ENDPOINT"); - -// Derive Azure OpenAI endpoint from the project endpoint (strip /api/projects/...) -var projectUri = new Uri(projectEndpoint); -var openAiEndpoint = $"{projectUri.Scheme}://{projectUri.Host}"; - -if (string.IsNullOrEmpty(toolboxEndpoint)) - Console.Error.WriteLine( - "WARNING: TOOLBOX_ENDPOINT is not set. The agent will run without toolbox tools. " - + "Set this variable (platform-injected at runtime) to enable toolbox integration."); - -// ── Azure OpenAI client ────────────────────────────────────────────────── - -var credential = new DefaultAzureCredential(); -var aoaiClient = new AzureOpenAIClient(new Uri(openAiEndpoint), credential); -var chatClient = aoaiClient.GetChatClient(deployment); - -// ── Toolbox MCP client ─────────────────────────────────────────────────── - -var toolboxClient = !string.IsNullOrEmpty(toolboxEndpoint) - ? new ToolboxMcpClient(toolboxEndpoint, credential) - : null; - -ResponsesServer.Run(configure: builder => -{ - builder.Services.AddSingleton(new AgentConfig(chatClient, toolboxClient)); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// Config record -// ═══════════════════════════════════════════════════════════════════════════ -public record AgentConfig(ChatClient ChatClient, ToolboxMcpClient? ToolboxClient); - -// ═══════════════════════════════════════════════════════════════════════════ -// Response handler -// ═══════════════════════════════════════════════════════════════════════════ -public class ToolboxHandler : ResponseHandler -{ - private readonly AgentConfig _config; - - public ToolboxHandler(AgentConfig config) => _config = config; - - public override IAsyncEnumerable CreateAsync( - CreateResponse request, - ResponseContext context, - CancellationToken cancellationToken) - { - return new TextResponse(context, request, - createTextStream: ct => ProcessAsync(context, ct)); - } - - private async IAsyncEnumerable ProcessAsync( - ResponseContext context, - [EnumeratorCancellation] CancellationToken cancellationToken) - { - var userMessage = await context.GetInputTextAsync(cancellationToken: cancellationToken) ?? "Hello!"; - - // Discover tools from the toolbox MCP endpoint - var chatTools = _config.ToolboxClient != null - ? await _config.ToolboxClient.GetChatToolsAsync() - : new List(); - - var messages = new List - { - new SystemChatMessage( - "You are a helpful assistant with access to toolbox tools in Microsoft Foundry. " + - "Use the available tools to help answer user questions."), - new UserChatMessage(userMessage), - }; - - var options = new ChatCompletionOptions(); - foreach (var tool in chatTools) - options.Tools.Add(tool); - - // Tool-calling loop (max 5 rounds) - for (int round = 0; round < 5; round++) - { - var completion = await _config.ChatClient.CompleteChatAsync(messages, options, cancellationToken); - var result = completion.Value; - - if (result.FinishReason == ChatFinishReason.ToolCalls) - { - var assistantMsg = new AssistantChatMessage(result); - messages.Add(assistantMsg); - - foreach (var toolCall in result.ToolCalls) - { - Console.WriteLine($" Tool call: {toolCall.FunctionName}({toolCall.FunctionArguments})"); - var toolResult = _config.ToolboxClient != null - ? await _config.ToolboxClient.CallToolAsync( - toolCall.FunctionName, - toolCall.FunctionArguments.ToString()) - : "{\"error\": \"Toolbox not configured\"}"; - messages.Add(new ToolChatMessage(toolCall.Id, toolResult)); - } - continue; - } - - // Final text response - foreach (var part in result.Content) - { - if (part.Kind == ChatMessageContentPartKind.Text) - yield return part.Text; - } - yield break; - } - - yield return "Reached maximum tool-calling rounds."; - } -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Toolbox MCP HTTP client -// ═══════════════════════════════════════════════════════════════════════════ -public class ToolboxMcpClient -{ - private readonly string? _endpoint; - private readonly DefaultAzureCredential _credential; - private List? _cachedTools; - - public ToolboxMcpClient(string? endpoint, DefaultAzureCredential credential) - { - _endpoint = endpoint; - _credential = credential; - } - - private async Task GetTokenAsync() - { - var result = await _credential.GetTokenAsync( - new Azure.Core.TokenRequestContext(new[] { "https://ai.azure.com/.default" })); - return result.Token; - } - - private async Task CreateHttpClientAsync() - { - var http = new HttpClient { Timeout = TimeSpan.FromSeconds(120) }; - var token = await GetTokenAsync(); - http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); - http.DefaultRequestHeaders.Add("Foundry-Features", "Toolboxes=V1Preview"); - return http; - } - - public async Task> GetChatToolsAsync() - { - if (string.IsNullOrEmpty(_endpoint)) - return new List(); - - if (_cachedTools != null) - return _cachedTools.Select(t => t.ToChatTool()).ToList(); - - using var http = await CreateHttpClientAsync(); - var payload = JsonSerializer.Serialize(new - { - jsonrpc = "2.0", - id = 1, - method = "tools/list", - @params = new { } - }); - - var resp = await http.PostAsync(_endpoint, - new StringContent(payload, Encoding.UTF8, "application/json")); - resp.EnsureSuccessStatusCode(); - - var body = await resp.Content.ReadAsStringAsync(); - var doc = JsonDocument.Parse(body); - var tools = doc.RootElement - .GetProperty("result") - .GetProperty("tools") - .EnumerateArray() - .Select(McpToolDefinition.FromJson) - .ToList(); - - _cachedTools = tools; - Console.WriteLine($"Discovered {tools.Count} toolbox tool(s):"); - foreach (var t in tools) - Console.WriteLine($" - {t.Name}: {t.Description}"); - - return tools.Select(t => t.ToChatTool()).ToList(); - } - - public async Task CallToolAsync(string toolName, string argumentsJson) - { - if (string.IsNullOrEmpty(_endpoint)) - return "Toolbox endpoint not configured"; - - using var http = await CreateHttpClientAsync(); - var args = JsonDocument.Parse(argumentsJson).RootElement; - var payload = JsonSerializer.Serialize(new - { - jsonrpc = "2.0", - id = 2, - method = "tools/call", - @params = new { name = toolName, arguments = args } - }); - - var resp = await http.PostAsync(_endpoint, - new StringContent(payload, Encoding.UTF8, "application/json")); - resp.EnsureSuccessStatusCode(); - - var body = await resp.Content.ReadAsStringAsync(); - var doc = JsonDocument.Parse(body); - var content = doc.RootElement - .GetProperty("result") - .GetProperty("content") - .EnumerateArray() - .ToList(); - - var texts = content - .Where(c => c.TryGetProperty("text", out _)) - .Select(c => c.GetProperty("text").GetString() ?? "") - .ToList(); - - var result = string.Join("\n", texts); - Console.WriteLine($" Tool result ({toolName}): {result[..Math.Min(200, result.Length)]}..."); - return result; - } -} - -// ═══════════════════════════════════════════════════════════════════════════ -// MCP tool definition β†’ ChatTool converter -// ═══════════════════════════════════════════════════════════════════════════ -public class McpToolDefinition -{ - public string Name { get; set; } = ""; - public string Description { get; set; } = ""; - public JsonElement? InputSchema { get; set; } - - public static McpToolDefinition FromJson(JsonElement el) - { - return new McpToolDefinition - { - Name = el.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "", - Description = el.TryGetProperty("description", out var d) ? d.GetString() ?? "" : "", - InputSchema = el.TryGetProperty("inputSchema", out var s) ? s : null, - }; - } - - public ChatTool ToChatTool() - { - // Ensure schema always has "type":"object" and "properties" - // Azure OpenAI rejects function schemas without these fields - string schemaJson; - if (InputSchema.HasValue) - { - var raw = InputSchema.Value.GetRawText(); - var schemaDoc = JsonDocument.Parse(raw); - var root = schemaDoc.RootElement; - - // Check if properties is present - if (!root.TryGetProperty("properties", out _)) - { - schemaJson = """{"type":"object","properties":{}}"""; - } - else - { - schemaJson = raw; - } - } - else - { - schemaJson = """{"type":"object","properties":{}}"""; - } - - return ChatTool.CreateFunctionTool(Name, Description, BinaryData.FromString(schemaJson)); - } -} diff --git a/samples/csharp/toolbox/maf/ToolboxMafAgent/ToolboxMafAgent.csproj b/samples/csharp/toolbox/maf/ToolboxMafAgent/ToolboxMafAgent.csproj deleted file mode 100644 index 812d519fa..000000000 --- a/samples/csharp/toolbox/maf/ToolboxMafAgent/ToolboxMafAgent.csproj +++ /dev/null @@ -1,17 +0,0 @@ -ο»Ώ - - - Exe - net10.0 - enable - enable - $(NoWarn);OPENAI001;AAIP001 - - - - - - - - - diff --git a/samples/csharp/toolbox/maf/ToolboxMafAgent/agent.manifest.yaml b/samples/csharp/toolbox/maf/ToolboxMafAgent/agent.manifest.yaml deleted file mode 100644 index 817e03fe4..000000000 --- a/samples/csharp/toolbox/maf/ToolboxMafAgent/agent.manifest.yaml +++ /dev/null @@ -1,33 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml -name: toolbox-maf-dotnet -displayName: "Toolbox MAF Agent (.NET)" - -description: > - Agent Framework agent wired to an toolbox MCP endpoint in Microsoft Foundry. - Discovers tools via MCP tools/list and exposes them through Azure OpenAI - function calling. Tool calls are forwarded to the toolbox via MCP tools/call. - -metadata: - tags: - - AI Agent Hosting - - Agent Framework - - Toolbox - - MCP - - Microsoft Foundry - - .NET - -template: - name: toolbox-maf-dotnet - kind: hosted - protocols: - - protocol: responses - version: 1.0.0 - environment_variables: - - name: MODEL_DEPLOYMENT_NAME - value: "{{MODEL_DEPLOYMENT_NAME}}" - - name: TOOLBOX_ENDPOINT - value: "{{TOOLBOX_ENDPOINT}}" -resources: - - kind: model - id: gpt-4.1-mini - name: MODEL_DEPLOYMENT_NAME diff --git a/samples/java/quickstart/responses/src/main/java/com/azure/ai/agents/CreateResponse.java b/samples/java/quickstart/responses/src/main/java/com/azure/ai/agents/CreateResponse.java index 0e5fbdc5f..170446d8e 100644 --- a/samples/java/quickstart/responses/src/main/java/com/azure/ai/agents/CreateResponse.java +++ b/samples/java/quickstart/responses/src/main/java/com/azure/ai/agents/CreateResponse.java @@ -18,7 +18,7 @@ public static void main(String[] args) { // Run a responses API call ResponseCreateParams responseRequest = new ResponseCreateParams.Builder() .input("What is the size of France in square miles?") - .model("gpt-5-mini") // supports all Foundry direct models + .model("gpt-5-mini") .build(); Response response = responsesClient.getResponseService().create(responseRequest); System.out.println(response.output()); diff --git a/samples/python/hosted-agents/README.md b/samples/python/hosted-agents/README.md index 8fb98117e..9c8fac134 100644 --- a/samples/python/hosted-agents/README.md +++ b/samples/python/hosted-agents/README.md @@ -24,18 +24,18 @@ Read on to pick the right sample for your scenario, or jump to the [learning pat Hosted agents support two protocols. Pick the one that matches your scenario. -| Scenario | Protocol | Why | -|----------|----------|-----| -| Conversational chatbot or assistant | **Responses** | The platform manages conversation history, streaming events, and session lifecycle β€” use any OpenAI-compatible SDK as the client. | -| Agent published to Teams or M365 | **Responses** + **Activity** | The Responses protocol powers the agent logic; the Activity protocol handles the Teams channel integration. | -| Multi-turn Q&A with RAG or tools | **Responses** | Built-in `conversation_id` threading and tool result handling. | -| Background / async processing | **Responses** | `background: true` with platform-managed polling and cancellation β€” no custom code needed. | -| Webhook receiver (GitHub, Stripe, Jira, etc.) | **Invocations** | The external system sends its own payload format β€” you can't change it to match `/responses`. | -| Non-conversational processing (classification, extraction, batch) | **Invocations** | The input is structured data, not a chat message. Arbitrary JSON in, arbitrary JSON out. | -| Custom streaming protocol (AG-UI, etc.) | **Invocations** | AG-UI and other agent-UI protocols aren't OpenAI-compatible β€” you need raw SSE control. | -| Async job with custom progress, polling, or non-OpenAI callers | **Invocations** | Custom progress reporting, intermediate results, and polling semantics beyond what Responses `background: true` provides. | -| Protocol bridge (GitHub Copilot, proprietary systems) | **Invocations** | The caller has its own protocol that doesn't map to `/responses`. | -| Inter-service orchestration (Durable Functions, Logic Apps) | **Invocations** | The caller sends structured task payloads, not chat messages. | +| Scenario | Protocol | Why | +| ----------------------------------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| Conversational chatbot or assistant | **Responses** | The platform manages conversation history, streaming events, and session lifecycle β€” use any OpenAI-compatible SDK as the client. | +| Agent published to Teams or M365 | **Responses** + **Activity** | The Responses protocol powers the agent logic; the Activity protocol handles the Teams channel integration. | +| Multi-turn Q&A with RAG or tools | **Responses** | Built-in `conversation_id` threading and tool result handling. | +| Background / async processing | **Responses** | `background: true` with platform-managed polling and cancellation β€” no custom code needed. | +| Webhook receiver (GitHub, Stripe, Jira, etc.) | **Invocations** | The external system sends its own payload format β€” you can't change it to match `/responses`. | +| Non-conversational processing (classification, extraction, batch) | **Invocations** | The input is structured data, not a chat message. Arbitrary JSON in, arbitrary JSON out. | +| Custom streaming protocol (AG-UI, etc.) | **Invocations** | AG-UI and other agent-UI protocols aren't OpenAI-compatible β€” you need raw SSE control. | +| Async job with custom progress, polling, or non-OpenAI callers | **Invocations** | Custom progress reporting, intermediate results, and polling semantics beyond what Responses `background: true` provides. | +| Protocol bridge (GitHub Copilot, proprietary systems) | **Invocations** | The caller has its own protocol that doesn't map to `/responses`. | +| Inter-service orchestration (Durable Functions, Logic Apps) | **Invocations** | The caller sends structured task payloads, not chat messages. | > **Still not sure?** Start with **Responses**. You can always add an Invocations endpoint later β€” a hosted agent can support both protocols simultaneously by listing both in `agent.yaml`. @@ -44,16 +44,16 @@ Hosted agents support two protocols. Pick the one that matches your scenario.
Protocol comparison details -| | **Responses** | **Invocations** | -|---|---|---| -| **Best for** | Most agents β€” the platform manages conversation history, streaming lifecycle, and background polling | Agents that need full HTTP control, custom payloads, or custom async workflows | -| **Payload** | OpenAI-compatible `/responses` contract | Arbitrary JSON via `/invocations` β€” you define the schema | -| **Client SDK** | Any OpenAI-compatible SDK (Python, JS, C#) works out of the box | Custom client β€” you define the contract | -| **Session history** | Framework-managed via `conversation_id` | You manage sessions (in-memory, Cosmos DB, etc.) | -| **Streaming** | Framework-managed `ResponseEventStream` with lifecycle events (`created`, `in_progress`, `delta`, `completed`) | Raw SSE β€” you format and write events directly | -| **Background / long-running** | Built-in (`background: true` + platform-managed polling) | Manual task tracking and custom polling endpoints | -| **Server SDK** | `azure-ai-agentserver-responses` | `azure-ai-agentserver-invocations` | -| **agent.yaml** | `protocol: responses`, `version: v0.1.0` | `protocol: invocations`, `version: v0.0.1` | +| | **Responses** | **Invocations** | +| ----------------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| **Best for** | Most agents β€” the platform manages conversation history, streaming lifecycle, and background polling | Agents that need full HTTP control, custom payloads, or custom async workflows | +| **Payload** | OpenAI-compatible `/responses` contract | Arbitrary JSON via `/invocations` β€” you define the schema | +| **Client SDK** | Any OpenAI-compatible SDK (Python, JS, C#) works out of the box | Custom client β€” you define the contract | +| **Session history** | Framework-managed via `conversation_id` | You manage sessions (in-memory, Cosmos DB, etc.) | +| **Streaming** | Framework-managed `ResponseEventStream` with lifecycle events (`created`, `in_progress`, `delta`, `completed`) | Raw SSE β€” you format and write events directly | +| **Background / long-running** | Built-in (`background: true` + platform-managed polling) | Manual task tracking and custom polling endpoints | +| **Server SDK** | `azure-ai-agentserver-responses` | `azure-ai-agentserver-invocations` | +| **agent.yaml** | `protocol: responses`, `version: v0.1.0` | `protocol: invocations`, `version: v0.0.1` |
@@ -63,14 +63,14 @@ Hosted agents support two protocols. Pick the one that matches your scenario. Hosted agents run any code you can put in a container. These samples cover three frameworks β€” pick the one that matches where you are. -| | **Agent Framework** | **LangGraph** | **Bring Your Own** | -|---|---|---|---| -| **Best for** | Starting fresh on Foundry β€” also supports AutoGen and Semantic Kernel | Already using LangChain / LangGraph | Already built with CrewAI or your own stack | -| **SDK** | `agent-framework-foundry-hosting` (includes core, openai, foundry, orchestrations) | `azure-ai-agentserver-responses` / `azure-ai-agentserver-invocations` | `azure-ai-agentserver-responses` / `azure-ai-agentserver-invocations`, or `azure-ai-agentserver-core` for fully custom HTTP | -| **Foundry integration** | Native β€” sessions, tools, memory, streaming all built in | Adapter β€” sessions and tools wired through LangGraph adapter | Core adapter hosts the web server and exposes `/invocations` and `/responses` endpoints; you supply the agent logic | -| **Protocols** | Responses and Invocations | Responses and Invocations | Responses and Invocations | -| **Language support** | Python and C# | Python only | Any language (Python and C# samples provided) | -| **Start here** | [Basic Agent β†’](agent-framework/responses/01-basic/) | [LangGraph Chat β†’](bring-your-own/responses/langgraph-chat/) | [Hello World β†’](bring-your-own/responses/hello-world/) | +| | **Agent Framework** | **LangGraph** | **Bring Your Own** | +| ----------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| **Best for** | Starting fresh on Foundry β€” also supports AutoGen and Semantic Kernel | Already using LangChain / LangGraph | Already built with CrewAI or your own stack | +| **SDK** | `agent-framework-foundry-hosting` (includes core, openai, foundry, orchestrations) | `azure-ai-agentserver-responses` / `azure-ai-agentserver-invocations` | `azure-ai-agentserver-responses` / `azure-ai-agentserver-invocations`, or `azure-ai-agentserver-core` for fully custom HTTP | +| **Foundry integration** | Native β€” sessions, tools, memory, streaming all built in | Adapter β€” sessions and tools wired through LangGraph adapter | Core adapter hosts the web server and exposes `/invocations` and `/responses` endpoints; you supply the agent logic | +| **Protocols** | Responses and Invocations | Responses and Invocations | Responses and Invocations | +| **Language support** | Python and C# | Python only | Any language (Python and C# samples provided) | +| **Start here** | [Basic Agent β†’](agent-framework/responses/01-basic/) | [LangGraph Chat β†’](bring-your-own/responses/langgraph-chat/) | [Hello World β†’](bring-your-own/responses/hello-world/) | > **Which should I choose?** If you're building a new agent β€” or already using AutoGen or Semantic Kernel β€” start with **Agent Framework**. It has the tightest Foundry integration, supports those orchestrators natively, and has the most samples to learn from. If you already have LangGraph code, use the **LangGraph** adapter to bring it to Foundry. If you have an existing agent in another framework (e.g., CrewAI), **Bring Your Own** shows how to containerize and deploy it unchanged. @@ -95,6 +95,11 @@ The platform manages conversation history, streaming lifecycle, and background e 3. **[MCP Tools](./agent-framework/responses/03-mcp/)** β€” Connect your agent to a remote MCP server to access tools, retrieval, and more. 4. **[Foundry Toolbox](./agent-framework/responses/04-foundry-toolbox/)** β€” Wire your agent to a Foundry Toolbox for managed tool access. 5. **[Workflows](./agent-framework/responses/05-workflows/)** β€” Compose multiple agents into sequential pipelines. +6. **[Files](./agent-framework/responses/06-files/)** β€” Agent capable of manipulating files uploaded to the session. +7. **[Skills](./agent-framework/responses/07-skills/)** β€” Add native file-based skills to your agent and generate a colorful PDF travel guide. +8. **[Observability](./agent-framework/responses/08-observability/)** β€” Add logging, metrics, and distributed tracing to your agent and visualize them in Foundry. +9. **[Declarative Workflows](./agent-framework/responses/09-declarative-customer-support/)** β€” A multi-turn customer-support triage workflow defined entirely in YAML and hosted as an agent, demonstrating declarative workflow authoring with `InvokeAzureAgent` calls to specialist Foundry-hosted agents and conversation-aware routing. +10. **[Downstream Azure services](./agent-framework/responses/09-downstream-azure/)** β€” Call Azure Blob Storage and Service Bus from the agent using its per-agent Microsoft Entra identity (no connection strings). ### Invocations protocol @@ -102,8 +107,8 @@ Full control over the HTTP request/response cycle. You define the payload schema > **Every capability works with both protocols.** Tools, RAG, memory, evaluations, Teams publishing, multi-agent β€” all of these work with Invocations. The Invocations samples below focus on the protocol mechanics (how you handle requests, streaming, sessions, and long-running tasks). To add a capability like knowledge grounding or tools, learn the Invocations pattern from these samples, then adapt the relevant Responses sample β€” the capability code is the same, only the HTTP handler differs. -| Sample | What it shows | -|--------|---------------| +| Sample | What it shows | +| ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | | **[Basic Invocations Agent](./agent-framework/invocations/01-basic/)** | Minimal invocations agent β€” shows the invocations handler pattern with Agent Framework. | --- @@ -113,6 +118,7 @@ Full control over the HTTP request/response cycle. You define the payload schema LangGraph samples are included in the **Bring Your Own** section below β€” see [`bring-your-own/responses/langgraph-chat/`](bring-your-own/responses/langgraph-chat/) and [`bring-your-own/invocations/langgraph-chat/`](bring-your-own/invocations/langgraph-chat/). --- + ## Bring Your Own Framework samples Already built an agent with CrewAI or your own code? The protocol SDKs (`azure-ai-agentserver-responses` / `azure-ai-agentserver-invocations`) give you the hosted agent HTTP contract β€” they host the web server, expose the right endpoint, and handle request parsing β€” so you just plug in your agent logic. This is the recommended path for BYO to ensure your agent stays aligned with the platform contract as new endpoints are added. For lower-level control, the **Core adapter** (`azure-ai-agentserver-core`) gives you managed hosting, OpenTelemetry tracing, and health endpoints, but you handle the protocol details yourself. @@ -121,25 +127,26 @@ Already built an agent with CrewAI or your own code? The protocol SDKs (`azure-a ### Responses protocol -| Sample | What it shows | -|--------|--------------| -| **[Hello World](bring-your-own/responses/hello-world/)** | Minimal agent β€” calls a Foundry model via the Responses API and returns the reply. The simplest possible BYO starting point. | -| **[LangGraph Chat](bring-your-own/responses/langgraph-chat/)** | LangGraph conversational agent hosted on Foundry with multi-turn history via the Responses protocol. | -| **[Notetaking Agent](bring-your-own/responses/notetaking-agent/)** | Agent that takes and retrieves notes using a custom tool. | -| **[Toolbox](bring-your-own/responses/toolbox/)** | BYO agent wired to a Foundry Toolbox MCP endpoint for tool access. | -| **[Background Agent](bring-your-own/responses/background-agent/)** | Long-running background processing with async execution. | +| Sample | What it shows | +| ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | +| **[Hello World](bring-your-own/responses/hello-world/)** | Minimal agent β€” calls a Foundry model via the Responses API and returns the reply. The simplest possible BYO starting point. | +| **[LangGraph Chat](bring-your-own/responses/langgraph-chat/)** | LangGraph conversational agent hosted on Foundry with multi-turn history via the Responses protocol. | +| **[Notetaking Agent](bring-your-own/responses/notetaking-agent/)** | Agent that takes and retrieves notes using a custom tool. | +| **[Toolbox](bring-your-own/responses/toolbox/)** | BYO agent wired to a Foundry Toolbox MCP endpoint for tool access. | +| **[Background Agent](bring-your-own/responses/background-agent/)** | Long-running background processing with async execution. | ### Invocations protocol -| Sample | What it shows | -|--------|--------------| -| **[Hello World](bring-your-own/invocations/hello-world/)** | Minimal agent β€” arbitrary JSON in, streaming SSE out. The simplest possible BYO invocations starting point. | -| **[LangGraph Chat](bring-your-own/invocations/langgraph-chat/)** | LangGraph conversational agent over the Invocations protocol with client-managed sessions. | -| **[Notetaking Agent](bring-your-own/invocations/notetaking-agent/)** | Note-taking agent with the Invocations protocol. | -| **[Toolbox](bring-your-own/invocations/toolbox/)** | BYO invocations agent wired to a Foundry Toolbox MCP endpoint. | -| **[AG-UI](bring-your-own/invocations/ag-ui/)** | Agent using the AG-UI streaming protocol via the Invocations endpoint. | -| **[GitHub Copilot](bring-your-own/invocations/github-copilot/)** | Agent that integrates with GitHub Copilot as the AI backbone. | -| **[Human-in-the-Loop](bring-your-own/invocations/human-in-the-loop/)** | Long-running agent that pauses for human approval before continuing. | +| Sample | What it shows | +| ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| **[Hello World](bring-your-own/invocations/hello-world/)** | Minimal agent β€” arbitrary JSON in, streaming SSE out. The simplest possible BYO invocations starting point. | +| **[LangGraph Chat](bring-your-own/invocations/langgraph-chat/)** | LangGraph conversational agent over the Invocations protocol with client-managed sessions. | +| **[Notetaking Agent](bring-your-own/invocations/notetaking-agent/)** | Note-taking agent with the Invocations protocol. | +| **[Toolbox](bring-your-own/invocations/toolbox/)** | BYO invocations agent wired to a Foundry Toolbox MCP endpoint. | +| **[AG-UI](bring-your-own/invocations/ag-ui/)** | Agent using the AG-UI streaming protocol via the Invocations endpoint. | +| **[GitHub Copilot](bring-your-own/invocations/github-copilot/)** | Agent that integrates with GitHub Copilot as the AI backbone. | +| **[Human-in-the-Loop](bring-your-own/invocations/human-in-the-loop/)** | Long-running agent that pauses for human approval before continuing. | +| **[Event Grid Trigger](bring-your-own/invocations/event-grid-trigger/)** | Event-driven agent: Azure Storage β†’ Event Grid β†’ hosted agent (direct delivery, authenticated by the system topic's system-assigned managed identity); agent summarizes the new blob and writes the summary to a sibling Storage container. | ## Deploy any sample @@ -160,11 +167,40 @@ azd down ### Other ways to invoke your agent -| Method | When to use | -|--------|------------| -| `azd ai agent invoke` | Quick CLI test after deploy | -| [VS Code Foundry extension](https://learn.microsoft.com/en-us/azure/foundry/agents/quickstarts/quickstart-hosted-agent?pivots=vscode) | One-click invoke from the editor | -| `curl` | Each sample README includes curl examples | +| Method | When to use | +| ------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | +| `azd ai agent invoke` | Quick CLI test after deploy | +| [VS Code Foundry extension](https://learn.microsoft.com/en-us/azure/foundry/agents/quickstarts/quickstart-hosted-agent?pivots=vscode) | One-click invoke from the editor | +| `curl` | Each sample README includes curl examples | + +## Voice Live integration + +For **Responses** protocol agents, once the agent is deployed to Microsoft Foundry, you can interact with it using real-time voice through the [Azure VoiceLive SDK](https://pypi.org/project/azure-ai-voicelive/). The shared Voice Live client sample, [voicelive_client.py](bring-your-own/voicelive/client/voicelive_client.py), demonstrates how to connect to your deployed agent and have a voice conversation. + +```bash +python voicelive_client.py \ + --endpoint "https://.services.ai.azure.com" \ + --agent-name "" \ + --project-name "" +``` + +The client authenticates using `DefaultAzureCredential` β€” make sure you are logged in (`az login`). + +For **Invocations** protocol agents, to make the agent work with Voice Live, the agent needs: + +- The agent can process voice live transcription input: `{"type": "input_audio.transcription", "input": "example voice input"}` +- The agent should output the text to be read as the following SSE, Voice Live will generate audio for the the `delta` text in the `output_audio_transcription.delta` event: + ``` + data: {"type": "output_audio_transcription.delta", "delta": "The weather "} + data: {"type": "output_audio_transcription.delta", "delta": "in Seattle "} + data: {"type": "output_audio_transcription.delta", "delta": "is 52Β°F "} + data: {"type": "output_audio_transcription.delta", "delta": "and partly cloudy."} + data: {"type": "output_audio_transcription.done", "text": "The weather in Seattle is 52Β°F and partly cloudy."} + data: {"type": "done"} + ``` +- The agent manifest must declare `voiceLiveCompatible: "true"` in the metadata section to indicate compatibility with Voice Live. + +Here is a hosted agent sample with Invocations protocol that is compatible with Voice Live: [hello-world-invocations-voicelive](bring-your-own/voicelive/hello-world-invocations-voicelive/). ## Prerequisites diff --git a/samples/python/hosted-agents/SUPPORTED_TOOLBOX_SCENARIOS.md b/samples/python/hosted-agents/SUPPORTED_TOOLBOX_SCENARIOS.md new file mode 100644 index 000000000..18aed3224 --- /dev/null +++ b/samples/python/hosted-agents/SUPPORTED_TOOLBOX_SCENARIOS.md @@ -0,0 +1,648 @@ +# Supported Toolbox Scenarios + +All hosted-agents toolbox samples can be configured for any of these 14 scenarios. For each scenario, create an `agent.manifest.yaml` file using the example provided below, then pass it to `azd ai agent init -m `. + +--- + +## 1. Web Search + +No connection or secrets required. The simplest toolbox scenario. + +**`agent.manifest.yaml`** + +```yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: toolbox-hosted-web-search +displayName: "Web Search Toolbox Agent" +description: > + Hosted agent with a Bing web search toolbox. The simplest toolbox + scenario β€” no connections or secrets required. +template: + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 +parameters: + properties: [] +resources: + - kind: model + id: gpt-4o + name: AZURE_AI_MODEL_DEPLOYMENT_NAME + - kind: toolbox + name: agent-tools + tools: + - type: web_search +``` + +--- + +## 2. File Search + +Requires a vector store in the same Foundry project. Prompted parameter: `file_search_vector_store_id`. + +**`agent.manifest.yaml`** + +```yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: toolbox-hosted-file-search +displayName: "File Search Toolbox Agent" +description: > + Hosted agent with a File Search toolbox backed by an Azure AI + Foundry vector store. The vector store must exist in the same project. +template: + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 +parameters: + properties: + - name: file_search_vector_store_id + secret: false + description: Vector store ID from the same Foundry project (e.g. vs_xxxxxxxxxxxx) +resources: + - kind: model + id: gpt-4o + name: AZURE_AI_MODEL_DEPLOYMENT_NAME + - kind: toolbox + name: agent-tools + tools: + - type: file_search + vector_store_ids: + - "{{ file_search_vector_store_id }}" +``` + +--- + +## 3. Code Interpreter + +No secrets required. Executes Python code in a sandboxed environment. + +**`agent.manifest.yaml`** + +```yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: toolbox-hosted-code-interpreter +displayName: "Code Interpreter Toolbox Agent" +description: > + Hosted agent with a Code Interpreter toolbox. Executes Python + code in a sandboxed environment via toolbox in Microsoft Foundry. +template: + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 +parameters: + properties: [] +resources: + - kind: model + id: gpt-4o + name: AZURE_AI_MODEL_DEPLOYMENT_NAME + - kind: toolbox + name: agent-tools + tools: + - type: code_interpreter +``` + +--- + +## 4. MCP Key-Auth (GitHub) + +Prompted parameter: `github_pat` (GitHub Personal Access Token, injected as Bearer token). + +**`agent.manifest.yaml`** + +```yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: toolbox-hosted-mcp-keyauth +displayName: "GitHub MCP Key-Auth Toolbox Agent" +description: > + Hosted agent with a GitHub MCP toolbox using key-based authentication + (GitHub PAT injected as Bearer token). +template: + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 +parameters: + properties: + - name: github_pat + secret: true + description: GitHub Personal Access Token (classic ghp_... or fine-grained github_pat_...) +resources: + - kind: model + id: gpt-4o + name: AZURE_AI_MODEL_DEPLOYMENT_NAME + - kind: connection + name: github-mcp-conn + category: RemoteTool + authType: CustomKeys + target: https://api.githubcopilot.com/mcp + credentials: + type: CustomKeys + keys: + Authorization: "Bearer {{ github_pat }}" + - kind: toolbox + name: agent-tools + tools: + - type: mcp + server_label: github + project_connection_id: github-mcp-conn +``` + +--- + +## 5. MCP No-Auth + +Prompted parameter: `mcp_endpoint` (URL of the public MCP server). No credentials needed. + +**`agent.manifest.yaml`** + +```yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: toolbox-hosted-mcp-noauth +displayName: "Public MCP No-Auth Toolbox Agent" +description: > + Hosted agent connected to a public MCP server that requires no + authentication. The server URL is proxied by toolbox in Microsoft Foundry. +template: + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 +parameters: + properties: + - name: mcp_endpoint + secret: false + description: URL of the public MCP server (e.g. https://gitmcp.io/Azure/azure-rest-api-specs) +resources: + - kind: model + id: gpt-4o + name: AZURE_AI_MODEL_DEPLOYMENT_NAME + - kind: toolbox + name: agent-tools + tools: + - type: mcp + server_label: noauthmcp + server_url: "{{ mcp_endpoint }}" +``` + +--- + +## 6. MCP OAuth (Managed Connector) + +No secrets required β€” Foundry manages the OAuth app registration. First invocation returns a consent URL (MCP code `-32006`). + +**`agent.manifest.yaml`** + +```yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: toolbox-hosted-mcp-oauth-managed +displayName: "MCP OAuth2 Managed Connector Toolbox Agent" +description: > + Hosted agent with a GitHub MCP toolbox using Microsoft Foundry's + managed OAuth connector. No client credentials needed β€” Foundry handles + the OAuth app registration. First invocation triggers a consent flow. +template: + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 +parameters: + properties: [] +resources: + - kind: model + id: gpt-4o + name: AZURE_AI_MODEL_DEPLOYMENT_NAME + - kind: connection + name: github-oauth-conn + category: RemoteTool + authType: OAuth2 + target: https://api.githubcopilot.com/mcp + connectorName: foundrygithubmcp + credentials: + type: OAuth2 + clientId: managed + clientSecret: managed + - kind: toolbox + name: agent-tools + tools: + - type: mcp + server_label: github + project_connection_id: github-oauth-conn +``` + +--- + +## 7. MCP OAuth (Custom App) + +Prompted parameters: `your_client_id`, `your_client_secret` (OAuth2 app registration). First invocation returns a consent URL (MCP code `-32006`). + +**`agent.manifest.yaml`** + +```yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: toolbox-hosted-mcp-oauth-custom +displayName: "MCP OAuth2 Custom App Registration Toolbox Agent" +description: > + Hosted agent with a GitHub MCP toolbox using a bring-your-own + OAuth2 app registration. First invocation triggers a consent flow. +template: + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 +parameters: + properties: + - name: your_client_id + secret: false + description: OAuth2 client ID from your app registration + - name: your_client_secret + secret: true + description: OAuth2 client secret from your app registration +resources: + - kind: model + id: gpt-4o + name: AZURE_AI_MODEL_DEPLOYMENT_NAME + - kind: connection + name: github-oauth-custom-conn + category: RemoteTool + authType: OAuth2 + target: https://api.githubcopilot.com/mcp + credentials: + type: OAuth2 + clientId: "{{ your_client_id }}" + clientSecret: "{{ your_client_secret }}" + authorizationUrl: "https://github.com/login/oauth/authorize" + tokenUrl: "https://github.com/login/oauth/access_token" + refreshUrl: "https://github.com/login/oauth/access_token" + scopes: + - repo + - read:user + - kind: toolbox + name: agent-tools + tools: + - type: mcp + server_label: github + project_connection_id: github-oauth-custom-conn +``` + +--- + +## 8. MCP Agent Identity + +Prompted parameters: `entra_audience`, `mcp_target_url`. Assign an RBAC role to the agent's managed identity on the target MCP server before deploying. + +**`agent.manifest.yaml`** + +```yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: toolbox-hosted-mcp-agent-identity +displayName: "MCP Agent Identity Toolbox Agent" +description: > + Hosted agent with an MCP toolbox using Microsoft Foundry's Agentic + Identity (agent managed identity) for Entra ID authentication to the MCP server. +template: + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 +parameters: + properties: + - name: entra_audience + secret: false + description: Entra ID audience for the target MCP server + - name: mcp_target_url + secret: false + description: URL of the MCP server that accepts agent identity tokens +resources: + - kind: model + id: gpt-4o + name: AZURE_AI_MODEL_DEPLOYMENT_NAME + - kind: connection + name: language-mcp + category: RemoteTool + authType: AgenticIdentity + audience: "{{ entra_audience }}" + target: "{{ mcp_target_url }}" + - kind: toolbox + name: agent-tools + tools: + - type: mcp + server_label: language-mcp + project_connection_id: language-mcp +``` + +--- + +## 9. Azure AI Search + +Prompted parameters: `ai_search_endpoint`, `ai_search_key`, `ai_search_index_name`. + +**`agent.manifest.yaml`** + +```yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: toolbox-hosted-ai-search +displayName: "Azure AI Search Toolbox Agent" +description: > + Hosted agent with an Azure AI Search toolbox. Queries an existing + search index via toolbox proxy in Microsoft Foundry. +template: + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 +parameters: + properties: + - name: ai_search_endpoint + secret: false + description: Azure AI Search service endpoint (e.g. https://my-search.search.windows.net/) + - name: ai_search_key + secret: true + description: Azure AI Search admin key + - name: ai_search_index_name + secret: false + description: Name of the search index to query +resources: + - kind: model + id: gpt-4o + name: AZURE_AI_MODEL_DEPLOYMENT_NAME + - kind: connection + name: aisearch-conn + category: CognitiveSearch + authType: ApiKey + target: "{{ ai_search_endpoint }}" + credentials: + type: ApiKey + key: "{{ ai_search_key }}" + - kind: toolbox + name: agent-tools + tools: + - type: azure_ai_search + index_name: "{{ ai_search_index_name }}" + project_connection_id: aisearch-conn +``` + +--- + +## 10. A2A (Agent-to-Agent) + +Prompted parameter: `a2a_agent_endpoint` (URL of the remote A2A-compatible agent). + +**`agent.manifest.yaml`** + +```yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: toolbox-hosted-a2a +displayName: "Agent-to-Agent (A2A) Toolbox Agent" +description: > + Hosted agent with an Agent-to-Agent (A2A) toolbox. Calls a remote + A2A-compatible agent endpoint via toolbox proxy in Microsoft Foundry. +template: + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 +parameters: + properties: + - name: a2a_agent_endpoint + secret: false + description: URL of the remote A2A-compatible agent endpoint (e.g. https://my-agent.azurecontainerapps.io) +resources: + - kind: model + id: gpt-4o + name: AZURE_AI_MODEL_DEPLOYMENT_NAME + - kind: connection + name: a2a-conn + category: RemoteA2A + authType: None + target: "{{ a2a_agent_endpoint }}" + - kind: toolbox + name: agent-tools + tools: + - type: a2a_preview + project_connection_id: a2a-conn +``` + +--- + +## 11. Bing Custom Search + +Prompted parameters: `bing_api_key`, `bing_resource_id`, `bing_custom_instance`. + +**`agent.manifest.yaml`** + +```yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: toolbox-hosted-bing-custom-search +displayName: "Bing Custom Search Toolbox Agent" +description: > + Hosted agent with a Bing Custom Search toolbox. Uses a + GroundingWithCustomSearch connection for scoped web search. +template: + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 +parameters: + properties: + - name: bing_api_key + secret: true + description: Bing Search API key + - name: bing_resource_id + secret: false + description: ARM resource ID of your Bing account + - name: bing_custom_instance + secret: false + description: Bing Custom Search instance name +resources: + - kind: model + id: gpt-4o + name: AZURE_AI_MODEL_DEPLOYMENT_NAME + - kind: connection + name: bing-custom-conn + category: GroundingWithCustomSearch + authType: ApiKey + target: https://api.bing.microsoft.com/ + credentials: + type: ApiKey + key: "{{ bing_api_key }}" + metadata: + ResourceId: "{{ bing_resource_id }}" + type: bing_custom_search_preview + - kind: toolbox + name: agent-tools + tools: + - type: web_search + custom_search_configuration: + instance_name: "{{ bing_custom_instance }}" + project_connection_id: bing-custom-conn +``` + +--- + +## 12. OpenAPI Key-Auth + +Prompted parameter: `tripadvisor_api_key`. Replace the spec and connection with your own OpenAPI service. + +**`agent.manifest.yaml`** + +```yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: toolbox-hosted-openapi-keyauth +displayName: "OpenAPI Key-Auth Toolbox Agent" +description: > + Hosted agent with an OpenAPI toolbox using key-based auth. + Uses TripAdvisor Content API as an example β€” replace the spec and + connection with your own OpenAPI service. +template: + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 +parameters: + properties: + - name: tripadvisor_api_key + secret: true + description: TripAdvisor Content API key (replace with your own OpenAPI service key) +resources: + - kind: model + id: gpt-4o + name: AZURE_AI_MODEL_DEPLOYMENT_NAME + - kind: connection + name: tripadvisor-conn + category: CustomKeys + authType: CustomKeys + target: https://api.content.tripadvisor.com + credentials: + type: CustomKeys + keys: + key: "{{ tripadvisor_api_key }}" + - kind: toolbox + name: agent-tools + tools: + - type: openapi + openapi: + name: tripadvisor + spec: + openapi: "3.0.1" + info: + title: "TripAdvisor API" + version: "1.0" + servers: + - url: https://api.content.tripadvisor.com/api/v1 + paths: + /location/search: + get: + operationId: searchLocations + parameters: + - name: searchQuery + in: query + required: true + schema: + type: string + - name: key + in: query + required: true + schema: + type: string + responses: + "200": + description: OK + auth: + type: connection_auth + connection_id: tripadvisor-conn +``` + +--- + +## 13. MCP OAuth (Entra Passthrough) + +Prompted parameters: `entra_audience`, `entra_mcp_target`. Foundry proxies the caller's Entra identity to the downstream MCP server. + +**`agent.manifest.yaml`** + +```yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: toolbox-hosted-mcp-entra-passthrough +displayName: "MCP Entra Token Passthrough Toolbox Agent" +description: > + Hosted agent with an MCP toolbox that uses Entra token passthrough. + Microsoft Foundry proxies the caller's Entra identity to the downstream MCP server. +template: + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 +parameters: + properties: + - name: entra_audience + secret: false + description: Entra ID audience for the target MCP server + - name: entra_mcp_target + secret: false + description: URL of the MCP server that accepts Entra user tokens +resources: + - kind: model + id: gpt-4o + name: AZURE_AI_MODEL_DEPLOYMENT_NAME + - kind: connection + name: entra-passthrough-conn + category: RemoteTool + authType: UserEntraToken + audience: "{{ entra_audience }}" + target: "{{ entra_mcp_target }}" + - kind: toolbox + name: agent-tools + tools: + - type: mcp + server_label: outlook-mail + project_connection_id: entra-passthrough-conn +``` + +--- + +## 14. Multi-Tool Toolbox + +Prompted parameter: `github_pat`. Combines Bing web search and GitHub MCP in one toolbox. + +**`agent.manifest.yaml`** + +```yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: toolbox-hosted-multi-tool +displayName: "Multi-Tool Toolbox Agent (Web Search + GitHub MCP)" +description: > + Hosted agent with a combined toolbox: Bing web search plus GitHub + MCP tools via key-based auth. Demonstrates multiple tool types in one toolbox. +template: + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 +parameters: + properties: + - name: github_pat + secret: true + description: GitHub Personal Access Token (classic ghp_... or fine-grained github_pat_...) +resources: + - kind: model + id: gpt-4o + name: AZURE_AI_MODEL_DEPLOYMENT_NAME + - kind: connection + name: github-mcp-conn + category: RemoteTool + authType: CustomKeys + target: https://api.githubcopilot.com/mcp + credentials: + type: CustomKeys + keys: + Authorization: "Bearer {{ github_pat }}" + - kind: toolbox + name: agent-tools + tools: + - type: web_search + - type: mcp + server_label: github + project_connection_id: github-mcp-conn +``` diff --git a/samples/python/hosted-agents/agent-framework/README.md b/samples/python/hosted-agents/agent-framework/README.md index 4f2a701b9..2d0a6a4fa 100644 --- a/samples/python/hosted-agents/agent-framework/README.md +++ b/samples/python/hosted-agents/agent-framework/README.md @@ -6,19 +6,24 @@ This directory contains samples that demonstrate how to use the [Agent Framework ### Responses API -| # | Sample | Description | -|---|--------|-------------| -| 1 | [Basic](responses/01-basic/) | A minimal agent demonstrating basic request/response interaction and multi-turn conversations using `previous_response_id`. | -| 2 | [Tools](responses/02-tools/) | An agent with local tools (e.g., weather lookup), demonstrating how to register and invoke custom tool functions alongside the LLM. | -| 3 | [MCP](responses/03-mcp/) | An agent connected to a remote MCP server (GitHub), demonstrating external MCP tool provider integration. | -| 4 | [Foundry Toolbox](responses/04-foundry-toolbox/) | An agent using Azure Foundry Toolbox, demonstrating toolbox provisioning and querying available tools at runtime. | -| 5 | [Workflows](responses/05-workflows/) | An agent with a multi-step orchestrated workflow, demonstrating chaining prompts through an orchestrated flow. | +| # | Sample | Description | +| --- | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | [Basic](responses/01-basic/) | A minimal agent demonstrating basic request/response interaction and multi-turn conversations using `previous_response_id`. | +| 2 | [Tools](responses/02-tools/) | An agent with local tools (e.g., weather lookup), demonstrating how to register and invoke custom tool functions alongside the LLM. | +| 3 | [MCP](responses/03-mcp/) | An agent connected to a remote MCP server (GitHub), demonstrating external MCP tool provider integration. | +| 4 | [Foundry Toolbox](responses/04-foundry-toolbox/) | An agent using Azure Foundry Toolbox, demonstrating toolbox provisioning and querying available tools at runtime. | +| 5 | [Workflows](responses/05-workflows/) | An agent with a multi-step orchestrated workflow, demonstrating chaining prompts through an orchestrated flow. | +| 6 | [Files](responses/06-files/) | An agent capable of handling files uploaded by users. | +| 7 | [Skills](responses/07-skills/) | An agent using native Agent Framework file-based skills, demonstrating skill discovery and a script-backed PDF travel guide skill. | +| 8 | [Observability](responses/08-observability/) | An agent demonstrating observability features, including logging, metrics, and tracing. | +| 9 | [Declarative Customer Support](responses/09-declarative-customer-support/) | A multi-turn customer-support triage workflow defined entirely in YAML and hosted as an agent, demonstrating declarative workflow authoring with `InvokeAzureAgent` calls to specialist Foundry-hosted agents and conversation-aware routing. | +| 10 | [Downstream Azure services](responses/09-downstream-azure/) | An agent that performs data-plane operations on Azure Blob Storage and Service Bus using its per-agent Microsoft Entra identity, demonstrating the per-agent identity + Azure RBAC pattern with no connection strings or shared keys. | ### Invocations API -| # | Sample | Description | -|---|--------|-------------| -| 1 | [Basic](invocations/01-basic/) | A minimal agent demonstrating session state management via `agent_session_id` in URL params/response headers. | +| # | Sample | Description | +| --- | ------------------------------ | ------------------------------------------------------------------------------------------------------------- | +| 1 | [Basic](invocations/01-basic/) | A minimal agent demonstrating session state management via `agent_session_id` in URL params/response headers. | ## Running the Agent Host Locally @@ -27,9 +32,8 @@ This directory contains samples that demonstrate how to use the [Agent Framework #### Prerequisites 1. **Azure Developer CLI (`azd`)** - - - [Install azd](https://learn.microsoft.com/en-us/azure/developer/azure-developer-cli/install-azd) and the AI agent extension: `azd ext install azure.ai.agents` - - Authenticated: `azd auth login` + - [Install azd](https://learn.microsoft.com/en-us/azure/developer/azure-developer-cli/install-azd) and the AI agent extension: `azd ext install azure.ai.agents` + - Authenticated: `azd auth login` 2. **Azure Subscription** diff --git a/samples/python/hosted-agents/agent-framework/invocations/01-basic/.dockerignore b/samples/python/hosted-agents/agent-framework/invocations/01-basic/.dockerignore index 008e6e661..0d6619bae 100644 --- a/samples/python/hosted-agents/agent-framework/invocations/01-basic/.dockerignore +++ b/samples/python/hosted-agents/agent-framework/invocations/01-basic/.dockerignore @@ -3,4 +3,5 @@ __pycache__ *.pyc *.pyo *.pyd -.Python \ No newline at end of file +.Python +.env \ No newline at end of file diff --git a/samples/python/hosted-agents/agent-framework/invocations/01-basic/agent.yaml b/samples/python/hosted-agents/agent-framework/invocations/01-basic/agent.yaml index 152179a8e..68a73d34a 100644 --- a/samples/python/hosted-agents/agent-framework/invocations/01-basic/agent.yaml +++ b/samples/python/hosted-agents/agent-framework/invocations/01-basic/agent.yaml @@ -7,3 +7,6 @@ protocols: resources: cpu: '0.25' memory: '0.5Gi' +environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} \ No newline at end of file diff --git a/samples/python/hosted-agents/agent-framework/invocations/01-basic/requirements.txt b/samples/python/hosted-agents/agent-framework/invocations/01-basic/requirements.txt index ca7773b3c..f7dc62f3e 100644 --- a/samples/python/hosted-agents/agent-framework/invocations/01-basic/requirements.txt +++ b/samples/python/hosted-agents/agent-framework/invocations/01-basic/requirements.txt @@ -1,2 +1,2 @@ -agent-framework>=1.1.1 +agent-framework agent-framework-foundry-hosting \ No newline at end of file diff --git a/samples/python/hosted-agents/agent-framework/invocations/01-basic/test-payload.txt b/samples/python/hosted-agents/agent-framework/invocations/01-basic/test-payload.txt deleted file mode 100644 index d2e676094..000000000 --- a/samples/python/hosted-agents/agent-framework/invocations/01-basic/test-payload.txt +++ /dev/null @@ -1 +0,0 @@ -{"message":"Hi!"} diff --git a/samples/python/hosted-agents/agent-framework/responses/01-basic/.dockerignore b/samples/python/hosted-agents/agent-framework/responses/01-basic/.dockerignore index 008e6e661..0d6619bae 100644 --- a/samples/python/hosted-agents/agent-framework/responses/01-basic/.dockerignore +++ b/samples/python/hosted-agents/agent-framework/responses/01-basic/.dockerignore @@ -3,4 +3,5 @@ __pycache__ *.pyc *.pyo *.pyd -.Python \ No newline at end of file +.Python +.env \ No newline at end of file diff --git a/samples/python/hosted-agents/agent-framework/responses/01-basic/agent.yaml b/samples/python/hosted-agents/agent-framework/responses/01-basic/agent.yaml index 6f506d1f7..bf652ba1f 100644 --- a/samples/python/hosted-agents/agent-framework/responses/01-basic/agent.yaml +++ b/samples/python/hosted-agents/agent-framework/responses/01-basic/agent.yaml @@ -7,3 +7,6 @@ protocols: resources: cpu: '0.25' memory: '0.5Gi' +environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} diff --git a/samples/python/hosted-agents/agent-framework/responses/01-basic/requirements.txt b/samples/python/hosted-agents/agent-framework/responses/01-basic/requirements.txt index ca7773b3c..a50487247 100644 --- a/samples/python/hosted-agents/agent-framework/responses/01-basic/requirements.txt +++ b/samples/python/hosted-agents/agent-framework/responses/01-basic/requirements.txt @@ -1,2 +1,2 @@ -agent-framework>=1.1.1 +agent-framework>=1.2.2 agent-framework-foundry-hosting \ No newline at end of file diff --git a/samples/python/hosted-agents/agent-framework/responses/02-tools/.dockerignore b/samples/python/hosted-agents/agent-framework/responses/02-tools/.dockerignore index 008e6e661..0d6619bae 100644 --- a/samples/python/hosted-agents/agent-framework/responses/02-tools/.dockerignore +++ b/samples/python/hosted-agents/agent-framework/responses/02-tools/.dockerignore @@ -3,4 +3,5 @@ __pycache__ *.pyc *.pyo *.pyd -.Python \ No newline at end of file +.Python +.env \ No newline at end of file diff --git a/samples/python/hosted-agents/agent-framework/responses/02-tools/agent.yaml b/samples/python/hosted-agents/agent-framework/responses/02-tools/agent.yaml index 70c3fea75..597ad3b98 100644 --- a/samples/python/hosted-agents/agent-framework/responses/02-tools/agent.yaml +++ b/samples/python/hosted-agents/agent-framework/responses/02-tools/agent.yaml @@ -6,3 +6,6 @@ protocols: resources: cpu: "0.25" memory: 0.5Gi +environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} diff --git a/samples/python/hosted-agents/agent-framework/responses/02-tools/requirements.txt b/samples/python/hosted-agents/agent-framework/responses/02-tools/requirements.txt index ca7773b3c..a50487247 100644 --- a/samples/python/hosted-agents/agent-framework/responses/02-tools/requirements.txt +++ b/samples/python/hosted-agents/agent-framework/responses/02-tools/requirements.txt @@ -1,2 +1,2 @@ -agent-framework>=1.1.1 +agent-framework>=1.2.2 agent-framework-foundry-hosting \ No newline at end of file diff --git a/samples/python/hosted-agents/agent-framework/responses/03-mcp/.dockerignore b/samples/python/hosted-agents/agent-framework/responses/03-mcp/.dockerignore index 008e6e661..0d6619bae 100644 --- a/samples/python/hosted-agents/agent-framework/responses/03-mcp/.dockerignore +++ b/samples/python/hosted-agents/agent-framework/responses/03-mcp/.dockerignore @@ -3,4 +3,5 @@ __pycache__ *.pyc *.pyo *.pyd -.Python \ No newline at end of file +.Python +.env \ No newline at end of file diff --git a/samples/python/hosted-agents/agent-framework/responses/03-mcp/agent.yaml b/samples/python/hosted-agents/agent-framework/responses/03-mcp/agent.yaml index a1edfa8c7..e222b6f5f 100644 --- a/samples/python/hosted-agents/agent-framework/responses/03-mcp/agent.yaml +++ b/samples/python/hosted-agents/agent-framework/responses/03-mcp/agent.yaml @@ -7,5 +7,7 @@ resources: cpu: "0.25" memory: 0.5Gi environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" - name: GITHUB_PAT - value: ${GITHUB_PAT} \ No newline at end of file + value: ${GITHUB_PAT} diff --git a/samples/python/hosted-agents/agent-framework/responses/03-mcp/requirements.txt b/samples/python/hosted-agents/agent-framework/responses/03-mcp/requirements.txt index ff048e9ed..f67947c86 100644 --- a/samples/python/hosted-agents/agent-framework/responses/03-mcp/requirements.txt +++ b/samples/python/hosted-agents/agent-framework/responses/03-mcp/requirements.txt @@ -1,3 +1,3 @@ -agent-framework>=1.1.1 +agent-framework>=1.2.2 agent-framework-foundry-hosting mcp>=1.24.0,<2 \ No newline at end of file diff --git a/samples/python/hosted-agents/agent-framework/responses/04-foundry-toolbox/.dockerignore b/samples/python/hosted-agents/agent-framework/responses/04-foundry-toolbox/.dockerignore index 008e6e661..0d6619bae 100644 --- a/samples/python/hosted-agents/agent-framework/responses/04-foundry-toolbox/.dockerignore +++ b/samples/python/hosted-agents/agent-framework/responses/04-foundry-toolbox/.dockerignore @@ -3,4 +3,5 @@ __pycache__ *.pyc *.pyo *.pyd -.Python \ No newline at end of file +.Python +.env \ No newline at end of file diff --git a/samples/python/hosted-agents/agent-framework/responses/04-foundry-toolbox/README.md b/samples/python/hosted-agents/agent-framework/responses/04-foundry-toolbox/README.md index d3358cdc0..2b7fa5263 100644 --- a/samples/python/hosted-agents/agent-framework/responses/04-foundry-toolbox/README.md +++ b/samples/python/hosted-agents/agent-framework/responses/04-foundry-toolbox/README.md @@ -14,9 +14,7 @@ You can also create a Foundry Toolbox in the Foundry portal. Read more about it ### Model Integration -The agent uses `FoundryChatClient` from the Agent Framework to create an OpenAI-compatible Responses client. It loads a named Foundry Toolbox via `client.get_toolbox(name)` β€” the toolbox is a server-side bundle of tool configurations (e.g., `code_interpreter`, `web_search`) defined in the Foundry portal or by `azd provision`. Omitting `version` resolves the toolbox's current default version at runtime. - -The sample then narrows the toolbox to a subset of tool types via `select_toolbox_tools(toolbox, include_types=[...])` before handing it to the agent. This demonstrates how one toolbox can be reused across agents that each expose only the tools they need β€” here, the agent only sees `code_interpreter` even though the toolbox also includes `web_search`. +The agent uses `FoundryChatClient` from the Agent Framework to create an OpenAI-compatible Responses client. It connects to the toolbox's MCP endpoint via `MCPStreamableHTTPTool`, which discovers and invokes the toolbox's tools over MCP at runtime. See [main.py](main.py) for the full implementation. @@ -41,3 +39,33 @@ curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" ## Deploying the Agent to Foundry To host the agent on Foundry, follow the instructions in the [Deploying the Agent to Foundry](../../README.md#deploying-the-agent-to-foundry) section of the README in the parent directory. + +--- + +## Supported Scenarios + +The sample toolbox can be configured for any of these 14 scenarios. For each scenario, create a `agent.manifest.yaml` file (see examples below) and pass it to `azd ai agent init -m `. + +
+View all 14 supported scenarios + +Refer to [`samples/python/toolbox/azd/README.md`](../../../../toolbox/azd/README.md#supported-scenarios) for complete inline documentation of all scenarios including: + +1. **Web Search** β€” Bing web search (no auth required) +2. **File Search** β€” Vector store RAG search +3. **Code Interpreter** β€” Python code execution +4. **MCP Key-Auth (GitHub)** β€” GitHub MCP with PAT +5. **MCP No-Auth** β€” Public MCP servers +6. **MCP OAuth (Managed)** β€” Foundry-managed OAuth +7. **MCP OAuth (Custom)** β€” Bring-your-own OAuth app +8. **MCP Agent Identity** β€” Entra ID agent identity +9. **Azure AI Search** β€” Search index queries +10. **A2A (Agent-to-Agent)** β€” Remote agent delegation +11. **Bing Custom Search** β€” Scoped web search +12. **OpenAPI Key-Auth** β€” REST API integration +13. **MCP OAuth (Entra Passthrough)** β€” User identity delegation +14. **Multi-Tool Toolbox** β€” Web search + GitHub MCP combined + +Each scenario includes a complete `agent.manifest.yaml` example with parameter definitions and resource configurations. + +
diff --git a/samples/python/hosted-agents/agent-framework/responses/04-foundry-toolbox/agent.manifest.yaml b/samples/python/hosted-agents/agent-framework/responses/04-foundry-toolbox/agent.manifest.yaml index 8dd0cf9c9..8a09efcad 100644 --- a/samples/python/hosted-agents/agent-framework/responses/04-foundry-toolbox/agent.manifest.yaml +++ b/samples/python/hosted-agents/agent-framework/responses/04-foundry-toolbox/agent.manifest.yaml @@ -21,10 +21,12 @@ template: value: "agent-tools" resources: - kind: model - id: gpt-4.1-mini + id: gpt-4.1 name: AZURE_AI_MODEL_DEPLOYMENT_NAME - kind: toolbox name: agent-tools tools: - type: web_search name: web_search + - type: code_interpreter + name: code_interpreter diff --git a/samples/python/hosted-agents/agent-framework/responses/04-foundry-toolbox/agent.yaml b/samples/python/hosted-agents/agent-framework/responses/04-foundry-toolbox/agent.yaml index f4a3bd9a8..826ab3919 100644 --- a/samples/python/hosted-agents/agent-framework/responses/04-foundry-toolbox/agent.yaml +++ b/samples/python/hosted-agents/agent-framework/responses/04-foundry-toolbox/agent.yaml @@ -5,4 +5,9 @@ protocols: version: 1.0.0 resources: cpu: "0.25" - memory: 0.5Gi \ No newline at end of file + memory: 0.5Gi +environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} + - name: TOOLBOX_NAME + value: "agent-tools" \ No newline at end of file diff --git a/samples/python/hosted-agents/agent-framework/responses/04-foundry-toolbox/main.py b/samples/python/hosted-agents/agent-framework/responses/04-foundry-toolbox/main.py index 6b82811c6..b45c7819d 100644 --- a/samples/python/hosted-agents/agent-framework/responses/04-foundry-toolbox/main.py +++ b/samples/python/hosted-agents/agent-framework/responses/04-foundry-toolbox/main.py @@ -1,42 +1,119 @@ # Copyright (c) Microsoft. All rights reserved. -import asyncio +import httpx +import logging import os -from agent_framework import Agent +from agent_framework import Agent, MCPStreamableHTTPTool from agent_framework.foundry import FoundryChatClient from agent_framework_foundry_hosting import ResponsesHostServer -from azure.identity import DefaultAzureCredential +from azure.identity import DefaultAzureCredential, get_bearer_token_provider from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() +logger = logging.getLogger(__name__) + + +class _ResilientResponsesHostServer(ResponsesHostServer): + """Workaround for an alpha bug in `agent_framework_foundry_hosting`. + + The built-in `_handle_inner_agent` calls `await context.get_history()` + unconditionally on every request. When the platform issues the request + with `store=true` + a real `conversation.id` (as the foundry-extension + deploy path does), the history fetch can raise inside the SDK, which + bubbles up as a platform-level `server_error: An internal server error + occurred` with no usable diagnostic. + + Until the SDK is patched upstream, we defensively wrap `get_history` on + the inbound context so a transient failure degrades to "no prior turns" + instead of failing the whole request. + """ + + async def _handle_inner_agent(self, request, context): # type: ignore[override] + original_get_history = context.get_history + + async def safe_get_history(): + try: + return await original_get_history() + except Exception as ex: # noqa: BLE001 - intentional broad catch + logger.warning( + "context.get_history() failed (%s); proceeding with no prior history.", + ex, + ) + return [] + + # Replace the bound method on the instance for the duration of this request. + context.get_history = safe_get_history # type: ignore[method-assign] + async for item in super()._handle_inner_agent(request, context): + yield item + +def resolve_toolbox_endpoint() -> str: + """Resolve the toolbox MCP endpoint URL.""" + project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"].rstrip("/") + toolbox_name = os.environ["TOOLBOX_NAME"] + return f"{project_endpoint}/toolboxes/{toolbox_name}/mcp?api-version=v1" + +class ToolboxAuth(httpx.Auth): + """Injects a fresh bearer token on every request.""" + def __init__(self, token_provider): + self._get_token = token_provider + def auth_flow(self, request): + request.headers["Authorization"] = f"Bearer {self._get_token()}" + yield request + + +def main(): + # NOTE: This sample mirrors the sync `main()` + `server.run()` pattern of + # the sister 03-mcp sample (which passes on the foundry-ext deploy path). + # The previous async/`async with Agent(...)` pattern eagerly entered the + # MCPStreamableHTTPTool context at startup, which performs a network + # initialize + tools/list against the toolbox MCP endpoint before the + # HTTP server is bound. On the foundry-ext deploy path the platform + # probes /readiness within ~90s of container start; if the MCP handshake + # is still in flight, /readiness never returns 200 and the platform + # raises 424 session_not_ready on every invoke. Letting the Agent enter + # the tool context lazily on first request avoids the readiness race. + credential = DefaultAzureCredential() + + token_provider = get_bearer_token_provider( + credential, "https://ai.azure.com/.default" + ) + + http_client = httpx.AsyncClient( + auth=ToolboxAuth(token_provider), + headers={"Foundry-Features": "Toolboxes=V1Preview"}, + timeout=120.0, + ) + + toolbox = MCPStreamableHTTPTool( + name=os.environ["TOOLBOX_NAME"], + url=resolve_toolbox_endpoint(), + http_client=http_client, + load_prompts=False, + ) -async def main(): client = FoundryChatClient( project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=DefaultAzureCredential(), + credential=credential, ) - # Load the named toolbox from the Foundry project. Omitting `version` - # resolves the toolbox's current default version at runtime. - toolbox = await client.get_toolbox(os.environ["TOOLBOX_NAME"]) - agent = Agent( client=client, instructions="You are a friendly assistant. Keep your answers brief.", tools=toolbox, - # History will be managed by the hosting infrastructure, thus there - # is no need to store history by the service. Learn more at: + # History is managed by the hosting infrastructure; we don't need + # the service to store it. See: # https://developers.openai.com/api/reference/resources/responses/methods/create default_options={"store": False}, ) - server = ResponsesHostServer(agent) - await server.run_async() + server = _ResilientResponsesHostServer(agent) + server.run() if __name__ == "__main__": - asyncio.run(main()) + main() + diff --git a/samples/python/hosted-agents/agent-framework/responses/04-foundry-toolbox/requirements.txt b/samples/python/hosted-agents/agent-framework/responses/04-foundry-toolbox/requirements.txt index d8b8592e2..1bcb3b467 100644 --- a/samples/python/hosted-agents/agent-framework/responses/04-foundry-toolbox/requirements.txt +++ b/samples/python/hosted-agents/agent-framework/responses/04-foundry-toolbox/requirements.txt @@ -1,2 +1,7 @@ -agent-framework>=1.1.1 +# `agent-framework[foundry]` is required because main.py imports +# `from agent_framework.foundry import FoundryChatClient`. In 1.3+ the +# foundry submodule is an optional extra; installing the bare package +# leaves agent_framework.foundry unimportable and the container crashes +# at startup so /readiness never returns 200 -> 424 session_not_ready. +agent-framework[foundry]>=1.2.2 agent-framework-foundry-hosting diff --git a/samples/python/hosted-agents/agent-framework/responses/05-workflows/.dockerignore b/samples/python/hosted-agents/agent-framework/responses/05-workflows/.dockerignore index 008e6e661..0d6619bae 100644 --- a/samples/python/hosted-agents/agent-framework/responses/05-workflows/.dockerignore +++ b/samples/python/hosted-agents/agent-framework/responses/05-workflows/.dockerignore @@ -3,4 +3,5 @@ __pycache__ *.pyc *.pyo *.pyd -.Python \ No newline at end of file +.Python +.env \ No newline at end of file diff --git a/samples/python/hosted-agents/agent-framework/responses/05-workflows/agent.yaml b/samples/python/hosted-agents/agent-framework/responses/05-workflows/agent.yaml index a58893ddf..0dd24a999 100644 --- a/samples/python/hosted-agents/agent-framework/responses/05-workflows/agent.yaml +++ b/samples/python/hosted-agents/agent-framework/responses/05-workflows/agent.yaml @@ -5,4 +5,7 @@ protocols: version: 1.0.0 resources: cpu: "0.25" - memory: 0.5Gi \ No newline at end of file + memory: 0.5Gi +environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} \ No newline at end of file diff --git a/samples/python/hosted-agents/agent-framework/responses/05-workflows/requirements.txt b/samples/python/hosted-agents/agent-framework/responses/05-workflows/requirements.txt index ca7773b3c..a50487247 100644 --- a/samples/python/hosted-agents/agent-framework/responses/05-workflows/requirements.txt +++ b/samples/python/hosted-agents/agent-framework/responses/05-workflows/requirements.txt @@ -1,2 +1,2 @@ -agent-framework>=1.1.1 +agent-framework>=1.2.2 agent-framework-foundry-hosting \ No newline at end of file diff --git a/samples/python/hosted-agents/agent-framework/responses/06-files/.dockerignore b/samples/python/hosted-agents/agent-framework/responses/06-files/.dockerignore new file mode 100644 index 000000000..41c70d326 --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/06-files/.dockerignore @@ -0,0 +1,10 @@ +.venv +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +.env + +# Local-only client tooling and sample data; not needed inside the agent image. +resources/ diff --git a/samples/python/hosted-agents/agent-framework/responses/06-files/.env.example b/samples/python/hosted-agents/agent-framework/responses/06-files/.env.example new file mode 100644 index 000000000..91ae96ac4 --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/06-files/.env.example @@ -0,0 +1,3 @@ +FOUNDRY_PROJECT_ENDPOINT="..." +AZURE_AI_MODEL_DEPLOYMENT_NAME="..." +TOOLBOX_NAME="..." \ No newline at end of file diff --git a/samples/python/hosted-agents/agent-framework/responses/06-files/Dockerfile b/samples/python/hosted-agents/agent-framework/responses/06-files/Dockerfile new file mode 100644 index 000000000..eaffb94f1 --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/06-files/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY . user_agent/ +WORKDIR /app/user_agent + +RUN if [ -f requirements.txt ]; then \ + pip install -r requirements.txt; \ + else \ + echo "No requirements.txt found"; \ + fi + +EXPOSE 8088 + +CMD ["python", "main.py"] \ No newline at end of file diff --git a/samples/python/hosted-agents/agent-framework/responses/06-files/README.md b/samples/python/hosted-agents/agent-framework/responses/06-files/README.md new file mode 100644 index 000000000..9e10f2f9c --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/06-files/README.md @@ -0,0 +1,115 @@ +# What this sample demonstrates + +An [Agent Framework](https://github.com/microsoft/agent-framework) agent that uses a local shell tool and a code interpreter tool for working with files, and hosted using the **Responses protocol**. + +## How It Works + +### Model Integration + +The agent uses `FoundryChatClient` from the Agent Framework to create a Responses client from the project endpoint and model deployment. The agent supports both streaming (SSE events) and non-streaming (JSON) response modes. + +See [main.py](main.py) for the full implementation. + +### Agent Hosting + +The agent is hosted using the [Agent Framework](https://github.com/microsoft/agent-framework) with the `ResponsesHostServer`, which provisions a REST API endpoint compatible with the OpenAI Responses protocol. + +### Tools + +This agent uses four tools: + +1. **Get Current Working Directory Tool (`get_cwd`)** – Returns the current working directory of the agent host process. +2. **List Files Tool (`list_files`)** – Lists the files in a specified directory. +3. **Read File Tool (`read_file`)** – Reads the contents of a specified file. +4. **Code Interpreter Tool (`code_interpreter`)** – Allows the agent to execute Python code in a safe. + +> In this sample, the filesystem tools are function tools defined in Python using the `@tool` decorator from the Agent Framework. The code interpreter tool is a managed tool provided by [Foundry Toolbox](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/tools/toolbox). Learn more about foundry toolbox integration with hosted agents with this [sample](../04_foundry_toolbox/). + +## Running the Agent Host + +Follow the instructions in the [Running the Agent Host Locally](../../README.md#running-the-agent-host-locally) section of the README in the parent directory to run the agent host. + +An extra environment variable `TOOLBOX_NAME` must be set to the name of the Foundry Toolbox that the agent should load at runtime. This allows the agent host to dynamically retrieve the correct toolbox from Foundry when it starts. Run the following: + +```bash +export TOOLBOX_NAME="" +``` + +Or in PowerShell: + +```powershell +$env:TOOLBOX_NAME="" +``` + +## Interacting with the agent + +> Depending on how you run the agent host, you can invoke the agent using `curl` (`Invoke-WebRequest` in PowerShell) or `azd`. Please refer to the [parent README](../../README.md) for more details. Use this README for sample queries you can send to the agent. + +Send a POST request to the server with a JSON body containing an `"input"` field to interact with the agent. For example: + +```bash +curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "Find the quarterly report under `{cwd}/resources` and tell me the difference of revenue between q1 2026 and q1 2025?"}' +``` + +> When ruuning locally, it runs within the project directory, which contains the entire sample, so the `{cwd}/resources` path in the query above will allow the agent to locate the `resources` folder included with this sample and read the `contoso_q1_2026_report.txt` file from that folder. + +The server will respond with a JSON object containing the response text and a response ID. You can use this response ID to continue the conversation in subsequent requests. + +## Deploying the Agent to Foundry + +To host the agent on Foundry, follow the instructions in the [Deploying the Agent to Foundry](../../README.md#deploying-the-agent-to-foundry) section of the README in the parent directory. + +## Uploading a file to a session + +Deploying the agent won't automatically upload the files included with this sample to Foundry. To make these files available to the agent at runtime, you must upload them to a [hosted agent session](https://learn.microsoft.com/azure/foundry/agents/how-to/manage-hosted-sessions). Files are tied to a specific hosted agent session, so each time you start a new session you will need to upload the files again if the agent needs access to them during that session. + +After you deploy the agent to Foundry, you have two ways to interact with the agent: + +1. Using `azd ai agent invoke`. +2. Through the Foundry portal. + +### Using `azd ai agent invoke` + +After successfully deploying the agent to Foundry, run the following command: + +> You must remain in the directory where your `azd` project is initialized so that the CLI can locate the deployed agent configuration. + +```bash +azd ai agent invoke "Hi!" +``` + +The command will invoke the agent and the server will create a new session if one does not already exist for this interaction, returning the agent's response from the hosted agent session. Run the following if you want to force a new session: + +```bash +azd ai agent invoke --new-session "Hi!" +``` + +Run the following command to upload a file to the hosted agent session: + +```bash +azd ai agent files upload -f +``` + +> The above command will automatically detect the last active session and upload the file to that session without requiring you to explicitly provide a session ID. It is also possible to specify a particular session ID to upload the file to a specific hosted agent session by using the `--session-id` flag. Run `azd ai agent files upload -h` to see the full list of options and flags available for the `upload` command. + +Once the file is uploaded to the hosted agent session, the agent will be able to access it during that session and use it to respond to queries that reference the uploaded file. + +Invoke the agent again with a query that references the uploaded file to see how it can now use the file in its responses. For example: + +```bash +azd ai agent invoke "Find the quarterly report under the home directory and tell me the difference of revenue between q1 2026 and q1 2025?" +``` + +### Using the Foundry Portal + +Similar to using the `azd` CLI, you must invoke the agent first to create a session: + +![alt text](./resources/start-a-session.png) + +Once the session is created, you can grab the session ID and use `azd ai agent files upload --session-id ` to upload files to that specific hosted agent session. + +![alt text](./resources/session-started.png) + +Or you can upload files directly through the Foundry portal by navigating to Files tab in the agent playground: + +![alt text](./resources/file-upload-portal.png) diff --git a/samples/python/hosted-agents/agent-framework/responses/06-files/agent.manifest.yaml b/samples/python/hosted-agents/agent-framework/responses/06-files/agent.manifest.yaml new file mode 100644 index 000000000..9fde49334 --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/06-files/agent.manifest.yaml @@ -0,0 +1,32 @@ +name: agent-framework-agent-files-responses +description: > + An Agent Framework agent that can work with files hosted by Foundry. +metadata: + tags: + - Agent Framework + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Streaming +template: + name: agent-framework-agent-files-responses + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 + environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" + - name: TOOLBOX_NAME + value: "agent-tools" +resources: + - kind: model + id: gpt-4.1-mini + name: AZURE_AI_MODEL_DEPLOYMENT_NAME + - kind: toolbox + name: agent-tools + tools: + - type: web_search + name: web_search + - type: code_interpreter + name: code_interpreter \ No newline at end of file diff --git a/samples/python/hosted-agents/agent-framework/responses/06-files/agent.yaml b/samples/python/hosted-agents/agent-framework/responses/06-files/agent.yaml new file mode 100644 index 000000000..5a62d90ff --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/06-files/agent.yaml @@ -0,0 +1,14 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +kind: hosted +name: agent-framework-agent-files-responses +protocols: + - protocol: responses + version: 1.0.0 +resources: + cpu: '0.25' + memory: '0.5Gi' +environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} + - name: TOOLBOX_NAME + value: "agent-tools" \ No newline at end of file diff --git a/samples/python/hosted-agents/agent-framework/responses/06-files/main.py b/samples/python/hosted-agents/agent-framework/responses/06-files/main.py new file mode 100644 index 000000000..0913208b9 --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/06-files/main.py @@ -0,0 +1,105 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import httpx +import os + +from agent_framework import Agent, MCPStreamableHTTPTool, tool +from agent_framework.foundry import FoundryChatClient +from agent_framework_foundry_hosting import ResponsesHostServer +from azure.identity import DefaultAzureCredential, get_bearer_token_provider +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + +def resolve_toolbox_endpoint() -> str: + """Resolve the toolbox MCP endpoint URL.""" + project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"].rstrip("/") + toolbox_name = os.environ["TOOLBOX_NAME"] + return f"{project_endpoint}/toolboxes/{toolbox_name}/mcp?api-version=v1" + +class ToolboxAuth(httpx.Auth): + """Injects a fresh bearer token on every request.""" + def __init__(self, token_provider): + self._get_token = token_provider + def auth_flow(self, request): + request.headers["Authorization"] = f"Bearer {self._get_token()}" + yield request + + +@tool(description="Get the current working directory.", approval_mode="never_require") +def get_cwd() -> str: + """Get the current working directory.""" + try: + return os.getcwd() + except Exception as e: + return f"Error getting current working directory: {e}" + + +@tool(description="List files in a directory.", approval_mode="never_require") +def list_files(directory: str) -> list[str]: + """List files in a directory.""" + try: + return os.listdir(directory) + except Exception as e: + return [f"Error listing files in {directory}: {e}"] + + +@tool(description="Read the contents of a file.", approval_mode="never_require") +def read_file(file_path: str) -> str: + """Read the contents of a file.""" + try: + with open(file_path) as f: + return f.read() + except Exception as e: + return f"Error reading file {file_path}: {e}" + + +async def main(): + credential = DefaultAzureCredential() + + # Create the toolbox + token_provider = get_bearer_token_provider( + credential, "https://ai.azure.com/.default" + ) + + http_client = httpx.AsyncClient( + auth=ToolboxAuth(token_provider), + headers={"Foundry-Features": "Toolboxes=V1Preview"}, + timeout=120.0, + ) + + toolbox = MCPStreamableHTTPTool( + name=os.environ["TOOLBOX_NAME"], + url=resolve_toolbox_endpoint(), + http_client=http_client, + load_prompts=False, + ) + + # Create the chat client + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=credential, + ) + + async with Agent( + client=client, + instructions=( + "You are a friendly assistant. Keep your answers brief. " + "Make sure all mathematical calculations are performed using the code interpreter " + "instead of mental arithmetic." + ), + tools=[get_cwd, list_files, read_file, toolbox], + # History will be managed by the hosting infrastructure, thus there + # is no need to store history by the service. Learn more at: + # https://developers.openai.com/api/reference/resources/responses/methods/create + default_options={"store": False}, + ) as agent: + server = ResponsesHostServer(agent) + await server.run_async() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/samples/python/hosted-agents/agent-framework/responses/06-files/requirements.txt b/samples/python/hosted-agents/agent-framework/responses/06-files/requirements.txt new file mode 100644 index 000000000..f7dc62f3e --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/06-files/requirements.txt @@ -0,0 +1,2 @@ +agent-framework +agent-framework-foundry-hosting \ No newline at end of file diff --git a/samples/python/hosted-agents/agent-framework/responses/06-files/resources/contoso_q1_2026_report.txt b/samples/python/hosted-agents/agent-framework/responses/06-files/resources/contoso_q1_2026_report.txt new file mode 100644 index 000000000..858192a7d --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/06-files/resources/contoso_q1_2026_report.txt @@ -0,0 +1,121 @@ +Contoso Corporation +Quarterly Report β€” Q1 2026 (Three months ended March 31, 2026) + +DISCLAIMER +This document contains fictional data for sample/demo purposes only. +Contoso is a fictional company; all figures below are fabricated. + +------------------------------------------------------------ +1. EXECUTIVE SUMMARY +------------------------------------------------------------ +Contoso delivered a solid first quarter, with total revenue of +$1,482.6M, up 11.4% year-over-year. Growth was led by the Cloud +Services segment (+22.7% YoY) and continued double-digit expansion +in International markets. Operating margin expanded 140 basis points +to 23.8% on disciplined cost management and improved gross margin. + +Key highlights: + - Revenue: $1,482.6M (YoY +11.4%) + - Gross profit: $912.0M (gross margin 61.5%) + - Operating income: $352.9M (operating margin 23.8%) + - Net income: $268.4M (net margin 18.1%) + - Diluted EPS: $1.27 (vs. $1.04 prior year) + - Free cash flow: $311.5M + - Cash & equivalents: $2,140.8M + +------------------------------------------------------------ +2. INCOME STATEMENT (USD millions, unaudited) +------------------------------------------------------------ + Q1 2026 Q1 2025 YoY % +Revenue 1,482.6 1,330.7 +11.4% +Cost of revenue 570.6 538.9 +5.9% +Gross profit 912.0 791.8 +15.2% + Gross margin 61.5% 59.5% +200 bps +Operating expenses + Research & development 241.4 220.5 +9.5% + Sales & marketing 218.7 205.1 +6.6% + General & administrative 99.0 88.6 +11.7% +Total operating expenses 559.1 514.2 +8.7% +Operating income 352.9 277.6 +27.1% + Operating margin 23.8% 20.9% +290 bps +Other income / (expense), net 8.4 5.1 +Income before taxes 361.3 282.7 +Provision for income taxes 92.9 72.6 +Net income 268.4 210.1 +27.7% +Diluted EPS (USD) 1.27 1.04 +22.1% + +------------------------------------------------------------ +3. REVENUE BY SEGMENT (USD millions) +------------------------------------------------------------ +Segment Q1 2026 Q1 2025 YoY % +Cloud Services 612.4 499.1 +22.7% +Productivity Software 448.9 422.6 +6.2% +Devices & Hardware 267.0 260.4 +2.5% +Professional Services 154.3 148.6 +3.8% +Total revenue 1,482.6 1,330.7 +11.4% + +------------------------------------------------------------ +4. REVENUE BY GEOGRAPHY (USD millions) +------------------------------------------------------------ +Region Q1 2026 Q1 2025 YoY % +North America 812.1 756.0 +7.4% +EMEA 388.5 340.2 +14.2% +Asia-Pacific 221.7 183.4 +20.9% +Latin America 60.3 51.1 +18.0% +Total revenue 1,482.6 1,330.7 +11.4% + +------------------------------------------------------------ +5. SELECTED BALANCE SHEET ITEMS (USD millions) +------------------------------------------------------------ + Mar 31, Dec 31, + 2026 2025 +Cash & equivalents 2,140.8 1,902.3 +Short-term investments 845.6 820.4 +Accounts receivable, net 1,012.7 988.5 +Total current assets 4,510.2 4,190.6 +Goodwill & intangibles 2,330.1 2,338.9 +Total assets 9,884.5 9,512.0 +Total current liabilities 2,118.4 2,054.7 +Long-term debt 1,750.0 1,750.0 +Total liabilities 4,402.6 4,310.5 +Total stockholders' equity 5,481.9 5,201.5 + +------------------------------------------------------------ +6. CASH FLOW HIGHLIGHTS (USD millions) +------------------------------------------------------------ + Q1 2026 Q1 2025 +Net cash from operating activities 382.0 298.7 +Capital expenditures (70.5) (62.1) +Free cash flow 311.5 236.6 +Share repurchases (120.0) (90.0) +Dividends paid (54.2) (48.6) + +------------------------------------------------------------ +7. KEY OPERATING METRICS +------------------------------------------------------------ +Cloud paid seats (millions) 48.6 39.7 +22.4% +Cloud net revenue retention 118% 114% +Active enterprise customers 18,420 16,905 +9.0% +Headcount (end of period) 22,140 20,610 +7.4% + +------------------------------------------------------------ +8. OUTLOOK β€” Q2 2026 GUIDANCE +------------------------------------------------------------ +Revenue: $1,520M – $1,560M (YoY +10% to +13%) +Operating margin: 23.5% – 24.5% +Diluted EPS: $1.30 – $1.36 +Capital expenditures: ~$80M + +Management remains confident in the full-year plan and reiterates +fiscal-year 2026 revenue growth of 10–12% and operating-margin +expansion of 100–150 basis points versus FY 2025. + +------------------------------------------------------------ +9. NOTES +------------------------------------------------------------ +- All figures are unaudited and rounded to one decimal place. +- Year-over-year comparisons are versus the same period in 2025. +- "Free cash flow" is defined as net cash from operating activities + less capital expenditures, and is a non-GAAP measure. +- This sample report is intended solely for demonstration of an + agent-driven document analysis pipeline. diff --git a/samples/python/hosted-agents/agent-framework/responses/06-files/resources/file-upload-portal.png b/samples/python/hosted-agents/agent-framework/responses/06-files/resources/file-upload-portal.png new file mode 100644 index 000000000..cf99e40a5 Binary files /dev/null and b/samples/python/hosted-agents/agent-framework/responses/06-files/resources/file-upload-portal.png differ diff --git a/samples/python/hosted-agents/agent-framework/responses/06-files/resources/session-started.png b/samples/python/hosted-agents/agent-framework/responses/06-files/resources/session-started.png new file mode 100644 index 000000000..240865b79 Binary files /dev/null and b/samples/python/hosted-agents/agent-framework/responses/06-files/resources/session-started.png differ diff --git a/samples/python/hosted-agents/agent-framework/responses/06-files/resources/start-a-session.png b/samples/python/hosted-agents/agent-framework/responses/06-files/resources/start-a-session.png new file mode 100644 index 000000000..26a5f7c7a Binary files /dev/null and b/samples/python/hosted-agents/agent-framework/responses/06-files/resources/start-a-session.png differ diff --git a/samples/python/hosted-agents/agent-framework/responses/07-skills/.dockerignore b/samples/python/hosted-agents/agent-framework/responses/07-skills/.dockerignore new file mode 100644 index 000000000..0f0d55d2a --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/07-skills/.dockerignore @@ -0,0 +1,6 @@ +.venv +__pycache__ +*.pyc +*.pyo +*.pyd +.Python diff --git a/samples/python/hosted-agents/agent-framework/responses/07-skills/.env.example b/samples/python/hosted-agents/agent-framework/responses/07-skills/.env.example new file mode 100644 index 000000000..2a38d9c9b --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/07-skills/.env.example @@ -0,0 +1,2 @@ +FOUNDRY_PROJECT_ENDPOINT="..." +AZURE_AI_MODEL_DEPLOYMENT_NAME="..." diff --git a/samples/python/hosted-agents/agent-framework/responses/07-skills/Dockerfile b/samples/python/hosted-agents/agent-framework/responses/07-skills/Dockerfile new file mode 100644 index 000000000..0cc939d9b --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/07-skills/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY . user_agent/ +WORKDIR /app/user_agent + +RUN 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/python/hosted-agents/agent-framework/responses/07-skills/README.md b/samples/python/hosted-agents/agent-framework/responses/07-skills/README.md new file mode 100644 index 000000000..c69ef262f --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/07-skills/README.md @@ -0,0 +1,50 @@ +# What this sample demonstrates + +An [Agent Framework](https://github.com/microsoft/agent-framework) agent with **native file-based skills** hosted using the **Responses protocol**. It shows how to add a `SkillsProvider` to an agent so skills in a local `skills/` folder are discovered automatically and can use the same Azure credentials as the rest of the Foundry agent. + +The included `travel-guide` skill can create a colorful PDF city travel guide by running a bundled Python script. The script uses only the Python standard library so there is no extra PDF package to install. + +## How It Works + +### Model Integration + +The agent uses `FoundryChatClient` from the Agent Framework to create a Responses client from the project endpoint and model deployment. The agent supports both streaming (SSE events) and non-streaming (JSON) response modes. + +See [main.py](main.py) for the full implementation. + +### Skills + +The sample creates a `SkillsProvider` pointed at the local [skills](skills/) directory: + +```python +skills_provider = SkillsProvider( + skill_paths=Path(__file__).parent / "skills", + script_runner=run_local_skill_script, +) +``` + +Agent Framework discovers the [travel-guide](skills/travel-guide/) skill from its `SKILL.md` file and advertises it to the model. When the user asks for a travel guide, the model can load the skill instructions and run `scripts/create_travel_guide.py` through the configured script runner. + +### Agent Hosting + +The agent is hosted using the [Agent Framework](https://github.com/microsoft/agent-framework) with the `ResponsesHostServer`, which provisions a REST API endpoint compatible with the OpenAI Responses protocol. + +## Running the Agent Host + +Follow the instructions in the [Running the Agent Host Locally](../../README.md#running-the-agent-host-locally) section of the README in the parent directory to run the agent host. + +## Interacting with the agent + +> Depending on how you run the agent host, you can invoke the agent using `curl` (`Invoke-WebRequest` in PowerShell) or `azd`. Please refer to the [parent README](../../README.md) for more details. Use this README for sample queries you can send to the agent. + +Send a POST request to the server with a JSON body containing an "input" field to interact with the agent. For example: + +```bash +curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "Create a colorful 3-day PDF travel guide for Lisbon focused on food, viewpoints, and neighborhoods."}' +``` + +The script writes the PDF to `$HOME/generated-travel-guides` by default and returns a `$HOME`-based file path such as `$HOME/generated-travel-guides/lisbon-3-day-travel-guide.pdf`. `$HOME` is the Foundry hosted-agent convention for locating generated outputs. For production scenarios that need durable external sharing, update the skill script to upload the PDF to storage and return a shareable URL. + +## Deploying the Agent to Foundry + +To host the agent on Foundry, follow the instructions in the [Deploying the Agent to Foundry](../../README.md#deploying-the-agent-to-foundry) section of the README in the parent directory. diff --git a/samples/python/hosted-agents/agent-framework/responses/07-skills/agent.manifest.yaml b/samples/python/hosted-agents/agent-framework/responses/07-skills/agent.manifest.yaml new file mode 100644 index 000000000..3e5c1210d --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/07-skills/agent.manifest.yaml @@ -0,0 +1,24 @@ +name: agent-framework-agent-with-skills-responses +description: > + An Agent Framework agent with native file-based skills hosted by Foundry. +metadata: + tags: + - Agent Framework + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Streaming + - Skills +template: + name: agent-framework-agent-with-skills-responses + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 + environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" +resources: + - kind: model + id: gpt-4.1-mini + name: AZURE_AI_MODEL_DEPLOYMENT_NAME diff --git a/samples/python/hosted-agents/agent-framework/responses/07-skills/agent.yaml b/samples/python/hosted-agents/agent-framework/responses/07-skills/agent.yaml new file mode 100644 index 000000000..96879c772 --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/07-skills/agent.yaml @@ -0,0 +1,12 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +kind: hosted +name: agent-framework-agent-with-skills-responses +protocols: + - protocol: responses + version: 1.0.0 +resources: + cpu: '0.25' + memory: '0.5Gi' +environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} diff --git a/samples/python/hosted-agents/agent-framework/responses/07-skills/main.py b/samples/python/hosted-agents/agent-framework/responses/07-skills/main.py new file mode 100644 index 000000000..8babf194f --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/07-skills/main.py @@ -0,0 +1,103 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path +from typing import Any + +from agent_framework import Agent, Skill, SkillScript, SkillsProvider +from agent_framework.foundry import FoundryChatClient +from agent_framework_foundry_hosting import ResponsesHostServer +from azure.identity import DefaultAzureCredential +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + + +def run_local_skill_script(skill: Skill, script: SkillScript, args: dict[str, Any] | None = None) -> str: + """Run a trusted file-based skill script with simple CLI arguments.""" + if skill.path is None or script.path is None: + return "Error: only file-based skill scripts can be run by this runner." + + skill_path = Path(skill.path).resolve() + script_path = (skill_path / script.path).resolve() + if skill_path != script_path and skill_path not in script_path.parents: + return f"Error: script '{script.path}' resolves outside the skill directory." + + command = [sys.executable, str(script_path)] + for key, value in (args or {}).items(): + if value is None: + continue + + option = f"--{key.replace('_', '-')}" + if isinstance(value, bool): + if value: + command.append(option) + continue + + if isinstance(value, list | tuple): + value = ",".join(str(item) for item in value) + elif isinstance(value, dict): + value = json.dumps(value) + + command.extend([option, str(value)]) + + try: + completed = subprocess.run( + command, + cwd=skill_path, + capture_output=True, + check=False, + text=True, + timeout=60, + ) + except subprocess.TimeoutExpired: + return f"Error: script '{script.path}' timed out after 60 seconds." + + stdout = completed.stdout.strip() + stderr = completed.stderr.strip() + if completed.returncode != 0: + details = stderr or stdout or "no error output was produced." + return f"Error: script '{script.path}' failed with exit code {completed.returncode}: {details}" + + return stdout or f"Script '{script.path}' completed successfully." + + +def main(): + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=DefaultAzureCredential(), + ) + + skills_provider = SkillsProvider.from_paths( + skill_paths=Path(__file__).parent / "skills", + script_runner=run_local_skill_script, + ) + + agent = Agent( + client=client, + instructions=( + "You are a helpful travel planning assistant. When a user asks for a PDF " + "travel guide, city guide, itinerary, or trip-planning document, use the " + "travel-guide skill. After creating a guide, tell the user where the PDF " + "was saved and summarize what it contains." + ), + context_providers=[skills_provider], + # History will be managed by the hosting infrastructure, thus there + # is no need to store history by the service. Learn more at: + # https://developers.openai.com/api/reference/resources/responses/methods/create + default_options={"store": False}, + ) + + server = ResponsesHostServer(agent) + server.run() + + +if __name__ == "__main__": + main() diff --git a/samples/python/hosted-agents/agent-framework/responses/07-skills/requirements.txt b/samples/python/hosted-agents/agent-framework/responses/07-skills/requirements.txt new file mode 100644 index 000000000..90dcfcd8b --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/07-skills/requirements.txt @@ -0,0 +1,2 @@ +agent-framework>=1.2.2 +agent-framework-foundry-hosting diff --git a/samples/python/hosted-agents/agent-framework/responses/07-skills/skills/travel-guide/SKILL.md b/samples/python/hosted-agents/agent-framework/responses/07-skills/skills/travel-guide/SKILL.md new file mode 100644 index 000000000..fe4a88b1b --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/07-skills/skills/travel-guide/SKILL.md @@ -0,0 +1,37 @@ +--- +name: travel-guide +description: Creates colorful PDF travel guides for cities, including itinerary ideas, neighborhoods, food, practical tips, and photo-worthy stops. Use when the user asks for a travel guide, city guide, itinerary, trip plan, or PDF document for a destination. +--- + +# Travel guide skill + +Use this skill when the user wants a city travel guide, itinerary, or downloadable PDF trip-planning document. + +## Workflow + +1. Identify the city or destination from the user's request. +2. Infer the trip length and interests when provided. If the user does not specify them, use a 3-day guide and a balanced mix of culture, food, neighborhoods, views, and practical tips. +3. Run the PDF generator script: + - skill name: `travel-guide` + - script name: `scripts/create_travel_guide.py` + - args: + - `city`: destination city, required + - `days`: number of itinerary days, optional, defaults to `3` + - `interests`: comma-separated interests such as `food,art,history,views`, optional + - `tone`: guide style such as `family-friendly`, `luxury`, `budget`, or `first-time visitor`, optional +4. After the script returns, tell the user the `$HOME`-based PDF path and briefly summarize the guide. + +## Available scripts + +- `scripts/create_travel_guide.py` - Generates a colorful PDF travel guide and returns JSON with the saved file path. + +## Example script arguments + +```json +{ + "city": "Lisbon", + "days": 3, + "interests": "food,viewpoints,neighborhoods", + "tone": "first-time visitor" +} +``` diff --git a/samples/python/hosted-agents/agent-framework/responses/07-skills/skills/travel-guide/scripts/create_travel_guide.py b/samples/python/hosted-agents/agent-framework/responses/07-skills/skills/travel-guide/scripts/create_travel_guide.py new file mode 100644 index 000000000..40d71f338 --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/07-skills/skills/travel-guide/scripts/create_travel_guide.py @@ -0,0 +1,343 @@ +#!/usr/bin/env python3 +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import argparse +import json +import os +import re +import textwrap +from dataclasses import dataclass, field +from pathlib import Path + +PAGE_WIDTH = 595 +PAGE_HEIGHT = 842 +MARGIN = 54 + + +def safe_text(value: object) -> str: + text = str(value) + return text.encode("latin-1", "replace").decode("latin-1") + + +def pdf_escape(value: object) -> str: + text = safe_text(value) + return text.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)") + + +def slugify(value: str) -> str: + slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") + return slug or "city" + + +def display_path(path: Path) -> str: + home = Path.home().resolve() + resolved_path = path.resolve() + try: + return f"$HOME/{resolved_path.relative_to(home).as_posix()}" + except ValueError: + return str(resolved_path) + + +def rgb(hex_color: str) -> tuple[float, float, float]: + hex_color = hex_color.lstrip("#") + return tuple(int(hex_color[index:index + 2], 16) / 255 for index in (0, 2, 4)) + + +@dataclass +class PdfPage: + commands: list[str] = field(default_factory=list) + + def rect(self, x: float, top: float, width: float, height: float, color: str) -> None: + red, green, blue = rgb(color) + y = PAGE_HEIGHT - top - height + self.commands.append(f"{red:.3f} {green:.3f} {blue:.3f} rg {x:.1f} {y:.1f} {width:.1f} {height:.1f} re f") + + def text(self, x: float, top: float, value: object, size: int = 12, color: str = "#1f2937", bold: bool = False) -> None: + red, green, blue = rgb(color) + font = "F2" if bold else "F1" + y = PAGE_HEIGHT - top + self.commands.append( + f"BT /{font} {size} Tf {red:.3f} {green:.3f} {blue:.3f} rg {x:.1f} {y:.1f} Td ({pdf_escape(value)}) Tj ET" + ) + + def wrapped_text( + self, + x: float, + top: float, + value: object, + *, + size: int = 12, + color: str = "#1f2937", + bold: bool = False, + width_chars: int = 70, + line_gap: int = 17, + ) -> float: + y = top + for line in textwrap.wrap(safe_text(value), width=width_chars) or [""]: + self.text(x, y, line, size=size, color=color, bold=bold) + y += line_gap + return y + + def section(self, title: str, top: float, accent: str = "#2563eb") -> float: + self.rect(MARGIN, top - 17, 7, 24, accent) + self.text(MARGIN + 17, top, title, size=18, color="#111827", bold=True) + return top + 31 + + +def build_pdf(pages: list[PdfPage], output_path: Path) -> None: + objects: list[tuple[int, bytes]] = [ + (1, b"<< /Type /Catalog /Pages 2 0 R >>"), + (3, b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"), + (4, b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>"), + ] + + kids = [] + for index, page in enumerate(pages): + page_id = 5 + index * 2 + content_id = page_id + 1 + kids.append(f"{page_id} 0 R") + stream = "\n".join(page.commands).encode("latin-1", "replace") + objects.append( + ( + page_id, + ( + f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {PAGE_WIDTH} {PAGE_HEIGHT}] " + f"/Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents {content_id} 0 R >>" + ).encode("ascii"), + ) + ) + objects.append( + ( + content_id, + b"<< /Length " + str(len(stream)).encode("ascii") + b" >>\nstream\n" + stream + b"\nendstream", + ) + ) + + objects.append((2, f"<< /Type /Pages /Kids [{' '.join(kids)}] /Count {len(pages)} >>".encode("ascii"))) + objects.sort(key=lambda item: item[0]) + + output = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n") + offsets = [0] + for object_id, content in objects: + offsets.append(len(output)) + output.extend(f"{object_id} 0 obj\n".encode("ascii")) + output.extend(content) + output.extend(b"\nendobj\n") + + xref_start = len(output) + output.extend(f"xref\n0 {len(objects) + 1}\n".encode("ascii")) + output.extend(b"0000000000 65535 f \n") + for offset in offsets[1:]: + output.extend(f"{offset:010d} 00000 n \n".encode("ascii")) + output.extend( + f"trailer\n<< /Size {len(objects) + 1} /Root 1 0 R >>\nstartxref\n{xref_start}\n%%EOF\n".encode("ascii") + ) + + output_path.write_bytes(output) + + +def normalize_interests(raw: str) -> list[str]: + interests = [item.strip().lower() for item in raw.split(",") if item.strip()] + return interests or ["food", "history", "art", "views"] + + +def top_experiences(city: str, interests: list[str]) -> list[str]: + experiences = [ + f"Begin with a golden-hour walk through {city}'s most atmospheric streets.", + "Choose one anchor museum, market, or landmark each day, then leave room for wandering.", + "Plan a sunset viewpoint and a relaxed dinner nearby to avoid backtracking.", + ] + interest_map = { + "food": "Book one local food experience: a market crawl, cooking class, or neighborhood tasting route.", + "art": "Add a gallery district or design shop loop for a creative afternoon.", + "history": "Pair the main historic sight with a smaller local museum for context without crowds.", + "views": "Build in a rooftop, hilltop, riverfront, or observation stop for photos.", + "neighborhoods": "Explore two contrasting neighborhoods instead of trying to cross the whole city.", + "shopping": "Save space for local makers, bookshops, markets, and design boutiques.", + "family": "Alternate big sights with parks, treats, and short transit hops.", + } + for interest in interests: + if interest in interest_map: + experiences.append(interest_map[interest]) + return experiences[:7] + + +def itinerary(city: str, days: int, interests: list[str]) -> list[tuple[str, list[str]]]: + themes = [ + ("Arrival and icons", ["Historic center orientation walk", "Signature landmark or museum", "Sunset viewpoint"]), + ("Neighborhood flavor", ["Local market breakfast", "Two-neighborhood walking loop", "Casual dinner on a lively side street"]), + ("Culture and slow travel", ["Museum or gallery morning", "Cafe break and independent shops", "Evening performance or waterfront stroll"]), + ("Hidden corners", ["Quiet park or garden", "Lesser-known district", "Chef-led, street-food, or family-run dinner"]), + ("Day trip energy", ["Short regional excursion", "Scenic lunch stop", "Return for an easy evening"]), + ("Active city day", ["Bike, boat, hike, or long promenade", "Picnic or food-hall lunch", "Golden-hour photo route"]), + ("Favorites and farewell", ["Revisit the best neighborhood", "Buy local gifts", "Final meal with a view"]), + ] + if "food" in interests: + themes[1][1][0] = "Market breakfast and local tasting crawl" + if "art" in interests: + themes[2][1][0] = "Museum, gallery, or design district morning" + if "views" in interests: + themes[0][1][2] = "Best sunset viewpoint or rooftop" + return [(f"Day {index + 1}: {themes[index][0]}", themes[index][1]) for index in range(days)] + + +def add_header(page: PdfPage, city: str, subtitle: str) -> None: + page.rect(0, 0, PAGE_WIDTH, 88, "#dbeafe") + page.rect(0, 88, PAGE_WIDTH, 8, "#2563eb") + page.text(MARGIN, 38, city, size=26, color="#111827", bold=True) + page.text(MARGIN, 66, subtitle, size=12, color="#374151") + + +def add_bullets(page: PdfPage, items: list[str], top: float, *, color: str = "#1f2937") -> float: + y = top + for item in items: + page.text(MARGIN + 8, y, "-", size=12, color="#2563eb", bold=True) + y = page.wrapped_text(MARGIN + 25, y, item, size=11, color=color, width_chars=72, line_gap=15) + y += 6 + return y + + +def build_travel_guide(city: str, days: int, interests: list[str], tone: str, output_path: Path) -> int: + pages: list[PdfPage] = [] + + cover = PdfPage() + cover.rect(0, 0, PAGE_WIDTH, PAGE_HEIGHT, "#eff6ff") + cover.rect(0, 0, PAGE_WIDTH, 170, "#2563eb") + cover.rect(54, 122, 190, 12, "#f97316") + cover.rect(268, 122, 115, 12, "#10b981") + cover.rect(407, 122, 134, 12, "#facc15") + cover.text(MARGIN, 82, "Colorful City Guide", size=22, color="#ffffff", bold=True) + cover.text(MARGIN, 120, city, size=42, color="#ffffff", bold=True) + cover.wrapped_text( + MARGIN, + 210, + f"A {days}-day {tone} travel guide with itinerary ideas, local flavor, practical tips, and photo-worthy stops.", + size=16, + color="#111827", + bold=True, + width_chars=52, + line_gap=22, + ) + cover.rect(MARGIN, 330, 487, 190, "#ffffff") + cover.text(MARGIN + 26, 374, "Best for", size=17, color="#111827", bold=True) + add_bullets(cover, [interest.title() for interest in interests[:6]], 405) + cover.text(MARGIN, 742, "Generated by the Agent Framework travel-guide skill", size=11, color="#6b7280") + pages.append(cover) + + overview = PdfPage() + add_header(overview, city, "Highlights and planning compass") + y = overview.section("Top experiences", 140, "#f97316") + y = add_bullets(overview, top_experiences(city, interests), y) + y = overview.section("Neighborhood strategy", y + 18, "#10b981") + y = add_bullets( + overview, + [ + "Pick one compact base area with easy transit and strong evening food options.", + "Group sights by neighborhood so each day has fewer transfers and more serendipity.", + "Use mornings for major attractions, afternoons for cafes and local streets, evenings for views and food.", + ], + y, + ) + y = overview.section("Food and drink notes", y + 18, "#7c3aed") + add_bullets( + overview, + [ + "Reserve one special meal, then keep the rest flexible for markets, bakeries, and casual local spots.", + "Ask for seasonal specialties and house recommendations rather than only ordering famous dishes.", + ], + y, + ) + pages.append(overview) + + plan = PdfPage() + add_header(plan, city, f"{days}-day itinerary") + y = 140 + for title, items in itinerary(city, days, interests): + if y > 690: + pages.append(plan) + plan = PdfPage() + add_header(plan, city, f"{days}-day itinerary continued") + y = 140 + y = plan.section(title, y, "#2563eb") + y = add_bullets(plan, items, y) + y += 12 + pages.append(plan) + + tips = PdfPage() + add_header(tips, city, "Practical tips and finishing touches") + y = tips.section("Easy logistics", 140, "#10b981") + y = add_bullets( + tips, + [ + "Keep the first afternoon light: check in, walk the local area, and save the ambitious plan for day two.", + "Download offline maps and pin your hotel, transit stops, dinner options, and backup rainy-day sights.", + "Carry a reusable water bottle, a compact umbrella, and one comfortable layer for changing weather.", + ], + y, + ) + y = tips.section("Photo checklist", y + 18, "#f97316") + y = add_bullets( + tips, + [ + "Wide establishing shot from a viewpoint", + "Street detail: tiles, signs, doors, markets, or transit", + "One food photo in natural light", + "Blue-hour skyline or waterfront scene", + ], + y, + ) + y = tips.section("Before you go", y + 18, "#7c3aed") + add_bullets( + tips, + [ + "Confirm opening days for museums and restaurants.", + "Check local transit passes and airport transfer options.", + "Leave one open block for discoveries, weather changes, or a slower morning.", + ], + y, + ) + pages.append(tips) + + build_pdf(pages, output_path) + return len(pages) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Create a colorful PDF city travel guide.") + parser.add_argument("--city", required=True, help="Destination city for the guide.") + parser.add_argument("--days", type=int, default=3, help="Number of itinerary days, from 1 to 7.") + parser.add_argument("--interests", default="food,history,art,views", help="Comma-separated interests.") + parser.add_argument("--tone", default="first-time visitor", help="Guide style or audience.") + parser.add_argument( + "--output-dir", + default=os.getenv("TRAVEL_GUIDE_OUTPUT_DIR") or str(Path.home() / "generated-travel-guides"), + help="Directory where the generated PDF should be saved.", + ) + args = parser.parse_args() + + days = min(max(args.days, 1), 7) + interests = normalize_interests(args.interests) + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + output_path = output_dir / f"{slugify(args.city)}-{days}-day-travel-guide.pdf" + + page_count = build_travel_guide(args.city, days, interests, args.tone, output_path) + print( + json.dumps( + { + "city": args.city, + "days": days, + "interests": interests, + "pages": page_count, + "path": display_path(output_path), + "message": f"Created a colorful PDF travel guide for {args.city}.", + }, + indent=2, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/samples/python/hosted-agents/agent-framework/responses/08-observability/.dockerignore b/samples/python/hosted-agents/agent-framework/responses/08-observability/.dockerignore new file mode 100644 index 000000000..0d6619bae --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/08-observability/.dockerignore @@ -0,0 +1,7 @@ +.venv +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +.env \ No newline at end of file diff --git a/samples/python/hosted-agents/agent-framework/responses/08-observability/.env.example b/samples/python/hosted-agents/agent-framework/responses/08-observability/.env.example new file mode 100644 index 000000000..f53b64c8c --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/08-observability/.env.example @@ -0,0 +1,4 @@ +FOUNDRY_PROJECT_ENDPOINT="..." +AZURE_AI_MODEL_DEPLOYMENT_NAME="..." +ENABLE_INSTRUMENTATION=true +ENABLE_SENSITIVE_DATA=true \ No newline at end of file diff --git a/samples/python/hosted-agents/agent-framework/responses/08-observability/Dockerfile b/samples/python/hosted-agents/agent-framework/responses/08-observability/Dockerfile new file mode 100644 index 000000000..eaffb94f1 --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/08-observability/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY . user_agent/ +WORKDIR /app/user_agent + +RUN if [ -f requirements.txt ]; then \ + pip install -r requirements.txt; \ + else \ + echo "No requirements.txt found"; \ + fi + +EXPOSE 8088 + +CMD ["python", "main.py"] \ No newline at end of file diff --git a/samples/python/hosted-agents/agent-framework/responses/08-observability/README.md b/samples/python/hosted-agents/agent-framework/responses/08-observability/README.md new file mode 100644 index 000000000..9f08baa16 --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/08-observability/README.md @@ -0,0 +1,51 @@ +# What this sample demonstrates + +An instrumented [Agent Framework](https://github.com/microsoft/agent-framework) agent hosted using the **Responses protocol**. + +## How It Works + +### Model Integration + +The agent uses `FoundryChatClient` from the Agent Framework to create a Responses client from the project endpoint and model deployment. The agent supports both streaming (SSE events) and non-streaming (JSON) response modes. + +See [main.py](main.py) for the full implementation. + +### Agent Hosting + +The agent is hosted using the [Agent Framework](https://github.com/microsoft/agent-framework) with the `ResponsesHostServer`, which provisions a REST API endpoint compatible with the OpenAI Responses protocol. + +### Instrumentation + +Agent Framework is [**natively instrumented**](https://learn.microsoft.com/en-us/agent-framework/agents/observability?pivots=programming-language-python) to capture diagnostics and telemetry for agent execution, but it's turned off by default. This sample demonstrates how to enable instrumentation via environment variables in `agent.manifest.yaml` and `agent.yaml`. The relevant environment variables are `ENABLE_INSTRUMENTATION` and `ENABLE_SENSITIVE_DATA`, which can be set to `true` to enable diagnostics and capture sensitive events respectively. + +Foundry Hosted Agent has built-in observability thus you don't need to set up exporters manually to capture telemetry from your code. The traces, metrics, and logs generated by the agent are automatically collected and made available through Foundry's observability stack via Azure Monitor/Application Insights. The `APPLICATIONINSIGHTS_CONNECTION_STRING` environment variable is injected when the agent is deployed to Foundry, however it is still required to be set in your environment if you want to run the agent host locally and have telemetry sent to Application Insights from your local environment. + +## Running the Agent Host + +Follow the instructions in the [Running the Agent Host Locally](../../README.md#running-the-agent-host-locally) section of the README in the parent directory to run the agent host. + +## Interacting with the agent + +> Because the observability exporters are managed by Foundry, this sample must be run using `azd ai agent run`. Run this sample using `python main.py` will not send telemetry to Application Insights. + +```bash +azd ai agent run --local "What is the current weather?" +``` + +A couple of spans will be created for this request from Agent Framework's instrumentation, representing the generation of the response by the agent: + +- `invoke_agent`: This span represents the invocation of the agent itself, capturing the start and end of the agent's processing for this request. +- `chat`: This span represents the call to the underlying model. +- `execute_tool`: This span represents the execution of any tools invoked by the agent as part of generating the response. + +> For more information on the spans, refer to the [OpenTelemetry GenAI Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/) + +## Deploying the Agent to Foundry + +To host the agent on Foundry, follow the instructions in the [Deploying the Agent to Foundry](../../README.md#deploying-the-agent-to-foundry) section of the README in the parent directory. + +### Viewing Telemetry in Foundry + +Once the agent is deployed to Foundry, the telemetry generated by the agent (traces, metrics, and logs) will be automatically collected and sent to Azure Monitor/Application Insights. You can view this telemetry by navigating to the Application Insights resource associated with your Foundry project or directly from the Foundry UI. + +In the Foundry UI, next to the **Playground** tab is the **Traces** tab, where you can find the conversations and their corresponding trace IDs. Clicking on a trace ID will allow you to drill into the detailed trace information for that particular conversation. diff --git a/samples/python/hosted-agents/agent-framework/responses/08-observability/agent.manifest.yaml b/samples/python/hosted-agents/agent-framework/responses/08-observability/agent.manifest.yaml new file mode 100644 index 000000000..b96c34ad9 --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/08-observability/agent.manifest.yaml @@ -0,0 +1,27 @@ +name: agent-framework-agent-observability-responses +description: > + A basic Agent Framework agent hosted by Foundry. +metadata: + tags: + - Agent Framework + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Streaming +template: + name: agent-framework-agent-observability-responses + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 + environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" + - name: ENABLE_INSTRUMENTATION + value: true + - name: ENABLE_SENSITIVE_DATA + value: true +resources: + - kind: model + id: gpt-4.1-mini + name: AZURE_AI_MODEL_DEPLOYMENT_NAME diff --git a/samples/python/hosted-agents/agent-framework/responses/08-observability/agent.yaml b/samples/python/hosted-agents/agent-framework/responses/08-observability/agent.yaml new file mode 100644 index 000000000..216dd415d --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/08-observability/agent.yaml @@ -0,0 +1,16 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +kind: hosted +name: agent-framework-agent-observability-responses +protocols: + - protocol: responses + version: 1.0.0 +resources: + cpu: '0.25' + memory: '0.5Gi' +environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} + - name: ENABLE_INSTRUMENTATION + value: true + - name: ENABLE_SENSITIVE_DATA + value: true \ No newline at end of file diff --git a/samples/python/hosted-agents/agent-framework/responses/08-observability/main.py b/samples/python/hosted-agents/agent-framework/responses/08-observability/main.py new file mode 100644 index 000000000..5672df11f --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/08-observability/main.py @@ -0,0 +1,57 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import os +from random import randint +from typing import Annotated + +from agent_framework import Agent, tool +from agent_framework.foundry import FoundryChatClient +from agent_framework_foundry_hosting import ResponsesHostServer +from azure.identity import DefaultAzureCredential +from dotenv import load_dotenv +from pydantic import Field + +# Load environment variables from .env file +load_dotenv() + + +@tool(approval_mode="never_require", description="Get the current location of the user.") +def get_current_location() -> str: + """Get the current location of the agent.""" + locations = ["New York", "London", "Paris", "Tokyo"] + return locations[randint(0, len(locations) - 1)] + + +@tool(approval_mode="never_require") +def get_weather( + location: Annotated[str, Field(description="The location to get the weather for.")], +) -> str: + """Get the weather for a given location.""" + conditions = ["sunny", "cloudy", "rainy", "stormy"] + return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}Β°C." + + +async def main(): + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=DefaultAzureCredential(), + ) + + agent = Agent( + client=client, + instructions="You are a friendly assistant. Keep your answers brief.", + tools=[get_weather, get_current_location], + # History will be managed by the hosting infrastructure, thus there + # is no need to store history by the service. Learn more at: + # https://developers.openai.com/api/reference/resources/responses/methods/create + default_options={"store": False}, + ) + + server = ResponsesHostServer(agent) + await server.run_async() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/samples/python/hosted-agents/agent-framework/responses/08-observability/requirements.txt b/samples/python/hosted-agents/agent-framework/responses/08-observability/requirements.txt new file mode 100644 index 000000000..f7dc62f3e --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/08-observability/requirements.txt @@ -0,0 +1,2 @@ +agent-framework +agent-framework-foundry-hosting \ No newline at end of file diff --git a/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/.dockerignore b/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/.dockerignore new file mode 100644 index 000000000..31ed562a7 --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/.dockerignore @@ -0,0 +1,7 @@ +.venv +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +.env diff --git a/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/.env.example b/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/.env.example new file mode 100644 index 000000000..2a38d9c9b --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/.env.example @@ -0,0 +1,2 @@ +FOUNDRY_PROJECT_ENDPOINT="..." +AZURE_AI_MODEL_DEPLOYMENT_NAME="..." diff --git a/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/Dockerfile b/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/Dockerfile new file mode 100644 index 000000000..66d3b74ec --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/Dockerfile @@ -0,0 +1,31 @@ +FROM python:3.12-slim + +WORKDIR /app + +# .NET 10 runtime is required by the `powerfx` package, which +# agent-framework-declarative uses to evaluate `=...` expressions in the +# workflow YAML (e.g. =Local.Triage.NeedsClarification). Without it, +# ConditionGroup evaluation raises and the workflow produces no output. +RUN apt-get update \ + && apt-get install -y --no-install-recommends wget ca-certificates libicu-dev \ + && wget -qO /tmp/dotnet-install.sh https://dot.net/v1/dotnet-install.sh \ + && chmod +x /tmp/dotnet-install.sh \ + && /tmp/dotnet-install.sh --runtime dotnet --channel 10.0 --install-dir /usr/share/dotnet \ + && ln -s /usr/share/dotnet/dotnet /usr/local/bin/dotnet \ + && rm -f /tmp/dotnet-install.sh \ + && rm -rf /var/lib/apt/lists/* + +ENV DOTNET_ROOT=/usr/share/dotnet + +COPY . user_agent/ +WORKDIR /app/user_agent + +RUN 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/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/README.md b/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/README.md new file mode 100644 index 000000000..a7035518d --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/README.md @@ -0,0 +1,67 @@ +# What this sample demonstrates + +A realistic **multi-turn** [Agent Framework](https://github.com/microsoft/agent-framework) **declarative workflow** β€” defined entirely in YAML β€” hosted using the **Responses protocol**. It shows how a declarative workflow that invokes multiple Foundry-hosted agents can run end-to-end on every user turn while reading the prior conversation through `Conversation.messages` (populated automatically by `Workflow.as_agent()`). + +> Read more about declarative workflows in the [Agent Framework documentation](https://learn.microsoft.com/en-us/agent-framework/workflows/declarative/?pivots=programming-language-python) and about workflow-as-an-agent in the [Workflow as an Agent documentation](https://learn.microsoft.com/en-us/agent-framework/workflows/as-agents?pivots=programming-language-python). + +## How It Works + +### The Workflow + +[`workflow.yaml`](workflow.yaml) describes a customer-support triage flow: + +1. `InvokeAzureAgent: TriageAgent` β€” looks at the full conversation so far and emits a structured `TriageResponse` (`Category`, `NeedsClarification`, `ClarificationQuestion`, `Reply`). +2. `ConditionGroup` routes on the triage decision: + - **NeedsClarification** β†’ `SendActivity` asks one focused follow-up question and ends the turn. + - **Category = "Technical"** β†’ `SendActivity` confirms the handoff, then `InvokeAzureAgent: TechSupportAgent` answers with `autoSend: true` so its reply streams directly to the caller. + - **Category = "Billing"** β†’ same pattern, routed to `BillingAgent`. + - **else** β†’ `SendActivity` returns the triage agent's `Reply` directly (good for greetings or general questions). + +Each user message re-runs the workflow from the trigger. Because `Workflow.as_agent()` populates `Conversation.messages` with the prior turns of the conversation, every `InvokeAzureAgent` call sees the full history β€” which is what makes the triage decision and the specialist follow-ups coherent across turns. + +### Agent Hosting + +[`main.py`](main.py) builds three `Agent` instances on top of a shared `FoundryChatClient` (one per workflow role), registers them with the `WorkflowFactory` so the YAML's `InvokeAzureAgent` actions can resolve them by name, loads the workflow, wraps it with `.as_agent(...)`, and hands the agent to `ResponsesHostServer`, which provisions a REST API endpoint compatible with the OpenAI Responses protocol. + +The triage agent is configured with `response_format=TriageResponse` (a Pydantic model) so the workflow can read its structured fields via `Local.Triage.*`. The specialist agents are plain text and use `autoSend: true` to deliver their reply straight to the caller. + +## Running the Agent Host + +Follow the instructions in the [Running the Agent Host Locally](../../README.md#running-the-agent-host-locally) section of the README in the parent directory to run the agent host. + +## Interacting with the agent + +> Depending on how you run the agent host, you can invoke the agent using `curl` (`Invoke-WebRequest` in PowerShell) or `azd`. Please refer to the [parent README](../../README.md) for more details. Use this README for sample queries you can send to the agent. + +A typical multi-turn session looks like this: + +```bash +azd ai agent invoke --local "I have a problem" +# β†’ "Could you tell me a bit more about what's going on?" + +azd ai agent invoke --local "My laptop won't turn on" +# β†’ "Connecting you with technical support..." +# β†’ TechSupportAgent: "Let's start simple β€” is the charger LED on when plugged in?" + +azd ai agent invoke --local "Yes the LED is on" +# β†’ "Connecting you with technical support..." +# β†’ TechSupportAgent: "Great. Try a hard reset: hold the power button for 30 seconds..." +``` + +Or for billing: + +```bash +azd ai agent invoke --local "I was double-charged this month" +# β†’ "Connecting you with billing support..." +# β†’ BillingAgent: "I'm sorry about that. Can you share the last 4 digits of the card on file?" +``` + +You can also POST directly to the Responses endpoint: + +```bash +curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "My internet is down"}' +``` + +## Deploying the Agent to Foundry + +To host the agent on Foundry, follow the instructions in the [Deploying the Agent to Foundry](../../README.md#deploying-the-agent-to-foundry) section of the README in the parent directory. diff --git a/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/agent.manifest.yaml b/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/agent.manifest.yaml new file mode 100644 index 000000000..1fbe741c9 --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/agent.manifest.yaml @@ -0,0 +1,25 @@ +name: agent-framework-declarative-customer-support-responses +description: > + A multi-turn Agent Framework declarative (YAML-defined) customer-support + triage workflow hosted by Foundry. +metadata: + tags: + - Agent Framework + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Declarative Workflow + - Multi-turn +template: + name: agent-framework-declarative-customer-support-responses + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 + environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" +resources: + - kind: model + id: gpt-4.1-mini + name: AZURE_AI_MODEL_DEPLOYMENT_NAME diff --git a/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/agent.yaml b/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/agent.yaml new file mode 100644 index 000000000..68834440d --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/agent.yaml @@ -0,0 +1,12 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +kind: hosted +name: agent-framework-declarative-customer-support-responses +protocols: + - protocol: responses + version: 1.0.0 +resources: + cpu: '0.25' + memory: '0.5Gi' +environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} diff --git a/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/main.py b/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/main.py new file mode 100644 index 000000000..84f59a451 --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/main.py @@ -0,0 +1,143 @@ +# Copyright (c) Microsoft. All rights reserved. + +import os +from pathlib import Path +from typing import Literal + +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient +from agent_framework_declarative import WorkflowFactory +from agent_framework_foundry_hosting import ResponsesHostServer +from azure.identity import DefaultAzureCredential +from dotenv import load_dotenv +from pydantic import BaseModel, Field + +# Load environment variables from .env file +load_dotenv() + + +# --- Structured triage response -------------------------------------------------- + +class TriageResponse(BaseModel): + """Triage decision produced from the conversation so far.""" + + Category: Literal["Technical", "Billing", "General"] = Field( + description=( + "The best category for the user's request. " + "Use 'Technical' for hardware/software/network issues, " + "'Billing' for invoices/subscriptions/refunds, and " + "'General' for anything else (greetings, FAQs, small talk)." + ), + ) + NeedsClarification: bool = Field( + description=( + "True if you cannot confidently classify the request yet and " + "need to ask the user one focused follow-up question." + ), + ) + ClarificationQuestion: str = Field( + default="", + description=( + "A single, polite follow-up question to ask the user. " + "Required when NeedsClarification is true; otherwise empty." + ), + ) + Reply: str = Field( + default="", + description=( + "A natural-language reply to the user. " + "Used when Category is 'General'; otherwise may be left empty." + ), + ) + + +# --- Agent instructions ---------------------------------------------------------- + +TRIAGE_INSTRUCTIONS = """ +You are the front-line triage agent for a customer support workflow. + +You will see the full conversation so far. Decide whether to: +- Ask the user one focused follow-up question (set NeedsClarification = true), or +- Route the conversation to the right specialist by setting Category, or +- Answer directly for general/small-talk requests via Reply. + +Be efficient: do not ask a clarification if a category is already clear. +""".strip() + +TECH_SUPPORT_INSTRUCTIONS = """ +You are a senior technical support specialist. The conversation history shows +what the user has told you so far and which steps were already attempted. + +Provide one concrete next troubleshooting step at a time, then wait for the +user's response. Be concise and friendly. If the issue appears resolved, +congratulate the user and ask if there's anything else. +""".strip() + +BILLING_INSTRUCTIONS = """ +You are a customer billing specialist. The conversation history shows what +the user has asked. + +Help the user with invoice, subscription, refund, and payment-method +questions. If you need account details (e.g., last 4 of card, account email), +ask for them one at a time. Keep responses short and polite. +""".strip() + + +# --- Host setup ------------------------------------------------------------------ + +def main() -> None: + workflow_path = Path(__file__).parent / "workflow.yaml" + + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=DefaultAzureCredential(), + ) + + # The workflow's InvokeAzureAgent actions reference these agents by name. + triage_agent = Agent( + client=client, + name="TriageAgent", + instructions=TRIAGE_INSTRUCTIONS, + default_options={"response_format": TriageResponse, "store": False}, + ) + tech_support_agent = Agent( + client=client, + name="TechSupportAgent", + instructions=TECH_SUPPORT_INSTRUCTIONS, + default_options={"store": False}, + ) + billing_agent = Agent( + client=client, + name="BillingAgent", + instructions=BILLING_INSTRUCTIONS, + default_options={"store": False}, + ) + + factory = WorkflowFactory( + agents={ + "TriageAgent": triage_agent, + "TechSupportAgent": tech_support_agent, + "BillingAgent": billing_agent, + }, + ) + + workflow = factory.create_workflow_from_yaml_path(str(workflow_path)) + + # Wrap the declarative workflow as an AIAgent so it can be served behind + # the Responses protocol. Each user turn re-runs the workflow with the + # full conversation history available via Conversation.messages. + workflow_agent = workflow.as_agent( + name="declarative-customer-support", + description=( + "A multi-turn customer-support triage workflow that routes " + "between technical and billing specialists based on the " + "conversation history." + ), + ) + + ResponsesHostServer(workflow_agent).run() + + +if __name__ == "__main__": + main() diff --git a/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/requirements.txt b/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/requirements.txt new file mode 100644 index 000000000..1ed4f3c7d --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/requirements.txt @@ -0,0 +1,2 @@ +agent-framework +agent-framework-foundry-hosting diff --git a/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/workflow.yaml b/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/workflow.yaml new file mode 100644 index 000000000..c4593d98d --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support/workflow.yaml @@ -0,0 +1,89 @@ +# A multi-turn customer-support triage workflow defined declaratively. +# +# Each user message re-runs the workflow with the full conversation history +# available to the invoked agents via Conversation.messages. The TriageAgent +# decides on every turn whether to ask a follow-up question, route to a +# technical specialist, route to a billing specialist, or close the ticket +# with a general response. + +kind: Workflow +trigger: + kind: OnConversationStart + id: customer_support_triage + actions: + + # Look at the full conversation so far and decide what to do. + # The agent's structured response drives the routing below. + # autoSend=false keeps the raw structured JSON out of the user-facing + # output stream β€” the ConditionGroup below is what produces the reply. + - kind: InvokeAzureAgent + id: triage + agent: + name: TriageAgent + output: + autoSend: false + responseObject: Local.Triage + + # Branch on the triage decision. + - kind: ConditionGroup + id: route + conditions: + + # Not enough information yet β€” ask the user a targeted follow-up. + - condition: =Local.Triage.NeedsClarification + id: ask_followup + actions: + - kind: SendActivity + id: send_followup + activity: + text: =Local.Triage.ClarificationQuestion + - kind: GotoAction + id: end_after_followup + actionId: all_done + + # Technical issue β€” hand off to the technical specialist. + # autoSend streams the agent's reply directly to the caller. + - condition: =Local.Triage.Category = "Technical" + id: route_technical + actions: + - kind: SendActivity + id: log_technical + activity: + text: "Connecting you with technical support..." + - kind: InvokeAzureAgent + id: tech_support + agent: + name: TechSupportAgent + output: + autoSend: true + - kind: GotoAction + id: end_after_technical + actionId: all_done + + # Billing issue β€” hand off to the billing specialist. + - condition: =Local.Triage.Category = "Billing" + id: route_billing + actions: + - kind: SendActivity + id: log_billing + activity: + text: "Connecting you with billing support..." + - kind: InvokeAzureAgent + id: billing_support + agent: + name: BillingAgent + output: + autoSend: true + - kind: GotoAction + id: end_after_billing + actionId: all_done + + # Default: a general question or chit-chat β€” send the triage reply. + elseActions: + - kind: SendActivity + id: send_general + activity: + text: =Local.Triage.Reply + + - kind: EndWorkflow + id: all_done diff --git a/samples/python/hosted-agents/agent-framework/responses/09-downstream-azure/.dockerignore b/samples/python/hosted-agents/agent-framework/responses/09-downstream-azure/.dockerignore new file mode 100644 index 000000000..f3bd29a80 --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/09-downstream-azure/.dockerignore @@ -0,0 +1,12 @@ +.venv +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +.env + +*.log +.azure +infra +azure.yaml diff --git a/samples/python/toolbox/azd/.env.example b/samples/python/hosted-agents/agent-framework/responses/09-downstream-azure/.env.example similarity index 51% rename from samples/python/toolbox/azd/.env.example rename to samples/python/hosted-agents/agent-framework/responses/09-downstream-azure/.env.example index a04c04481..dc4c43978 100644 --- a/samples/python/toolbox/azd/.env.example +++ b/samples/python/hosted-agents/agent-framework/responses/09-downstream-azure/.env.example @@ -3,11 +3,18 @@ # FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ # Model deployment name β€” must match a deployment in your Foundry project. -MODEL_DEPLOYMENT_NAME= +AZURE_AI_MODEL_DEPLOYMENT_NAME= # Application Insights β€” auto-injected in hosted containers. # Set for local telemetry (optional but recommended). # APPLICATIONINSIGHTS_CONNECTION_STRING=InstrumentationKey=... -# Toolbox MCP endpoint β€” full URL including toolbox name and api-version. -# TOOLBOX_ENDPOINT=https://.services.ai.azure.com/api/projects//toolboxes//mcp?api-version=v1 +# --- Azure Blob Storage --- +# The per-agent identity must have "Storage Blob Data Contributor" on this container. +AZURE_STORAGE_ACCOUNT_NAME= +AZURE_STORAGE_CONTAINER_NAME= + +# --- Azure Service Bus (queue) --- +# The per-agent identity must have "Azure Service Bus Data Sender" and "Data Receiver" on the queue. +AZURE_SERVICEBUS_FQDN= # e.g. .servicebus.windows.net +AZURE_SERVICEBUS_QUEUE_NAME= diff --git a/samples/python/hosted-agents/agent-framework/responses/09-downstream-azure/Dockerfile b/samples/python/hosted-agents/agent-framework/responses/09-downstream-azure/Dockerfile new file mode 100644 index 000000000..0cc939d9b --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/09-downstream-azure/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY . user_agent/ +WORKDIR /app/user_agent + +RUN 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/python/hosted-agents/agent-framework/responses/09-downstream-azure/README.md b/samples/python/hosted-agents/agent-framework/responses/09-downstream-azure/README.md new file mode 100644 index 000000000..150e2d7e1 --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/09-downstream-azure/README.md @@ -0,0 +1,204 @@ +# What this sample demonstrates + +An [Agent Framework](https://github.com/microsoft/agent-framework) hosted agent that performs data-plane operations on **two Azure services β€” Blob Storage and Service Bus β€”** using its **per-agent Microsoft Entra identity** (no connection strings, no shared keys). + +The sample covers two services on purpose: Storage and Service Bus both use **standard Azure (ARM) RBAC** for data-plane authorization, but each requires a **different built-in role** scoped to a different resource. Working through both makes the per-agent identity pattern concrete. + +## How It Works + +### Per-Agent Identity + +When you deploy a hosted agent to Foundry, the platform provisions a dedicated Microsoft Entra **service identity** for that agent. Every outbound call the agent makes can use `DefaultAzureCredential` and Foundry will inject the per-agent identity at runtime. To let the agent touch a downstream Azure resource, you assign that identity the appropriate data-plane RBAC role on the target resource. + +### Tools + +The tools are plain Python functions decorated with `@tool` and registered with the agent in [main.py](main.py). Each tool builds its client with `DefaultAzureCredential()` so the same code works locally (your developer identity) and in Foundry (the per-agent identity). + +| Service | Tools | SDK | +| ------------- | ------------------------------------------------------ | -------------------- | +| Blob Storage | `storage_put_blob`, `storage_get_blob` | `azure-storage-blob` | +| Service Bus | `servicebus_send_message`, `servicebus_peek_messages` | `azure-servicebus` | + +### Agent Hosting + +The agent is hosted using the [Agent Framework](https://github.com/microsoft/agent-framework) with `ResponsesHostServer`, which provisions a REST API endpoint compatible with the OpenAI Responses protocol. + +## Prerequisites + +In addition to the prerequisites listed in the [parent README](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/agent-framework/README.md), this sample also requires: + +- **Azure Blob Storage** β€” an existing storage account and container the agent will read/write. +- **Azure Service Bus** β€” an existing namespace and queue the agent will send to / peek from. + +## Granting the agent data-plane access + +Both services use standard Azure RBAC, so both assignments use `az role assignment create` against the resource's ARM id. + +When running **locally** with `DefaultAzureCredential`, role assignments must be applied to **your developer principal** (the one `az login` was performed with). When running on **Foundry**, role assignments must be applied to the **per-agent identity**. The two principals also have different types β€” your user identity is a `User`, while the per-agent identity is a `ServicePrincipal` β€” and `az role assignment create` requires you to pass the right one via `--assignee-principal-type`. + +Capture both values into shell variables, then reuse them in the assignment commands below. + +**Local (your developer identity):** + +```bash +PRINCIPAL_ID=$(az ad signed-in-user show --query id -o tsv) +PRINCIPAL_TYPE="User" +``` + +```powershell +$PRINCIPAL_ID = az ad signed-in-user show --query id -o tsv +$PRINCIPAL_TYPE = "User" +``` + +**Foundry (per-agent identity, after `azd deploy`):** + +`azd ai agent show` returns the per-agent identity's object id under `instance_identity.principal_id`: + +```bash +PRINCIPAL_ID=$(azd ai agent show -o json | jq -r '.instance_identity.principal_id') +PRINCIPAL_TYPE="ServicePrincipal" +``` + +```powershell +$PRINCIPAL_ID = (azd ai agent show -o json | ConvertFrom-Json).instance_identity.principal_id +$PRINCIPAL_TYPE = "ServicePrincipal" +``` + +### Blob Storage β€” Storage Blob Data Contributor + +```bash +STORAGE_SCOPE=$(az storage account show \ + --name "$AZURE_STORAGE_ACCOUNT_NAME" \ + --query id -o tsv)/blobServices/default/containers/$AZURE_STORAGE_CONTAINER_NAME + +az role assignment create \ + --assignee-object-id "$PRINCIPAL_ID" \ + --assignee-principal-type "$PRINCIPAL_TYPE" \ + --role "Storage Blob Data Contributor" \ + --scope "$STORAGE_SCOPE" +``` + +```powershell +$StorageScope = "$(az storage account show --name $env:AZURE_STORAGE_ACCOUNT_NAME --query id -o tsv)/blobServices/default/containers/$env:AZURE_STORAGE_CONTAINER_NAME" + +az role assignment create ` + --assignee-object-id $PRINCIPAL_ID ` + --assignee-principal-type $PRINCIPAL_TYPE ` + --role "Storage Blob Data Contributor" ` + --scope $StorageScope +``` + +### Service Bus β€” Data Sender + Data Receiver + +```bash +QUEUE_SCOPE=$(az servicebus queue show \ + --namespace-name "" \ + --resource-group "" \ + --name "$AZURE_SERVICEBUS_QUEUE_NAME" \ + --query id -o tsv) + +az role assignment create \ + --assignee-object-id "$PRINCIPAL_ID" \ + --assignee-principal-type "$PRINCIPAL_TYPE" \ + --role "Azure Service Bus Data Sender" \ + --scope "$QUEUE_SCOPE" + +az role assignment create \ + --assignee-object-id "$PRINCIPAL_ID" \ + --assignee-principal-type "$PRINCIPAL_TYPE" \ + --role "Azure Service Bus Data Receiver" \ + --scope "$QUEUE_SCOPE" +``` + +```powershell +$QueueScope = az servicebus queue show --namespace-name "" --resource-group "" --name $env:AZURE_SERVICEBUS_QUEUE_NAME --query id -o tsv + +az role assignment create ` + --assignee-object-id $PRINCIPAL_ID ` + --assignee-principal-type $PRINCIPAL_TYPE ` + --role "Azure Service Bus Data Sender" ` + --scope $QueueScope + +az role assignment create ` + --assignee-object-id $PRINCIPAL_ID ` + --assignee-principal-type $PRINCIPAL_TYPE ` + --role "Azure Service Bus Data Receiver" ` + --scope $QueueScope +``` + +Role assignments take a minute or two to propagate. + +## Running the Agent Host + +In addition to the standard environment variables described in the [parent README](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/agent-framework/README.md), this sample requires the following: + +```bash +export AZURE_STORAGE_ACCOUNT_NAME="" +export AZURE_STORAGE_CONTAINER_NAME="" +export AZURE_SERVICEBUS_FQDN=".servicebus.windows.net" +export AZURE_SERVICEBUS_QUEUE_NAME="" +``` + +```powershell +$env:AZURE_STORAGE_ACCOUNT_NAME="" +$env:AZURE_STORAGE_CONTAINER_NAME="" +$env:AZURE_SERVICEBUS_FQDN=".servicebus.windows.net" +$env:AZURE_SERVICEBUS_QUEUE_NAME="" +``` + +Follow the instructions in the [Running the Agent Host Locally](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/agent-framework/README.md#running-the-agent-host-locally) section of the README in the parent directory to run the agent host. + +## Interacting with the agent + +> Depending on how you run the agent host, you can invoke the agent using `curl` (`Invoke-WebRequest` in PowerShell) or `azd`. Please refer to the [parent README](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/agent-framework/README.md) for more details. Use this README for sample queries you can send to the agent. + +**Blob Storage β€” write and read back:** + +```powershell +azd ai agent invoke 'Upload a blob named hello.txt with the content "hi from the agent".' +azd ai agent invoke 'Read the blob hello.txt and tell me what it contains.' +``` + +**Service Bus β€” send and peek:** + +```powershell +azd ai agent invoke 'Send a Service Bus message with the body {"orderId": 42}.' +azd ai agent invoke 'Peek the next message on the queue.' +``` + +Or hit the local endpoint directly: + +```bash +curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "Read the blob hello.txt and tell me what it contains."}' +``` + +```powershell +(Invoke-WebRequest -Uri http://localhost:8088/responses -Method POST -ContentType "application/json" -Body '{"input": "Read the blob hello.txt and tell me what it contains."}').Content +``` + +## Deploying the Agent to Foundry + +[agent.yaml](agent.yaml) declares the same four environment variables and binds each value to an `${...}` placeholder that `azd` resolves from the **azd environment** at deploy time (your shell's `export` / `$env:` values are not propagated to the deployed agent). Set them once with `azd env set` before deploying: + +```powershell +azd env set AZURE_STORAGE_ACCOUNT_NAME "" +azd env set AZURE_STORAGE_CONTAINER_NAME "" +azd env set AZURE_SERVICEBUS_FQDN ".servicebus.windows.net" +azd env set AZURE_SERVICEBUS_QUEUE_NAME "" +``` + +Then follow the instructions in the [Deploying the Agent to Foundry](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/agent-framework/README.md#deploying-the-agent-to-foundry) section of the README in the parent directory. After deployment, apply the role assignments described in [Granting the agent data-plane access](#granting-the-agent-data-plane-access) to the **per-agent identity** before invoking the deployed agent. + +## Troubleshooting + +### `AuthorizationPermissionMismatch` from Storage + +The role assignment hasn't propagated yet, or the scope is wrong. Confirm the assignment with `az role assignment list --assignee "$PRINCIPAL_ID" --all` and verify the scope ends with `/containers/`. + +### `Unauthorized` from Service Bus + +Make sure you assigned **both** Sender and Receiver if the agent does both send and peek/receive. Sender alone cannot peek. + +### Local runs fail with credential errors + +`DefaultAzureCredential` falls back to your developer identity locally. Run `az login` and assign your user the same roles on the same scopes. diff --git a/samples/python/hosted-agents/agent-framework/responses/09-downstream-azure/agent.manifest.yaml b/samples/python/hosted-agents/agent-framework/responses/09-downstream-azure/agent.manifest.yaml new file mode 100644 index 000000000..106c6e115 --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/09-downstream-azure/agent.manifest.yaml @@ -0,0 +1,39 @@ +name: agent-framework-agent-downstream-azure-responses +description: > + An Agent Framework hosted agent that operates on Azure Blob Storage and + Service Bus using its per-agent Microsoft Entra identity. Demonstrates the + manual RBAC assignment pattern for Azure data-plane services. +metadata: + tags: + - Agent Framework + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Agent Identity + - Azure Storage + - Azure Service Bus +template: + name: agent-framework-agent-downstream-azure-responses + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 + environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" + # Azure Blob Storage. The per-agent identity needs a data-plane role + # (e.g. Storage Blob Data Contributor) on the container β€” see README. + - name: AZURE_STORAGE_ACCOUNT_NAME + value: ${AZURE_STORAGE_ACCOUNT_NAME} + - name: AZURE_STORAGE_CONTAINER_NAME + value: ${AZURE_STORAGE_CONTAINER_NAME} + # Azure Service Bus queue. The per-agent identity needs Azure Service Bus + # Data Sender (to send) and Azure Service Bus Data Receiver (to peek/receive). + - name: AZURE_SERVICEBUS_FQDN + value: ${AZURE_SERVICEBUS_FQDN} + - name: AZURE_SERVICEBUS_QUEUE_NAME + value: ${AZURE_SERVICEBUS_QUEUE_NAME} +resources: + - kind: model + id: gpt-4.1-mini + name: AZURE_AI_MODEL_DEPLOYMENT_NAME diff --git a/samples/python/hosted-agents/agent-framework/responses/09-downstream-azure/agent.yaml b/samples/python/hosted-agents/agent-framework/responses/09-downstream-azure/agent.yaml new file mode 100644 index 000000000..115fafe75 --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/09-downstream-azure/agent.yaml @@ -0,0 +1,32 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml + +kind: hosted +name: agent-framework-agent-downstream-azure-responses +description: | + An Agent Framework hosted agent that operates on Azure Blob Storage and Service Bus using its per-agent Microsoft Entra identity. Demonstrates the manual RBAC assignment pattern for Azure data-plane services. +metadata: + tags: + - Agent Framework + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Agent Identity + - Azure Storage + - Azure Service Bus +protocols: + - protocol: responses + version: 1.0.0 +resources: + cpu: "0.25" + memory: 0.5Gi +environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} + - name: AZURE_STORAGE_ACCOUNT_NAME + value: ${AZURE_STORAGE_ACCOUNT_NAME} + - name: AZURE_STORAGE_CONTAINER_NAME + value: ${AZURE_STORAGE_CONTAINER_NAME} + - name: AZURE_SERVICEBUS_FQDN + value: ${AZURE_SERVICEBUS_FQDN} + - name: AZURE_SERVICEBUS_QUEUE_NAME + value: ${AZURE_SERVICEBUS_QUEUE_NAME} diff --git a/samples/python/hosted-agents/agent-framework/responses/09-downstream-azure/main.py b/samples/python/hosted-agents/agent-framework/responses/09-downstream-azure/main.py new file mode 100644 index 000000000..ed1d7d48a --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/09-downstream-azure/main.py @@ -0,0 +1,52 @@ +# Copyright (c) Microsoft. All rights reserved. + +import logging +import os + +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient +from agent_framework_foundry_hosting import ResponsesHostServer +from azure.identity import DefaultAzureCredential +from dotenv import load_dotenv + +from tools import ALL_TOOLS + +load_dotenv() + +logger = logging.getLogger(__name__) + + +def main(): + if not os.environ.get("APPLICATIONINSIGHTS_CONNECTION_STRING"): + logger.warning( + "APPLICATIONINSIGHTS_CONNECTION_STRING not set β€” traces will not be sent " + "to Application Insights. Set it to enable local telemetry." + ) + + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=DefaultAzureCredential(), + ) + + agent = Agent( + client=client, + instructions=( + "You are an assistant that performs data-plane operations on two " + "Azure services on the user's behalf using your per-agent Microsoft " + "Entra identity:\n" + " - Azure Blob Storage (storage_* tools)\n" + " - Azure Service Bus (servicebus_* tools)\n" + "Pick the tool that matches the service the user named. Confirm each " + "action briefly when complete." + ), + tools=ALL_TOOLS, + default_options={"store": False}, + ) + + server = ResponsesHostServer(agent) + server.run() + + +if __name__ == "__main__": + main() diff --git a/samples/python/hosted-agents/agent-framework/responses/09-downstream-azure/requirements.txt b/samples/python/hosted-agents/agent-framework/responses/09-downstream-azure/requirements.txt new file mode 100644 index 000000000..ea62383e5 --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/09-downstream-azure/requirements.txt @@ -0,0 +1,4 @@ +agent-framework>=1.2.2 +agent-framework-foundry-hosting +azure-storage-blob +azure-servicebus diff --git a/samples/python/hosted-agents/agent-framework/responses/09-downstream-azure/sample.yaml b/samples/python/hosted-agents/agent-framework/responses/09-downstream-azure/sample.yaml new file mode 100644 index 000000000..82c9b5e23 --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/09-downstream-azure/sample.yaml @@ -0,0 +1,5 @@ +name: Hosted Agent β€” Downstream Azure (Storage + Service Bus) +description: An Agent Framework hosted agent that operates on Azure Blob Storage and Service Bus using its per-agent Microsoft Entra identity. + +build: "pip install -r requirements.txt" +validate: "python -m compileall -q ." diff --git a/samples/python/hosted-agents/agent-framework/responses/09-downstream-azure/tools/__init__.py b/samples/python/hosted-agents/agent-framework/responses/09-downstream-azure/tools/__init__.py new file mode 100644 index 000000000..49235ec1a --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/09-downstream-azure/tools/__init__.py @@ -0,0 +1,25 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tool functions for the downstream-Azure sample. + +Each module wraps one Azure data-plane SDK with a small set of generic CRUD-ish +tools. All tools build their SDK client with ``DefaultAzureCredential()`` so +that, in the hosted sandbox, calls authenticate as the agent's per-agent +Microsoft Entra identity. +""" + +from .servicebus import ( + servicebus_peek_messages, + servicebus_send_message, +) +from .storage import ( + storage_get_blob, + storage_put_blob, +) + +ALL_TOOLS = [ + storage_put_blob, + storage_get_blob, + servicebus_send_message, + servicebus_peek_messages, +] diff --git a/samples/python/hosted-agents/agent-framework/responses/09-downstream-azure/tools/servicebus.py b/samples/python/hosted-agents/agent-framework/responses/09-downstream-azure/tools/servicebus.py new file mode 100644 index 000000000..c13311345 --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/09-downstream-azure/tools/servicebus.py @@ -0,0 +1,60 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Azure Service Bus tools (queue, namespace-MI auth). + +RBAC: the calling principal needs ``Azure Service Bus Data Sender`` to send +and ``Azure Service Bus Data Receiver`` to peek/receive β€” see the sample +README. +""" + +import logging +import os +from typing import Annotated + +from agent_framework import tool +from azure.identity import DefaultAzureCredential +from azure.servicebus import ServiceBusClient, ServiceBusMessage +from pydantic import Field + +logger = logging.getLogger(__name__) + + +def _client() -> ServiceBusClient: + fqdn = os.environ["AZURE_SERVICEBUS_FQDN"] + return ServiceBusClient( + fully_qualified_namespace=fqdn, + credential=DefaultAzureCredential(), + ) + + +def _queue_name() -> str: + return os.environ["AZURE_SERVICEBUS_QUEUE_NAME"] + + +@tool(approval_mode="never_require") +def servicebus_send_message( + body: Annotated[str, Field(description="Message body as text.")], +) -> str: + """Send a single message to the configured Service Bus queue.""" + queue = _queue_name() + with _client() as client, client.get_queue_sender(queue) as sender: + sender.send_messages(ServiceBusMessage(body)) + logger.info("Sent message to %s (%d bytes)", queue, len(body)) + return f"Sent message to queue '{queue}'." + + +@tool(approval_mode="never_require") +def servicebus_peek_messages( + max_count: Annotated[ + int, + Field(description="Maximum number of messages to peek.", ge=1, le=50), + ] = 10, +) -> str: + """Peek up to ``max_count`` messages from the queue without removing them.""" + queue = _queue_name() + with _client() as client, client.get_queue_receiver(queue) as receiver: + msgs = receiver.peek_messages(max_message_count=max_count) + if not msgs: + return "Queue is empty." + bodies = [str(m) for m in msgs] + return f"Peeked {len(bodies)} message(s):\n" + "\n".join(f"- {b}" for b in bodies) diff --git a/samples/python/hosted-agents/agent-framework/responses/09-downstream-azure/tools/storage.py b/samples/python/hosted-agents/agent-framework/responses/09-downstream-azure/tools/storage.py new file mode 100644 index 000000000..92c5c649e --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/responses/09-downstream-azure/tools/storage.py @@ -0,0 +1,60 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Azure Blob Storage tools. + +RBAC: the calling principal must have ``Storage Blob Data Contributor`` (or a +narrower equivalent) on the target container β€” see the sample README. +""" + +import logging +import os +from typing import Annotated + +from agent_framework import tool +from azure.core.exceptions import ResourceExistsError, ResourceNotFoundError +from azure.identity import DefaultAzureCredential +from azure.storage.blob import BlobServiceClient, ContainerClient +from pydantic import Field + +logger = logging.getLogger(__name__) + + +def _container_client() -> ContainerClient: + account = os.environ["AZURE_STORAGE_ACCOUNT_NAME"] + container = os.environ["AZURE_STORAGE_CONTAINER_NAME"] + service = BlobServiceClient( + account_url=f"https://{account}.blob.core.windows.net", + credential=DefaultAzureCredential(), + ) + return service.get_container_client(container) + + +@tool(approval_mode="never_require") +def storage_put_blob( + name: Annotated[str, Field(description="Blob name (acts as the key).")], + content: Annotated[str, Field(description="Blob content as text.")], +) -> str: + """Upsert a blob in the configured Azure Storage container.""" + container = _container_client() + try: + container.upload_blob(name=name, data=content.encode("utf-8"), overwrite=True) + except ResourceNotFoundError: + try: + container.create_container() + except ResourceExistsError: + pass + container.upload_blob(name=name, data=content.encode("utf-8"), overwrite=True) + logger.info("Uploaded blob %s (%d bytes)", name, len(content)) + return f"Uploaded blob '{name}'." + + +@tool(approval_mode="never_require") +def storage_get_blob( + name: Annotated[str, Field(description="Blob name to read.")], +) -> str: + """Read a blob's content as text.""" + container = _container_client() + try: + return container.download_blob(name).readall().decode("utf-8") + except ResourceNotFoundError: + return f"No blob named '{name}'." diff --git a/samples/python/hosted-agents/bring-your-own/invocations/ag-ui/README.md b/samples/python/hosted-agents/bring-your-own/invocations/ag-ui/README.md index 792baf49d..955589473 100644 --- a/samples/python/hosted-agents/bring-your-own/invocations/ag-ui/README.md +++ b/samples/python/hosted-agents/bring-your-own/invocations/ag-ui/README.md @@ -146,7 +146,7 @@ Error calling Azure OpenAI: Error code: 401 - {'error': {'code': 'PermissionDeni The identity running the agent does not have the required RBAC roles on the Azure AI Foundry project. Assign the following roles: - **Cognitive Services OpenAI User** -- **Azure AI User** +- **Foundry User** Use the Azure CLI to assign them: @@ -163,10 +163,10 @@ az role assignment create \ --role "Cognitive Services OpenAI User" \ --scope "/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.MachineLearningServices/workspaces/$PROJECT_NAME" -# Assign "Azure AI User" role +# Assign "Foundry User" role az role assignment create \ --assignee "$PRINCIPAL_ID" \ - --role "Azure AI User" \ + --role "Foundry User" \ --scope "/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.MachineLearningServices/workspaces/$PROJECT_NAME" ``` diff --git a/samples/python/hosted-agents/bring-your-own/invocations/ag-ui/agent.yaml b/samples/python/hosted-agents/bring-your-own/invocations/ag-ui/agent.yaml index 615be91d1..9957b514d 100644 --- a/samples/python/hosted-agents/bring-your-own/invocations/ag-ui/agent.yaml +++ b/samples/python/hosted-agents/bring-your-own/invocations/ag-ui/agent.yaml @@ -7,3 +7,6 @@ protocols: resources: cpu: "0.25" memory: 0.5Gi +environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" diff --git a/samples/python/hosted-agents/bring-your-own/invocations/ag-ui/test-payload.txt b/samples/python/hosted-agents/bring-your-own/invocations/ag-ui/test-payload.txt deleted file mode 100644 index 3a6254965..000000000 --- a/samples/python/hosted-agents/bring-your-own/invocations/ag-ui/test-payload.txt +++ /dev/null @@ -1 +0,0 @@ -{"threadId":"thread-ci","runId":"run-ci","state":{},"messages":[{"id":"msg-1","role":"user","content":"Hello!"}],"tools":[],"context":[],"forwardedProps":{}} diff --git a/samples/python/hosted-agents/bring-your-own/invocations/claude-agent-sdk/.ci-skip b/samples/python/hosted-agents/bring-your-own/invocations/claude-agent-sdk/.ci-skip new file mode 100644 index 000000000..9e8b786c6 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/invocations/claude-agent-sdk/.ci-skip @@ -0,0 +1 @@ +This sample requires external Anthropic credentials and is excluded from the hosted agents e2e CI pipeline. diff --git a/samples/python/hosted-agents/bring-your-own/invocations/claude-agent-sdk/.claude/skills/joke/SKILL.md b/samples/python/hosted-agents/bring-your-own/invocations/claude-agent-sdk/.claude/skills/joke/SKILL.md new file mode 100644 index 000000000..193663bd3 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/invocations/claude-agent-sdk/.claude/skills/joke/SKILL.md @@ -0,0 +1,9 @@ +--- +name: joke +description: Tell jokes with a pirate tone when appropriate. +--- + +# Joke Skill + +When the user asks for a joke, answer in a friendly pirate tone. +Keep the response concise. diff --git a/samples/python/hosted-agents/bring-your-own/invocations/claude-agent-sdk/.dockerignore b/samples/python/hosted-agents/bring-your-own/invocations/claude-agent-sdk/.dockerignore new file mode 100644 index 000000000..c1b312c48 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/invocations/claude-agent-sdk/.dockerignore @@ -0,0 +1,23 @@ +**/__pycache__/ +**/*.py[cod] +**/*.egg-info/ +.eggs/ + +# Virtual environments +.venv/ +venv/ +env/ + +# IDE settings +.vscode/ +.idea/ + +# Version control +.git/ +.gitignore + +# Docker files +.dockerignore + +# Local env files +.env diff --git a/samples/python/hosted-agents/bring-your-own/invocations/claude-agent-sdk/.env.example b/samples/python/hosted-agents/bring-your-own/invocations/claude-agent-sdk/.env.example new file mode 100644 index 000000000..65490f458 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/invocations/claude-agent-sdk/.env.example @@ -0,0 +1,9 @@ +# Enable Claude Agent SDK to use Microsoft Foundry. +CLAUDE_CODE_USE_FOUNDRY=1 + +# Required value for startup. +# ANTHROPIC_MODEL=claude-opus-4-7 + +# Application Insights is auto-injected in hosted containers. +# Set this for local telemetry if desired. +# APPLICATIONINSIGHTS_CONNECTION_STRING=InstrumentationKey=... diff --git a/samples/python/hosted-agents/bring-your-own/invocations/claude-agent-sdk/Dockerfile b/samples/python/hosted-agents/bring-your-own/invocations/claude-agent-sdk/Dockerfile new file mode 100644 index 000000000..7350bc536 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/invocations/claude-agent-sdk/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY . user_agent/ +WORKDIR /app/user_agent + +RUN pip install --no-input --upgrade pip && \ + if [ -f requirements.txt ]; then \ + pip install --no-input -r requirements.txt; \ + else \ + echo "No requirements.txt found"; \ + fi + +EXPOSE 8088 + +CMD ["python", "main.py"] diff --git a/samples/python/hosted-agents/bring-your-own/invocations/claude-agent-sdk/README.md b/samples/python/hosted-agents/bring-your-own/invocations/claude-agent-sdk/README.md new file mode 100644 index 000000000..3d470798e --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/invocations/claude-agent-sdk/README.md @@ -0,0 +1,183 @@ +# Claude Agent SDK β€” Invocations Protocol (Streaming) + +**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. + +A minimal getting-started agent using the [Claude Agent SDK](https://pypi.org/project/claude-agent-sdk/) with [azure-ai-agentserver-invocations](https://pypi.org/project/azure-ai-agentserver-invocations/) protocol support. + +This sample is configured for **Microsoft Foundry** mode by default + +## How It Works + +1. Receives plain text via `POST /invocations` +2. Uses Claude Agent SDK `query()` to process the input +3. Streams assistant text chunks directly without buffering +4. Emits response and handles errors gracefully + +## Environment Variables + +| Variable | Required | Description | +| -------- | -------- | ----------- | +| `FOUNDRY_PROJECT_ENDPOINT` | Auto-injected | Automatically provided by Foundry when agent is invoked | +| `CLAUDE_CODE_USE_FOUNDRY` | Yes (default `1`) | Enables Foundry integration path in Claude Agent SDK | +| `ANTHROPIC_MODEL` | Built-in default | Explicit startup model set to `claude-opus-4-7` | +| `ANTHROPIC_FOUNDRY_BASE_URL` | Auto-generated | Automatically constructed from `FOUNDRY_PROJECT_ENDPOINT` as `https://.services.ai.azure.com/anthropic` | + +## Running Locally + +### Prerequisites + +- Python 3.10+ +- `az login` +- A Foundry resource with Claude model deployments + +### Using `azd` (Recommended) + +```bash + +azd ai agent run +``` + +This sample sets `ANTHROPIC_MODEL=claude-opus-4-7` in YAML, you can change the model here. + +### Without `azd` + +```bash +pip install -r requirements.txt +cp .env.example .env +python main.py +``` + +## Invoke + +### Local + +```bash +azd ai agent invoke --local "Hey hi" +``` + +### Curl + +```bash +curl -sS -N -X POST http://localhost:8088/invocations \ + -H "Content-Type: text/plain" \ + -d "List the main Python files in this folder." +``` + +## Deploying to Microsoft Foundry + +```bash +azd provision +azd deploy +``` + + +## ⚠️ CRITICAL: RBAC Configuration After Deployment + +**IMPORTANT!** After running `azd deploy`, you **MUST** assign the `Foundry User` role at the **account scope** to your agent's runtime identity. Without this, your agent will fail with a `401 Unauthorized` error when attempting to invoke the Claude model. + +### Why This Is Required + +Azure AI Foundry enforces authorization at two levels: + +1. **Project Scope**: Controls agent orchestration and project operations +2. **Account Scope**: Controls model inference API calls (required for Claude SDK calls) + +Without the account-level `Foundry User` role, your agent will initialize but fail when trying to call the model API. + +### Step-by-Step RBAC Setup + +#### Step 1: Get Your Agent's Runtime Principal ID + +After deployment, retrieve your agent's runtime principal ID: + +```bash +azd ai agent show +``` + +Look for the `instance_identity.principal_id` in the output: + +```json +"instance_identity": { + "principal_id": "11111111-2222-3333-4444-555555555555", + "client_id": "11111111-2222-3333-4444-555555555555" +} +``` + +Save this `principal_id` β€” you'll need it for the next step. + +#### Step 2: Collect Required Information + +Get your Azure subscription ID, resource group, and account name: + +```bash +# Get subscription ID +az account show --query id -o tsv + +# Get resource group (if not known) +az group list --query "[0].name" -o tsv + +# Get account name from environment (look for AZURE_AI_ACCOUNT_NAME in the output) +azd env get-values +``` + +From the `azd env get-values` output, find the line with `AZURE_AI_ACCOUNT_NAME` and copy that value. Do not leave `myFoundryAccount` in the command examples below; replace it with your actual Azure AI account name. + +#### Step 3: Assign Foundry User Role at Account Scope + +Run this command, replacing the placeholders with your values: + +**For Bash/Linux/macOS:** + +```bash +az role assignment create \ + --assignee-object-id \ + --assignee-principal-type ServicePrincipal \ + --role "Foundry User" \ + --scope /subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts/ +``` + +**For PowerShell (Windows):** + +```powershell +az role assignment create --assignee-object-id --assignee-principal-type ServicePrincipal --role "Foundry User" --scope "/subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts/" +``` + +#### Step 4: Verify the Role Assignment + +Confirm the role is assigned at account scope: + +```bash +az role assignment list \ + --assignee-object-id \ + --all -o table +``` + +For PowerShell, a single-line version is safest: + +```powershell +az role assignment list --assignee-object-id --all -o table +``` + +You should see both: + +- `Foundry User` at the project scope +- `Foundry User` at the account scope (this is the critical one) + +#### Step 5: Wait for RBAC Propagation + +Azure RBAC changes can take **2-5 minutes** to propagate. Wait before testing. + +#### Step 6: Test Your Agent + +After waiting, test with a new session: + +```bash +azd ai agent invoke --new-session "Hey hi" +``` + +If successful, you'll see the Claude model's response streaming through. If it still fails with a 401 error: + +- Verify the principal ID matches exactly by running `azd ai agent show` and checking `instance_identity.principal_id` +- Check the account scope path is correct +- Ensure you've waited 5+ minutes for role propagation +- Verify the role assignment: `az role assignment list --assignee-object-id --all -o table` diff --git a/samples/python/hosted-agents/bring-your-own/invocations/claude-agent-sdk/agent.manifest.yaml b/samples/python/hosted-agents/bring-your-own/invocations/claude-agent-sdk/agent.manifest.yaml new file mode 100644 index 000000000..87806dbaf --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/invocations/claude-agent-sdk/agent.manifest.yaml @@ -0,0 +1,30 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: claude-agent-sdk-invocations +displayName: "Claude Agent SDK (Invocations)" +description: > + A getting-started agent that uses the Claude Agent SDK with Microsoft Foundry + authentication and the azure-ai-agentserver-invocations protocol, streaming + assistant deltas as Server-Sent Events (SSE). +metadata: + tags: + - AI Agent Hosting + - Claude Agent SDK + - Anthropic + - Invocations Protocol + - Bring Your Own +template: + name: claude-agent-sdk-invocations + kind: hosted + protocols: + - protocol: invocations + version: 1.0.0 + environment_variables: + # APPLICATIONINSIGHTS_CONNECTION_STRING is auto-injected by the platform. + # Do not declare it here. + - name: CLAUDE_CODE_USE_FOUNDRY + value: "1" + - name: ANTHROPIC_MODEL + value: "claude-opus-4-7" +parameters: + properties: [] +resources: [] diff --git a/samples/python/hosted-agents/bring-your-own/invocations/claude-agent-sdk/agent.yaml b/samples/python/hosted-agents/bring-your-own/invocations/claude-agent-sdk/agent.yaml new file mode 100644 index 000000000..63a0a3756 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/invocations/claude-agent-sdk/agent.yaml @@ -0,0 +1,24 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml + +kind: hosted +name: claude-agent-sdk-invocations +description: | + A getting-started agent that uses the Claude Agent SDK with Microsoft Foundry authentication and the azure-ai-agentserver-invocations protocol, streaming assistant deltas as Server-Sent Events (SSE). +metadata: + tags: + - AI Agent Hosting + - Claude Agent SDK + - Anthropic + - Invocations Protocol + - Bring Your Own +protocols: + - protocol: invocations + version: 1.0.0 +resources: + cpu: "0.25" + memory: 0.5Gi +environment_variables: + - name: CLAUDE_CODE_USE_FOUNDRY + value: "1" + - name: ANTHROPIC_MODEL + value: "claude-opus-4-7" diff --git a/samples/python/hosted-agents/bring-your-own/invocations/claude-agent-sdk/main.py b/samples/python/hosted-agents/bring-your-own/invocations/claude-agent-sdk/main.py new file mode 100644 index 000000000..2182de8d9 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/invocations/claude-agent-sdk/main.py @@ -0,0 +1,67 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Getting-started: Claude Agent SDK with Foundry auth and invocations protocol.""" + +import os +import json +from dataclasses import asdict +from urllib.parse import urlparse + +from starlette.requests import Request +from starlette.responses import PlainTextResponse, Response, StreamingResponse + +from azure.ai.agentserver.invocations import InvocationAgentServerHost +from claude_agent_sdk import ClaudeAgentOptions, query + +project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] +resource_name = urlparse(project_endpoint).netloc.split(".")[0] +os.environ["ANTHROPIC_FOUNDRY_BASE_URL"] = f"https://{resource_name}.services.ai.azure.com/anthropic" + +app = InvocationAgentServerHost() + + +@app.invoke_handler +async def handle_invoke(request: Request) -> Response: + try: + input_text = (await request.body()).decode("utf-8").strip() + if not input_text: + raise ValueError("empty request body") + except (UnicodeDecodeError, ValueError): + return PlainTextResponse( + status_code=400, + content="Request body must be a non-empty plain text string.", + ) + + async def event_generator(): + prompt = input_text + options = ClaudeAgentOptions( + permission_mode="dontAsk", + model=os.environ["ANTHROPIC_MODEL"], + include_partial_messages=True, + system_prompt=( + "You are a helpful coding assistant running in a hosted invocations endpoint. " + "Prefer concise, actionable responses." + ), + ) + + try: + async for message in query(prompt=prompt, options=options): + yield f"data: {json.dumps(asdict(message))}\n\n".encode("utf-8") + except Exception as ex: + error_payload = json.dumps({"error": str(ex)}) + yield f"data: {error_payload}\n\n".encode("utf-8") + return + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + +if __name__ == "__main__": + app.run() diff --git a/samples/python/hosted-agents/bring-your-own/invocations/claude-agent-sdk/requirements.txt b/samples/python/hosted-agents/bring-your-own/invocations/claude-agent-sdk/requirements.txt new file mode 100644 index 000000000..a9f05b5d4 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/invocations/claude-agent-sdk/requirements.txt @@ -0,0 +1,3 @@ +claude-agent-sdk>=0.1.74 +azure-ai-agentserver-invocations==1.0.0b3 +httpx<1.0 diff --git a/samples/python/hosted-agents/bring-your-own/invocations/event-grid-trigger/.dockerignore b/samples/python/hosted-agents/bring-your-own/invocations/event-grid-trigger/.dockerignore new file mode 100644 index 000000000..1cc28355d --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/invocations/event-grid-trigger/.dockerignore @@ -0,0 +1,14 @@ +**/__pycache__/ +**/*.py[cod] +**/*.egg-info/ +.eggs/ + +# Virtual environments +.venv/ +venv/ +env/ + +# Local config +.env +.azure/ +*.log diff --git a/samples/csharp/toolbox/maf/ToolboxMafAgent/.env.example b/samples/python/hosted-agents/bring-your-own/invocations/event-grid-trigger/.env.example similarity index 52% rename from samples/csharp/toolbox/maf/ToolboxMafAgent/.env.example rename to samples/python/hosted-agents/bring-your-own/invocations/event-grid-trigger/.env.example index a04c04481..1ac9f50b3 100644 --- a/samples/csharp/toolbox/maf/ToolboxMafAgent/.env.example +++ b/samples/python/hosted-agents/bring-your-own/invocations/event-grid-trigger/.env.example @@ -3,11 +3,18 @@ # FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ # Model deployment name β€” must match a deployment in your Foundry project. -MODEL_DEPLOYMENT_NAME= +AZURE_AI_MODEL_DEPLOYMENT_NAME= # Application Insights β€” auto-injected in hosted containers. # Set for local telemetry (optional but recommended). # APPLICATIONINSIGHTS_CONNECTION_STRING=InstrumentationKey=... -# Toolbox MCP endpoint β€” full URL including toolbox name and api-version. -# TOOLBOX_ENDPOINT=https://.services.ai.azure.com/api/projects//toolboxes//mcp?api-version=v1 +# --- Azure Storage --- +# The per-agent identity must have: +# "Storage Blob Data Reader" on the source container +# "Storage Blob Data Contributor" on the summary (output) container +AZURE_STORAGE_ACCOUNT_NAME= + +# Container the agent writes .summary.json to. Must be a different +# container from the input one so writes do not re-trigger Event Grid. +AZURE_STORAGE_SUMMARY_CONTAINER_NAME= diff --git a/samples/python/toolbox/langgraph/Dockerfile b/samples/python/hosted-agents/bring-your-own/invocations/event-grid-trigger/Dockerfile similarity index 64% rename from samples/python/toolbox/langgraph/Dockerfile rename to samples/python/hosted-agents/bring-your-own/invocations/event-grid-trigger/Dockerfile index cd962d026..b89292edb 100644 --- a/samples/python/toolbox/langgraph/Dockerfile +++ b/samples/python/hosted-agents/bring-your-own/invocations/event-grid-trigger/Dockerfile @@ -1,7 +1,7 @@ -FROM mcr.microsoft.com/azurelinux/base/python:3.12 +FROM python:3.12-slim WORKDIR /app COPY . user_agent/ WORKDIR /app/user_agent RUN if [ -f requirements.txt ]; then pip install -r requirements.txt; fi EXPOSE 8088 -CMD ["python3", "main.py"] +CMD ["python", "main.py"] diff --git a/samples/python/hosted-agents/bring-your-own/invocations/event-grid-trigger/README.md b/samples/python/hosted-agents/bring-your-own/invocations/event-grid-trigger/README.md new file mode 100644 index 000000000..716f29efc --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/invocations/event-grid-trigger/README.md @@ -0,0 +1,344 @@ +# What this sample demonstrates + +A **Bring Your Own** [Invocations protocol](https://learn.microsoft.com/en-us/azure/foundry/agents/concepts/hosted-agents) hosted agent that is **event-driven from Azure Storage**, with Event Grid POSTing **directly** to the agent. Event Grid authenticates the delivery with the Event Grid system topic's **system-assigned managed identity (SAMI)** whose AAD audience is set to `https://ai.azure.com` (the Foundry data plane), so the agent's standard token validation accepts the request. The agent then reads the blob with its **per-agent Microsoft Entra identity**, summarizes it with a Foundry model, and writes the result as `.summary.json` to a **separate summary container** so the full pipeline is verifiable from Storage alone. + +End-to-end flow: + +``` +user uploads blob β†’ input container β†’ Event Grid system topic + β†’ EG delivery (system topic SAMI mints AAD token for https://ai.azure.com) + β†’ POST EG event batch to + β†’ agent extracts (container, name) from data.url, + downloads blob (per-agent MI), + calls model to summarize, + writes .summary.json to the summary container + ↳ also logged to stdout (azd ai agent monitor) +``` + +Using a **sibling output container** instead of the input container is what keeps the pipeline loop-free: the Event Grid subscription is scoped to the input container, so writes to the summary container never re-fire it. + +This is the canonical **event-driven Azure** pattern for hosted agents. It complements [`09-downstream-azure`](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/agent-framework/responses/09-downstream-azure/README.md), which shows a chat-driven agent calling Azure data-plane services; here, an Azure event source pushes work *into* the agent. + +## How It Works + +### Authentication + +Event Grid supports [delivery with managed identity](https://learn.microsoft.com/en-us/azure/event-grid/managed-service-identity): each event POST carries an AAD bearer token minted from a managed identity attached to the topic. By enabling a **system-assigned managed identity (SAMI)** on the system topic, setting the delivery audience to `https://ai.azure.com`, and giving that SAMI the **Foundry User** role on the Foundry project, the agent's invocations endpoint accepts the call as if it came from any other Foundry caller β€” no separate identity resource, no shared secrets, and no agent-side code to verify the EG handshake header. + +### The handler + +See [`main.py`](main.py). The handler accepts three POST shapes: + +1. **Event Grid `SubscriptionValidationEvent`** β€” answered with `{"validationResponse": ""}` so the EG subscription can finish provisioning. +2. **Event Grid `Microsoft.Storage.BlobCreated` batch** β€” the container and blob name are extracted from `data.url`. +3. **Direct `{"container": "...", "name": "..."}`** β€” useful for quick local invokes via `azd ai agent invoke`. + +For (2) and (3), the agent downloads the blob (truncated to 64 KiB), summarizes it with the Foundry Responses API, writes `.summary.json` to the configured summary container (`AZURE_STORAGE_SUMMARY_CONTAINER_NAME`), and logs `event-grid-trigger:summary blob=/ output=/.summary.json …`. Stream the logs with `azd ai agent monitor`, or just open the summary container in the Azure portal or Storage Explorer. + +> The summary container is intentionally **different** from the input container. The Event Grid subscription is scoped to the input container, so writes to a sibling container do not re-trigger the agent. Do not point the agent's output at the same container the EG subscription watches. + +## Prerequisites + +In addition to the prerequisites listed in the [parent README](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/README.md), this sample requires: + +- An **Azure Storage account** with two existing containers: one for **inputs** (the Event Grid subscription watches this one) and a separate one for **summaries** the agent writes back. The two must be distinct so writes to the summary container don't re-trigger the agent. + +Set the following shell variables β€” the rest of the commands below assume them. + +Bash: + +```bash +RG="" +AZURE_STORAGE_ACCOUNT_NAME="" +INPUT_CONTAINER="" +SUMMARY_CONTAINER="" +FOUNDRY_ACCOUNT_NAME="" +FOUNDRY_PROJECT_NAME="" +``` + +PowerShell: + +```powershell +$RG = "" +$AZURE_STORAGE_ACCOUNT_NAME = "" +$INPUT_CONTAINER = "" +$SUMMARY_CONTAINER = "" +$FOUNDRY_ACCOUNT_NAME = "" +$FOUNDRY_PROJECT_NAME = "" +``` + +## 1. Deploy the agent + +[agent.yaml](agent.yaml) declares two environment variables and binds each value to an `${...}` placeholder that `azd` resolves from the **azd environment** at deploy time (your shell's `export` / `$env:` values are not propagated to the deployed agent). Set them once with `azd env set` before deploying: + +```powershell +azd env set AZURE_STORAGE_ACCOUNT_NAME "" +azd env set AZURE_STORAGE_SUMMARY_CONTAINER_NAME "" +``` + +Then follow the [Deploy any sample](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/README.md#deploy-any-sample) section in the parent README: + +```powershell +azd deploy +``` + +After deployment capture the **Agent Invocations URL** β€” `https:///agents//endpoint/protocols/invocations?api-version=2025-11-15-preview` β€” printed by `azd ai agent show`. + +## 2. Grant the per-agent identity blob and Foundry access + +The per-agent identity needs three role assignments: + +- **Storage Blob Data Reader** on the **input** container β€” to download the uploaded blob. +- **Storage Blob Data Contributor** on the **summary** container β€” to write `.summary.json`. +- **Foundry User** on the **Foundry project** β€” to call the Responses API that produces the summary. The agent calls the Foundry project data plane with its own MI; without this role the call is rejected with `401 PermissionDenied: Principal does not have access to API/Operation`. + +`azd ai agent show` returns the per-agent identity's object id under `instance_identity.principal_id`; capture it together with the storage account and project scopes, then reuse them in the assignment commands below. + +Bash: + +```bash +PRINCIPAL_ID=$(azd ai agent show -o json | jq -r '.instance_identity.principal_id') +ACCOUNT_ID=$(az storage account show -n "$AZURE_STORAGE_ACCOUNT_NAME" --query id -o tsv) +PROJECT_ID="$(az cognitiveservices account show -n "$FOUNDRY_ACCOUNT_NAME" -g "$RG" --query id -o tsv)/projects/$FOUNDRY_PROJECT_NAME" + +az role assignment create \ + --assignee-object-id "$PRINCIPAL_ID" --assignee-principal-type ServicePrincipal \ + --role "Storage Blob Data Reader" \ + --scope "$ACCOUNT_ID/blobServices/default/containers/$INPUT_CONTAINER" + +az role assignment create \ + --assignee-object-id "$PRINCIPAL_ID" --assignee-principal-type ServicePrincipal \ + --role "Storage Blob Data Contributor" \ + --scope "$ACCOUNT_ID/blobServices/default/containers/$SUMMARY_CONTAINER" + +az role assignment create \ + --assignee-object-id "$PRINCIPAL_ID" --assignee-principal-type ServicePrincipal \ + --role "Foundry User" --scope "$PROJECT_ID" +``` + +PowerShell: + +```powershell +$PRINCIPAL_ID = (azd ai agent show -o json | ConvertFrom-Json).instance_identity.principal_id +$ACCOUNT_ID = az storage account show -n $AZURE_STORAGE_ACCOUNT_NAME --query id -o tsv +$PROJECT_ID = "$(az cognitiveservices account show -n $FOUNDRY_ACCOUNT_NAME -g $RG --query id -o tsv)/projects/$FOUNDRY_PROJECT_NAME" + +az role assignment create ` + --assignee-object-id $PRINCIPAL_ID --assignee-principal-type ServicePrincipal ` + --role "Storage Blob Data Reader" ` + --scope "$ACCOUNT_ID/blobServices/default/containers/$INPUT_CONTAINER" + +az role assignment create ` + --assignee-object-id $PRINCIPAL_ID --assignee-principal-type ServicePrincipal ` + --role "Storage Blob Data Contributor" ` + --scope "$ACCOUNT_ID/blobServices/default/containers/$SUMMARY_CONTAINER" + +az role assignment create ` + --assignee-object-id $PRINCIPAL_ID --assignee-principal-type ServicePrincipal ` + --role "Foundry User" --scope $PROJECT_ID +``` + +Role assignments take a minute or two to propagate. + +## 3. Create the Event Grid system topic with a system-assigned identity + +Create the topic on the storage account with **SAMI enabled**, then grant the topic's identity **Foundry User** on the Foundry project so the bearer tokens it mints for the `https://ai.azure.com` audience are accepted by the agent's invocations endpoint. + +Bash: + +```bash +TOPIC="" +SOURCE_ID=$(az storage account show -n "$AZURE_STORAGE_ACCOUNT_NAME" --query id -o tsv) +TOPIC_LOCATION=$(az storage account show -n "$AZURE_STORAGE_ACCOUNT_NAME" --query location -o tsv) + +az eventgrid system-topic create \ + -g "$RG" -n "$TOPIC" -l "$TOPIC_LOCATION" \ + --topic-type microsoft.storage.storageaccounts --source "$SOURCE_ID" \ + --identity systemassigned + +TOPIC_PRINCIPAL_ID=$(az eventgrid system-topic show -g "$RG" -n "$TOPIC" --query identity.principalId -o tsv) +PROJECT_ID="$(az cognitiveservices account show -n "$FOUNDRY_ACCOUNT_NAME" -g "$RG" --query id -o tsv)/projects/$FOUNDRY_PROJECT_NAME" + +az role assignment create \ + --assignee-object-id "$TOPIC_PRINCIPAL_ID" --assignee-principal-type ServicePrincipal \ + --role "Foundry User" --scope "$PROJECT_ID" +``` + +PowerShell: + +```powershell +$TOPIC = "" +$SOURCE_ID = az storage account show -n $AZURE_STORAGE_ACCOUNT_NAME --query id -o tsv +$TOPIC_LOCATION = az storage account show -n $AZURE_STORAGE_ACCOUNT_NAME --query location -o tsv + +az eventgrid system-topic create ` + -g $RG -n $TOPIC -l $TOPIC_LOCATION ` + --topic-type microsoft.storage.storageaccounts --source $SOURCE_ID ` + --identity systemassigned + +$TOPIC_PRINCIPAL_ID = az eventgrid system-topic show -g $RG -n $TOPIC --query identity.principalId -o tsv +$PROJECT_ID = "$(az cognitiveservices account show -n $FOUNDRY_ACCOUNT_NAME -g $RG --query id -o tsv)/projects/$FOUNDRY_PROJECT_NAME" + +az role assignment create ` + --assignee-object-id $TOPIC_PRINCIPAL_ID --assignee-principal-type ServicePrincipal ` + --role "Foundry User" --scope $PROJECT_ID +``` + +## 4. Create the event subscription with SAMI delivery + +Tell Event Grid to deliver to the agent's invocations URL as a webhook, authenticated by the system topic's SAMI with audience `https://ai.azure.com`. + +> The `az eventgrid system-topic event-subscription create` CLI does **not** expose the `deliveryWithResourceIdentity` property needed to attach the SAMI to delivery. Create the subscription with `az rest` (a direct ARM PUT) instead. The ARM resource path is `…/systemTopics//eventSubscriptions/`. + +Bash: + +```bash +SUB_NAME="blob-to-agent" +SUB_ID=$(az account show --query id -o tsv) +TENANT_ID=$(az account show --query tenantId -o tsv) +AGENT_URL=$(azd ai agent show -o json | jq -r '.agent_endpoints.invocations') + +cat > eg-sub.json < The `@filename` syntax tells `az rest` to read the body from a file and set the correct `Content-Type` header. Passing a JSON string directly via `--body $var` in PowerShell can drop the content-type and yield `UnsupportedMediaType`. + +During the PUT, Event Grid POSTs a one-time `SubscriptionValidationEvent` to the agent's invocations URL; the handler answers it with `{"validationResponse": ""}` and the subscription transitions to `provisioningState: Succeeded`. Confirm with `az rest` (the `az eventgrid` CLI uses an older API version that cannot read subscriptions configured with `deliveryWithResourceIdentity`): + +```powershell +az rest --method get --url $url --query "properties.provisioningState" -o tsv +``` + +## 5. Try it & verify + +Upload a `.txt` (or `.md`) blob: + +Bash: + +```bash +echo "Hosted agents process Event Grid blob-created events end to end via system-assigned MI delivery." > hello.txt +az storage blob upload \ + --account-name "$AZURE_STORAGE_ACCOUNT_NAME" \ + -c "$INPUT_CONTAINER" -f hello.txt -n hello.txt --auth-mode login +``` + +PowerShell: + +```powershell +"Hosted agents process Event Grid blob-created events end to end via system-assigned MI delivery." | Set-Content hello.txt +az storage blob upload ` + --account-name $AZURE_STORAGE_ACCOUNT_NAME ` + -c $INPUT_CONTAINER -f hello.txt -n hello.txt --auth-mode login +``` + +Within a few seconds a corresponding summary blob should appear in the sibling container: + +Bash: + +```bash +az storage blob download \ + --account-name "$AZURE_STORAGE_ACCOUNT_NAME" \ + -c "$SUMMARY_CONTAINER" -n hello.txt.summary.json --auth-mode login -f - | cat +``` + +PowerShell: + +```powershell +az storage blob download ` + --account-name $AZURE_STORAGE_ACCOUNT_NAME ` + -c $SUMMARY_CONTAINER -n hello.txt.summary.json --auth-mode login -f hello.txt.summary.json +Get-Content hello.txt.summary.json -Raw +``` + +Expected payload: + +```json +{ + "input": "/hello.txt", + "elapsed_ms": 842, + "truncated": false, + "summary": "- Hosted agents …\n- …" +} +``` + +## Troubleshooting + +| Symptom | Likely cause | +|---|---| +| EG subscription provisioning fails with `Webhook validation handshake failed` | The agent didn't return the `validationResponse`. Confirm the deployed `main.py` includes the `_extract_subscription_validation_event` branch (`azd deploy`), and that the system topic's SAMI has **Foundry User** on the Foundry project so EG's token is accepted. | +| `401 Unauthorized` from the agent on real events | The system topic's SAMI is missing **Foundry User** on the Foundry project, or the subscription is configured with the wrong audience (must be `https://ai.azure.com`) or wrong tenant id. | +| `az eventgrid system-topic show --query identity.principalId` is empty | SAMI wasn't enabled on the topic. Re-run `az eventgrid system-topic create ... --identity systemassigned` (or `az eventgrid system-topic update --identity systemassigned`) and recheck. | +| Agent trace shows `401 PermissionDenied: Principal does not have access to API/Operation` | Per-agent identity is missing **Foundry User** on the Foundry project (needed to call the Responses API that summarizes). Assign it in step 2. | +| Agent returns `AuthorizationPermissionMismatch` reading the blob | Per-agent identity is missing **Storage Blob Data Reader** on the input container. | +| Summary blob is never written | Per-agent identity is missing **Storage Blob Data Contributor** on the summary container, or the container does not exist. | +| Agent fires twice per upload | Summary is being written into the **same** container the EG subscription watches β€” set `AZURE_STORAGE_SUMMARY_CONTAINER_NAME` to a different container. | +| `System topic's location must match with location of the source resource` | Create the system topic in the storage account's region (step 3 reads it via `az storage account show --query location`). | + +## See also + +- [Deliver events using managed identity](https://learn.microsoft.com/en-us/azure/event-grid/managed-service-identity) β€” the Event Grid docs for the managed-identity delivery pattern this sample uses. +- [`09-downstream-azure`](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/agent-framework/responses/09-downstream-azure/README.md) β€” the per-agent-identity + Azure RBAC pattern in a chat-driven (not event-driven) agent. diff --git a/samples/python/hosted-agents/bring-your-own/invocations/event-grid-trigger/agent.manifest.yaml b/samples/python/hosted-agents/bring-your-own/invocations/event-grid-trigger/agent.manifest.yaml new file mode 100644 index 000000000..c99424bed --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/invocations/event-grid-trigger/agent.manifest.yaml @@ -0,0 +1,45 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: event-grid-trigger-python-invocations +displayName: "Event Grid β†’ hosted agent (blob trigger, Python, Invocations)" +description: > + BYO Invocations agent that Event Grid POSTs to directly using the + system topic's system-assigned managed identity with audience + https://ai.azure.com. Receives Storage BlobCreated events, downloads + the blob with the per-agent Microsoft Entra identity, and writes a + summary back to a sibling container. +metadata: + tags: + - AI Agent Hosting + - Invocations Protocol + - Bring Your Own + - Event-Driven + - Event Grid + - Managed Identity + - Azure Storage + - Python +template: + name: event-grid-trigger-python-invocations + kind: hosted + protocols: + - protocol: invocations + version: 1.0.0 + environment_variables: + # FOUNDRY_PROJECT_ENDPOINT and APPLICATIONINSIGHTS_CONNECTION_STRING are + # injected by the platform (hosted) and translated by azd (local) β€” do not + # declare them here. + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" + # Storage. Per-agent identity needs: + # Storage Blob Data Reader on the source container + # Storage Blob Data Contributor on the summary (output) container + - name: AZURE_STORAGE_ACCOUNT_NAME + value: ${AZURE_STORAGE_ACCOUNT_NAME} + # Required: name of the container the agent writes .summary.json to. + # Must be distinct from the input container so writes do not re-trigger + # Event Grid. + - name: AZURE_STORAGE_SUMMARY_CONTAINER_NAME + value: ${AZURE_STORAGE_SUMMARY_CONTAINER_NAME} +resources: + - kind: model + id: gpt-4.1-mini + name: AZURE_AI_MODEL_DEPLOYMENT_NAME diff --git a/samples/python/hosted-agents/bring-your-own/invocations/event-grid-trigger/agent.yaml b/samples/python/hosted-agents/bring-your-own/invocations/event-grid-trigger/agent.yaml new file mode 100644 index 000000000..174aea166 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/invocations/event-grid-trigger/agent.yaml @@ -0,0 +1,18 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +kind: hosted +name: event-grid-trigger-python-invocations +description: | + BYO Invocations agent that Event Grid POSTs to directly using the system topic's system-assigned managed identity with audience https://ai.azure.com. Receives Storage BlobCreated events, downloads the blob with the per-agent Microsoft Entra identity, and writes a summary back to a sibling container. +protocols: + - protocol: invocations + version: 1.0.0 +resources: + cpu: "0.25" + memory: 0.5Gi +environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} + - name: AZURE_STORAGE_ACCOUNT_NAME + value: ${AZURE_STORAGE_ACCOUNT_NAME} + - name: AZURE_STORAGE_SUMMARY_CONTAINER_NAME + value: ${AZURE_STORAGE_SUMMARY_CONTAINER_NAME} diff --git a/samples/python/hosted-agents/bring-your-own/invocations/event-grid-trigger/main.py b/samples/python/hosted-agents/bring-your-own/invocations/event-grid-trigger/main.py new file mode 100644 index 000000000..6d8f066f2 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/invocations/event-grid-trigger/main.py @@ -0,0 +1,273 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Event Grid β†’ hosted agent (blob trigger), no APIM bridge. + +A BYO Invocations agent that Event Grid POSTs to **directly**. EG +authenticates the delivery with the Event Grid system topic's +system-assigned managed identity whose AAD audience is set to +``https://ai.azure.com`` (the Foundry data plane), so the agent's +standard token validation accepts the request. + +The handler accepts three shapes on POST /invocations: + +* The Event Grid **subscription validation** handshake β€” answered with + ``{"validationResponse": ""}`` so the EG subscription can provision. +* An Event Grid batch of ``Microsoft.Storage.BlobCreated`` events β€” the + container and blob name are extracted from ``data.url`` and the blob is + processed. +* A direct ``{"container": "...", "name": "..."}`` payload β€” useful for + quick local invokes via ``azd ai agent invoke``. + +The blob is downloaded with the per-agent Microsoft Entra identity, +summarized with a Foundry model, and written back as +``.summary.json`` to the container named by +``AZURE_STORAGE_SUMMARY_CONTAINER_NAME``. Using a sibling output container +avoids re-triggering the Event Grid subscription that watches the input +container. + +Required environment variables: + + FOUNDRY_PROJECT_ENDPOINT (auto-injected in hosted containers) + AZURE_AI_MODEL_DEPLOYMENT_NAME + AZURE_STORAGE_ACCOUNT_NAME + AZURE_STORAGE_SUMMARY_CONTAINER_NAME +""" + +import json +import logging +import os +import time +from urllib.parse import urlparse + +from dotenv import load_dotenv +from starlette.requests import Request +from starlette.responses import JSONResponse + +from azure.ai.projects.aio import AIProjectClient +from azure.identity.aio import DefaultAzureCredential +from azure.storage.blob.aio import BlobClient + +from azure.ai.agentserver.invocations import InvocationAgentServerHost + +load_dotenv() + +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO) + + +_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] +_model = os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"] +_storage_account = os.environ.get("AZURE_STORAGE_ACCOUNT_NAME", "") +_summary_container = os.environ.get("AZURE_STORAGE_SUMMARY_CONTAINER_NAME", "") + +_credential = DefaultAzureCredential() +_project_client = AIProjectClient(endpoint=_endpoint, credential=_credential) +_openai_client = _project_client.get_openai_client() + +_MAX_BLOB_BYTES = 64 * 1024 +_ALLOWED_EXTENSIONS = (".txt", ".md") + +_EG_SUBSCRIPTION_VALIDATION_EVENT_TYPE = ( + "Microsoft.EventGrid.SubscriptionValidationEvent" +) +_EG_BLOB_CREATED_EVENT_TYPE = "Microsoft.Storage.BlobCreated" + + +def _iter_eg_events(payload): + """Yield event dicts from an EG (array) or CloudEvents (single) payload.""" + + if isinstance(payload, list): + for event in payload: + if isinstance(event, dict): + yield event + elif isinstance(payload, dict): + # CloudEvents v1.0 delivers a single event object. EG schema is + # always an array, but some testers post a single-event dict. + if payload.get("eventType") or payload.get("type"): + yield payload + + +def _extract_subscription_validation_event(payload): + """Return the SubscriptionValidationEvent object, or ``None``.""" + + for event in _iter_eg_events(payload): + event_type = event.get("eventType") or event.get("type") + if event_type == _EG_SUBSCRIPTION_VALIDATION_EVENT_TYPE: + return event + return None + + +def _extract_blob_created(payload): + """Return (container, name) from the first BlobCreated event, or ``None``. + + Both EG schema (``data.url``) and CloudEvents schema (``data.url``) + expose the full blob URL β€” split the path into ``/``. + """ + + for event in _iter_eg_events(payload): + event_type = event.get("eventType") or event.get("type") + if event_type != _EG_BLOB_CREATED_EVENT_TYPE: + continue + url = (event.get("data") or {}).get("url") + if not url: + continue + path = urlparse(url).path.lstrip("/") + parts = path.split("/", 1) + if len(parts) != 2 or not parts[0] or not parts[1]: + continue + return parts[0], parts[1] + return None + + +app = InvocationAgentServerHost() + + +@app.invoke_handler +async def handle_invoke(request: Request): + """POST /invocations β€” EG envelope or ``{"container": "...", "name": "..."}``.""" + + body = await request.body() + try: + payload = json.loads(body) if body else {} + except json.JSONDecodeError as exc: + return JSONResponse( + status_code=400, content={"error": "invalid_json", "message": str(exc)} + ) + + # 1. Event Grid subscription validation handshake. EG sends this once + # when the subscription is created; the agent must echo the code back + # or the subscription fails to provision. + # https://learn.microsoft.com/azure/event-grid/troubleshoot-subscription-validation + validation_event = _extract_subscription_validation_event(payload) + if validation_event is not None: + validation_code = (validation_event.get("data") or {}).get("validationCode") + if not validation_code: + return JSONResponse( + status_code=400, + content={ + "error": "invalid_validation_event", + "message": "missing data.validationCode", + }, + ) + logger.info( + "event-grid-trigger:subscription-validation code=%s", validation_code + ) + return JSONResponse({"validationResponse": validation_code}) + + # 2. Event Grid BlobCreated batch: extract (container, name) from data.url. + blob_ref = _extract_blob_created(payload) + if blob_ref is not None: + container, blob_name = blob_ref + else: + # 3. Direct {container, name} for ``azd ai agent invoke`` testing. + if not isinstance(payload, dict): + return JSONResponse( + status_code=400, + content={ + "error": "invalid_payload", + "message": "expected an EG event batch or {container, name}", + }, + ) + container = payload.get("container") + blob_name = payload.get("name") + if not container or not blob_name: + if isinstance(payload, dict) and ( + payload.get("query") or payload.get("message") or payload.get("input") + ): + return JSONResponse( + { + "info": ( + "This agent is triggered by Azure Storage BlobCreated events " + "via Event Grid. Send {\"container\": \"...\", \"name\": " + "\"...\"} (or upload a blob to the watched container) to run " + "a real turn." + ), + } + ) + return JSONResponse( + status_code=400, + content={"error": "invalid_payload", "message": "expected {container, name}"}, + ) + + if not blob_name.lower().endswith(_ALLOWED_EXTENSIONS): + return JSONResponse( + {"skipped": True, "reason": f"extension not in {_ALLOWED_EXTENSIONS}"} + ) + + if not _storage_account or not _summary_container: + return JSONResponse( + status_code=500, + content={ + "error": "missing_configuration", + "message": "AZURE_STORAGE_ACCOUNT_NAME and AZURE_STORAGE_SUMMARY_CONTAINER_NAME must be set", + }, + ) + + account_url = f"https://{_storage_account}.blob.core.windows.net" + + async with BlobClient(account_url, container, blob_name, credential=_credential) as src: + downloader = await src.download_blob(max_concurrency=1) + raw = await downloader.readall() + truncated = raw[:_MAX_BLOB_BYTES] + text = truncated.decode("utf-8", errors="replace") + + started = time.monotonic() + response = await _openai_client.responses.create( + model=_model, + instructions="You summarize files in 3-5 concise bullet points.", + input=[ + { + "role": "user", + "content": ( + f"Summarize the following file ({blob_name}) in 3-5 bullet points:\n\n" + f"{text}" + ), + } + ], + store=False, + ) + summary = response.output_text or "" + elapsed_ms = int((time.monotonic() - started) * 1000) + + # Persist the summary to a sibling container so the result is visible in + # Storage Explorer / the portal. The output container is intentionally + # different from the input so writes do not re-trigger Event Grid. + summary_blob_name = f"{blob_name}.summary.json" + summary_doc = { + "input": f"{container}/{blob_name}", + "elapsed_ms": elapsed_ms, + "truncated": len(raw) > _MAX_BLOB_BYTES, + "summary": summary, + } + async with BlobClient( + account_url, _summary_container, summary_blob_name, credential=_credential + ) as dst: + await dst.upload_blob( + json.dumps(summary_doc, ensure_ascii=False, indent=2).encode("utf-8"), + overwrite=True, + content_type="application/json", + ) + + logger.info( + "event-grid-trigger:summary blob=%s/%s elapsed_ms=%d truncated=%s output=%s/%s", + container, + blob_name, + elapsed_ms, + len(raw) > _MAX_BLOB_BYTES, + _summary_container, + summary_blob_name, + ) + + return JSONResponse( + { + "input": f"{container}/{blob_name}", + "output": f"{_summary_container}/{summary_blob_name}", + "elapsed_ms": elapsed_ms, + "truncated": len(raw) > _MAX_BLOB_BYTES, + "summary": summary, + } + ) + + +if __name__ == "__main__": + app.run() diff --git a/samples/python/hosted-agents/bring-your-own/invocations/event-grid-trigger/requirements.txt b/samples/python/hosted-agents/bring-your-own/invocations/event-grid-trigger/requirements.txt new file mode 100644 index 000000000..1f804bb2e --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/invocations/event-grid-trigger/requirements.txt @@ -0,0 +1,6 @@ +aiohttp==3.13.5 +azure-ai-agentserver-invocations==1.0.0b3 +azure-ai-projects==2.0.1 +azure-identity==1.25.3 +azure-storage-blob==12.27.0 +python-dotenv \ No newline at end of file diff --git a/samples/python/hosted-agents/bring-your-own/invocations/event-grid-trigger/sample.yaml b/samples/python/hosted-agents/bring-your-own/invocations/event-grid-trigger/sample.yaml new file mode 100644 index 000000000..02bb832f3 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/invocations/event-grid-trigger/sample.yaml @@ -0,0 +1,5 @@ +name: Hosted Agent β€” Event Grid β†’ hosted agent (blob trigger) +description: A BYO Invocations agent that Event Grid POSTs to directly using the system topic's system-assigned managed identity with audience https://ai.azure.com. Receives Storage BlobCreated events, downloads the blob with the per-agent Microsoft Entra identity, summarizes it with a Foundry model, and writes the summary to a sibling container. Summaries are also emitted via the agent's logs (Application Insights). + +build: "pip install -r requirements.txt" +validate: "python -m compileall -q ." diff --git a/samples/python/hosted-agents/bring-your-own/invocations/github-copilot/.env.example b/samples/python/hosted-agents/bring-your-own/invocations/github-copilot/.env.example index 5b471a522..76e6a4a4c 100644 --- a/samples/python/hosted-agents/bring-your-own/invocations/github-copilot/.env.example +++ b/samples/python/hosted-agents/bring-your-own/invocations/github-copilot/.env.example @@ -1,12 +1,21 @@ -# GitHub fine-grained PAT with "Copilot Requests β†’ Read-only" permission. -# Create one at: https://github.com/settings/personal-access-tokens/new -# Classic tokens (ghp_) are not supported β€” use github_pat_, gho_, or ghu_ tokens. +# This agent supports two LLM backends. Configure one of the following: +# +# Option 1 β€” GitHub Copilot model (quickest start): +# Set GITHUB_TOKEN to a fine-grained PAT with "Copilot Requests β†’ Read-only". +# Create one at: https://github.com/settings/personal-access-tokens/new +# Classic tokens (ghp_) are not supported β€” use github_pat_, gho_, or ghu_ tokens. +# +# Option 2 β€” Bring your own Foundry model (BYOK): +# Set FOUNDRY_PROJECT_ENDPOINT and AZURE_AI_MODEL_DEPLOYMENT_NAME. +# No GITHUB_TOKEN needed β€” authenticates via Managed Identity. +# FOUNDRY_PROJECT_ENDPOINT is auto-injected when deployed as a hosted agent. +# +# If both are set, the Foundry model takes precedence. + GITHUB_TOKEN= +# FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +# AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o -# Session ID for persistence/resume across restarts (optional). +# Optional β€” session ID for persistence/resume across restarts. # If unset, a random UUID is generated per process. # FOUNDRY_AGENT_SESSION_ID= - -# Application Insights β€” auto-injected in hosted containers. -# Set for local telemetry (optional but recommended). -# APPLICATIONINSIGHTS_CONNECTION_STRING=InstrumentationKey=... diff --git a/samples/python/hosted-agents/bring-your-own/invocations/github-copilot/README.md b/samples/python/hosted-agents/bring-your-own/invocations/github-copilot/README.md index 085bde40b..3abc867da 100644 --- a/samples/python/hosted-agents/bring-your-own/invocations/github-copilot/README.md +++ b/samples/python/hosted-agents/bring-your-own/invocations/github-copilot/README.md @@ -15,11 +15,20 @@ A minimal getting-started agent using the [GitHub Copilot SDK](https://pypi.org/ ## Environment Variables +This agent supports two LLM backends. Configure one of the following: + | Variable | Required | Description | |----------|----------|-------------| -| `GITHUB_TOKEN` | Yes | GitHub fine-grained PAT with **Copilot Requests β†’ Read-only** permission | +| `GITHUB_TOKEN` | For Copilot model | GitHub fine-grained PAT with **Copilot Requests β†’ Read-only** permission | +| `FOUNDRY_PROJECT_ENDPOINT` | For Foundry model | Azure AI Foundry project endpoint URL. Auto-injected when hosted β€” only needed locally | +| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | For Foundry model | Model deployment name (e.g. `gpt-4o`) | | `FOUNDRY_AGENT_SESSION_ID` | No | Session ID for persistence/resume. If unset, a UUID is generated | +**How the agent selects its LLM backend:** +- If `FOUNDRY_PROJECT_ENDPOINT` and `AZURE_AI_MODEL_DEPLOYMENT_NAME` are set β†’ uses your **Foundry model** via Managed Identity (no `GITHUB_TOKEN` needed) +- If only `GITHUB_TOKEN` is set β†’ uses the **GitHub Copilot model** (quickest way to get started) +- If both are set β†’ the **Foundry model takes precedence** + ## Running Locally ### Prerequisites @@ -33,7 +42,14 @@ Create one at [github.com/settings/personal-access-tokens/new](https://github.co ### Using `azd` (Recommended) -Add the `GITHUB_TOKEN` to the environment variables first: +Create a local `.env` file from the sample template and set `GITHUB_TOKEN`: + +```bash +cp .env.example .env +# Edit .env and set GITHUB_TOKEN=github_pat_... +``` + +The sample loads `.env` automatically when running locally. If you plan to deploy with `azd`, also add the token to your azd environment so it can be injected into the hosted agent: ```bash azd env set GITHUB_TOKEN="github_pat_..." @@ -51,7 +67,8 @@ The agent starts on `http://localhost:8088/`. ```bash pip install -r requirements.txt -cp .env.example .env # then set GITHUB_TOKEN +cp .env.example .env +# Edit .env and set GITHUB_TOKEN=github_pat_... python main.py ``` @@ -97,6 +114,18 @@ event: done data: {"invocation_id": "...", "session_id": "..."} ``` +## Using Your Own Foundry Model + +To use your own Azure AI Foundry model instead of the Copilot model, set the Foundry variables (no `GITHUB_TOKEN` needed): + +```bash +FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ \ +AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o \ +python main.py +``` + +Authentication uses Managed Identity via `DefaultAzureCredential`. When deployed as a hosted agent, `FOUNDRY_PROJECT_ENDPOINT` is auto-injected by the platform β€” you only need to set `AZURE_AI_MODEL_DEPLOYMENT_NAME` in `agent.yaml`. + ## Deploying the Agent to Microsoft Foundry Once you've tested locally, deploy to Microsoft Foundry: @@ -169,4 +198,4 @@ If you choose to **build locally**, and your machine is **not `linux/amd64`** (f docker build --platform=linux/amd64 -t image . ``` -This forces the image to be built for the required `amd64` architecture. \ No newline at end of file +This forces the image to be built for the required `amd64` architecture. diff --git a/samples/python/hosted-agents/bring-your-own/invocations/github-copilot/agent.manifest.yaml b/samples/python/hosted-agents/bring-your-own/invocations/github-copilot/agent.manifest.yaml index ed4207f9d..3ecadcc97 100644 --- a/samples/python/hosted-agents/bring-your-own/invocations/github-copilot/agent.manifest.yaml +++ b/samples/python/hosted-agents/bring-your-own/invocations/github-copilot/agent.manifest.yaml @@ -1,6 +1,6 @@ # yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml name: github-copilot-invocations -displayName: "GitHub Copilot (Invocations)" +displayName: "GitHub Copilot Invocations" description: > A getting-started agent that uses the GitHub Copilot SDK (CopilotClient) with the azure-ai-agentserver-invocations protocol, streaming raw session events @@ -10,21 +10,29 @@ metadata: - AI Agent Hosting - GitHub Copilot SDK - Invocations Protocol - - Bring Your Own + - Streaming template: name: github-copilot-invocations kind: hosted protocols: - protocol: invocations version: 1.0.0 + resources: + cpu: "0.25" + memory: 0.5Gi environment_variables: - # APPLICATIONINSIGHTS_CONNECTION_STRING is auto-injected by the platform β€” - # do NOT declare it here. + # Recommended β€” use a Foundry BYOK model with Managed Identity (no secrets needed). + # FOUNDRY_PROJECT_ENDPOINT is auto-injected by the platform β€” do not set it here. + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} # - # GITHUB_TOKEN is a user-supplied secret (fine-grained PAT). Supply via - # azd env set GITHUB_TOKEN="github_pat_..."" (local) or the hosted agent secret store (production). - - name: GITHUB_TOKEN - value: ${GITHUB_TOKEN} + # Alternative β€” set GITHUB_TOKEN to use the GitHub Copilot model instead. + # If both are set, the Foundry model is used and GITHUB_TOKEN is ignored. + # - name: GITHUB_TOKEN + # value: ${GITHUB_TOKEN} parameters: properties: [] -resources: [] \ No newline at end of file +resources: + - kind: model + id: gpt-5.4 + name: AZURE_AI_MODEL_DEPLOYMENT_NAME diff --git a/samples/python/hosted-agents/bring-your-own/invocations/github-copilot/agent.yaml b/samples/python/hosted-agents/bring-your-own/invocations/github-copilot/agent.yaml index 24c10c089..b873734f6 100644 --- a/samples/python/hosted-agents/bring-your-own/invocations/github-copilot/agent.yaml +++ b/samples/python/hosted-agents/bring-your-own/invocations/github-copilot/agent.yaml @@ -5,8 +5,15 @@ protocols: - protocol: invocations version: 1.0.0 resources: - cpu: "0.25" + cpu: '0.25' memory: 0.5Gi environment_variables: - - name: GITHUB_TOKEN - value: ${GITHUB_TOKEN} + # Recommended β€” use a Foundry BYOK model with Managed Identity (no secrets needed). + # FOUNDRY_PROJECT_ENDPOINT is auto-injected by the platform β€” do not set it here. + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} + # + # Alternative β€” set GITHUB_TOKEN to use the GitHub Copilot model instead. + # If both are set, the Foundry model is used and GITHUB_TOKEN is ignored. + # - name: GITHUB_TOKEN + # value: ${GITHUB_TOKEN} diff --git a/samples/python/hosted-agents/bring-your-own/invocations/github-copilot/main.py b/samples/python/hosted-agents/bring-your-own/invocations/github-copilot/main.py index 55348efd7..c9e32d065 100644 --- a/samples/python/hosted-agents/bring-your-own/invocations/github-copilot/main.py +++ b/samples/python/hosted-agents/bring-your-own/invocations/github-copilot/main.py @@ -1,41 +1,37 @@ # Copyright (c) Microsoft. All rights reserved. -"""Getting-started: GitHub Copilot SDK with the Foundry invocations protocol.""" +"""Getting-started: GitHub Copilot SDK with the Foundry invocations protocol. + +Supports two auth modes selected automatically by environment variables: + - GITHUB_TOKEN set β†’ uses the GitHub Copilot model (quickest start) + - FOUNDRY_PROJECT_ENDPOINT + AZURE_AI_MODEL_DEPLOYMENT_NAME set + β†’ uses a BYOK Foundry model via Managed Identity (no token needed) +""" import asyncio import json import logging import os import pathlib +import sys import uuid +from dotenv import load_dotenv from starlette.requests import Request from starlette.responses import JSONResponse, Response, StreamingResponse from azure.ai.agentserver.invocations import InvocationAgentServerHost from copilot import CopilotClient, SubprocessConfig -from copilot.session import PermissionHandler +from copilot.session import PermissionHandler, ProviderConfig from copilot.generated.session_events import SessionEventType +load_dotenv(override=False) + logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) -if not os.environ.get("APPLICATIONINSIGHTS_CONNECTION_STRING"): - logger.warning( - "APPLICATIONINSIGHTS_CONNECTION_STRING not set β€” traces will not be sent to " - "Application Insights. Set it to enable local telemetry. " - "(This variable is auto-injected in hosted Foundry containers β€” do not declare it in agent.manifest.yaml.)" - ) - -if not os.environ.get("GITHUB_TOKEN"): - raise EnvironmentError( - "GITHUB_TOKEN environment variable is not set. " - "Supply a GitHub fine-grained PAT with 'Copilot Requests β†’ Read-only' permission. " - "Create one at https://github.com/settings/personal-access-tokens/new" - ) - app = InvocationAgentServerHost() _client: CopilotClient | None = None @@ -44,6 +40,37 @@ _skills_dir = str(pathlib.Path(__file__).parent / "skills") +# ── BYOK helpers ───────────────────────────────────────────────────────────── + + +def _byok_provider() -> tuple[ProviderConfig | None, str | None]: + """Return (provider, model) for BYOK mode, or (None, None) for Copilot mode. + + Uses the FOUNDRY_PROJECT_ENDPOINT directly as a project-level OpenAI + endpoint (e.g. https://.services.ai.azure.com/api/projects//openai/v1). + """ + endpoint = os.environ.get("FOUNDRY_PROJECT_ENDPOINT", "") + model = os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "") + if not endpoint or not model: + return None, None + + from azure.identity import DefaultAzureCredential + token = DefaultAzureCredential().get_token( + "https://ai.azure.com/.default" + ).token + + provider = ProviderConfig( + type="azure", + base_url=endpoint, + wire_api="responses", + bearer_token=token, + ) + return provider, model + + +# ── Session management ─────────────────────────────────────────────────────── + + async def _ensure_session(): """Resume a persisted session or create a new one (lazy, runs once).""" global _client, _session, _session_id @@ -56,31 +83,39 @@ async def _ensure_session(): logger.warning( "FOUNDRY_AGENT_SESSION_ID not set, using: %s", _session_id) - _client = CopilotClient( - SubprocessConfig(github_token=os.environ["GITHUB_TOKEN"]), - auto_start=False, - ) + github_token = os.environ.get("GITHUB_TOKEN") + provider, model = _byok_provider() + + if provider: + # BYOK mode: Foundry model via Managed Identity β€” no token needed. + _client = CopilotClient(auto_start=False) + elif github_token: + # Copilot mode: use GitHub token. + _client = CopilotClient( + SubprocessConfig(github_token=github_token), auto_start=False) + else: + raise RuntimeError( + "Set GITHUB_TOKEN (Copilot model) or " + "FOUNDRY_PROJECT_ENDPOINT + AZURE_AI_MODEL_DEPLOYMENT_NAME " + "(BYOK Foundry model)") await _client.start() working_dir = os.environ.get("HOME", "/home") + common = dict( + on_permission_request=PermissionHandler.approve_all, + streaming=True, + skill_directories=[_skills_dir], + working_directory=working_dir, + provider=provider, + model=model, + ) + try: - _session = await _client.resume_session( - _session_id, - on_permission_request=PermissionHandler.approve_all, - streaming=True, - skill_directories=[_skills_dir], - working_directory=working_dir, - ) + _session = await _client.resume_session(_session_id, **common) logger.info("Resumed session: %s", _session_id) except Exception: - _session = await _client.create_session( - session_id=_session_id, - on_permission_request=PermissionHandler.approve_all, - streaming=True, - skill_directories=[_skills_dir], - working_directory=working_dir, - ) + _session = await _client.create_session(session_id=_session_id, **common) logger.info("Created session: %s", _session_id) @@ -143,4 +178,14 @@ async def handle_invoke(request: Request) -> Response: if __name__ == "__main__": + has_token = bool(os.environ.get("GITHUB_TOKEN")) + has_byok = bool( + os.environ.get("FOUNDRY_PROJECT_ENDPOINT") + and os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME") + ) + if not has_token and not has_byok: + sys.exit( + "Error: Set GITHUB_TOKEN (Copilot model) or " + "FOUNDRY_PROJECT_ENDPOINT + AZURE_AI_MODEL_DEPLOYMENT_NAME " + "(BYOK Foundry model)") app.run() diff --git a/samples/python/hosted-agents/bring-your-own/invocations/github-copilot/requirements.txt b/samples/python/hosted-agents/bring-your-own/invocations/github-copilot/requirements.txt index 0c9806883..da59ffb7d 100644 --- a/samples/python/hosted-agents/bring-your-own/invocations/github-copilot/requirements.txt +++ b/samples/python/hosted-agents/bring-your-own/invocations/github-copilot/requirements.txt @@ -1,3 +1,5 @@ github-copilot-sdk>=0.2.0 azure-ai-agentserver-invocations==1.0.0b3 +azure-identity>=1.17.0 +python-dotenv==1.1.1 diff --git a/samples/python/hosted-agents/bring-your-own/invocations/github-copilot/test-payload.txt b/samples/python/hosted-agents/bring-your-own/invocations/github-copilot/test-payload.txt deleted file mode 100644 index 14a1230b1..000000000 --- a/samples/python/hosted-agents/bring-your-own/invocations/github-copilot/test-payload.txt +++ /dev/null @@ -1 +0,0 @@ -{"input":"What can you help me with?"} diff --git a/samples/python/hosted-agents/bring-your-own/invocations/hello-world/main.py b/samples/python/hosted-agents/bring-your-own/invocations/hello-world/main.py index 837c5a66d..6914f77f5 100644 --- a/samples/python/hosted-agents/bring-your-own/invocations/hello-world/main.py +++ b/samples/python/hosted-agents/bring-your-own/invocations/hello-world/main.py @@ -38,7 +38,6 @@ -d '{"message": "What hosted agent options does it offer?"}' """ -import asyncio import json import logging import os @@ -46,8 +45,8 @@ from starlette.requests import Request from starlette.responses import JSONResponse, StreamingResponse -from azure.ai.projects import AIProjectClient -from azure.identity import DefaultAzureCredential +from azure.ai.projects.aio import AIProjectClient +from azure.identity.aio import DefaultAzureCredential from azure.ai.agentserver.invocations import InvocationAgentServerHost @@ -82,7 +81,7 @@ _project_client = AIProjectClient(endpoint=_endpoint, credential=_credential) # Use the Responses API β€” not chat.completions (Chat Completions API is legacy). -_responses_client = _project_client.get_openai_client().responses +_openai_client = _project_client.get_openai_client() _SYSTEM_PROMPT = "You are a helpful AI assistant. Be concise and informative." @@ -90,41 +89,7 @@ # In-memory session store β€” keyed by agent_session_id. # WARNING: state is lost on restart. Use durable storage in production. -_sessions: dict[str, list[dict[str, str]]] = {} - - -async def _stream_reply(input_items: list[dict[str, str]]): - """Call the Foundry model and yield text deltas as they arrive. - - The Responses SDK uses a synchronous streaming iterator. We bridge it to - async by running it in a thread pool and forwarding each delta through an - ``asyncio.Queue`` so the event loop is never blocked. - """ - loop = asyncio.get_running_loop() - queue: asyncio.Queue[str | None] = asyncio.Queue() - - def _produce() -> None: - """Runs in a thread: streams from the model and enqueues each delta.""" - try: - for event in _responses_client.create( - model=_model, - instructions=_SYSTEM_PROMPT, - input=input_items, - store=False, # This agent owns history β€” no need to store at the model level - stream=True, - ): - if event.type == "response.output_text.delta": - loop.call_soon_threadsafe(queue.put_nowait, event.delta) - finally: - # None signals end of stream - loop.call_soon_threadsafe(queue.put_nowait, None) - - # Start sync streaming in a background thread; yield deltas as they arrive. - fut = loop.run_in_executor(None, _produce) - while (delta := await queue.get()) is not None: - yield delta - await fut # re-raise any exception that escaped the thread - +_history: list[dict[str, str]] = [] # ── Required handler ────────────────────────────────────────────────────────── # @app.invoke_handler is the only handler you must implement. It receives every @@ -180,38 +145,29 @@ async def handle_invoke(request: Request): ) # Retrieve or create conversation history for this session. - history = _sessions.setdefault(session_id, []) - history.append({"role": "user", "content": user_message}) - - # Build the Responses API input list from history. - # History is stored as {role, content} dicts β€” the same format the API accepts. - input_items = list(history) + _history.append({"role": "user", "content": user_message}) async def event_generator(): full_reply = "" - try: - async for delta in _stream_reply(input_items): - full_reply += delta - yield f"data: {json.dumps({'type': 'token', 'content': delta})}\n\n" - except Exception as exc: - msg = f"Error calling model: {exc}" - logger.error(msg) - full_reply = msg - yield f"data: {json.dumps({'type': 'token', 'content': msg})}\n\n" - - # Final event carries the complete text so the caller can use it - # without having to reassemble the token stream. - yield f"data: {json.dumps({'type': 'done', 'invocation_id': invocation_id, 'session_id': session_id, 'full_text': full_reply})}\n\n" - - # Persist the assistant reply to history after streaming is complete. - if full_reply: - history.append({"role": "assistant", "content": full_reply}) + async for event in await _openai_client.responses.create( + model=_model, + instructions="You are a helpful AI assistant.", + input=list(_history), + store=False, + stream=True, + ): + if event.type == "response.output_text.delta": + full_reply += event.delta + yield f"data: {json.dumps({'type': 'token', 'content': event.delta})}\n\n" + + yield f"data: {json.dumps({'type': 'done', 'full_text': full_reply})}\n\n" + _history.append({"role": "assistant", "content": full_reply}) return StreamingResponse( event_generator(), media_type="text/event-stream", - headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + headers={"Cache-Control": "no-cache"}, ) - -app.run() +if __name__ == "__main__": + app.run() diff --git a/samples/python/hosted-agents/bring-your-own/invocations/hello-world/requirements.txt b/samples/python/hosted-agents/bring-your-own/invocations/hello-world/requirements.txt index 3943fb2fa..f16f9909a 100644 --- a/samples/python/hosted-agents/bring-your-own/invocations/hello-world/requirements.txt +++ b/samples/python/hosted-agents/bring-your-own/invocations/hello-world/requirements.txt @@ -1,3 +1,4 @@ +aiohttp==3.13.5 azure-ai-agentserver-invocations==1.0.0b3 azure-ai-projects==2.0.1 azure-identity==1.25.3 diff --git a/samples/python/hosted-agents/bring-your-own/invocations/hello-world/test-payload.txt b/samples/python/hosted-agents/bring-your-own/invocations/hello-world/test-payload.txt deleted file mode 100644 index 54068e2ba..000000000 --- a/samples/python/hosted-agents/bring-your-own/invocations/hello-world/test-payload.txt +++ /dev/null @@ -1 +0,0 @@ -{"message":"What is Microsoft Foundry?"} diff --git a/samples/python/hosted-agents/bring-your-own/invocations/human-in-the-loop/README.md b/samples/python/hosted-agents/bring-your-own/invocations/human-in-the-loop/README.md index 5f8f81137..876b6df61 100644 --- a/samples/python/hosted-agents/bring-your-own/invocations/human-in-the-loop/README.md +++ b/samples/python/hosted-agents/bring-your-own/invocations/human-in-the-loop/README.md @@ -166,7 +166,7 @@ Error calling Azure OpenAI: Error code: 401 - {'error': {'code': 'PermissionDeni The identity running the agent does not have the required RBAC roles on the Azure AI Foundry project. Assign the following roles: - **Cognitive Services OpenAI User** -- **Azure AI User** +- **Foundry User** Use the Azure CLI to assign them: @@ -183,10 +183,10 @@ az role assignment create \ --role "Cognitive Services OpenAI User" \ --scope "/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.MachineLearningServices/workspaces/$PROJECT_NAME" -# Assign "Azure AI User" role +# Assign "Foundry User" role az role assignment create \ --assignee "$PRINCIPAL_ID" \ - --role "Azure AI User" \ + --role "Foundry User" \ --scope "/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.MachineLearningServices/workspaces/$PROJECT_NAME" ``` diff --git a/samples/python/hosted-agents/bring-your-own/invocations/human-in-the-loop/test-payload.txt b/samples/python/hosted-agents/bring-your-own/invocations/human-in-the-loop/test-payload.txt deleted file mode 100644 index cb659cd54..000000000 --- a/samples/python/hosted-agents/bring-your-own/invocations/human-in-the-loop/test-payload.txt +++ /dev/null @@ -1 +0,0 @@ -{"task":"Draft a marketing email for our new AI product launch"} diff --git a/samples/python/hosted-agents/bring-your-own/invocations/langgraph-chat/Dockerfile b/samples/python/hosted-agents/bring-your-own/invocations/langgraph-chat/Dockerfile index b89292edb..be42c57b0 100644 --- a/samples/python/hosted-agents/bring-your-own/invocations/langgraph-chat/Dockerfile +++ b/samples/python/hosted-agents/bring-your-own/invocations/langgraph-chat/Dockerfile @@ -2,6 +2,6 @@ FROM python:3.12-slim WORKDIR /app COPY . user_agent/ WORKDIR /app/user_agent -RUN if [ -f requirements.txt ]; then pip install -r requirements.txt; fi +RUN pip install --upgrade pip && if [ -f requirements.txt ]; then pip install -r requirements.txt; fi EXPOSE 8088 CMD ["python", "main.py"] diff --git a/samples/python/hosted-agents/bring-your-own/invocations/langgraph-chat/main.py b/samples/python/hosted-agents/bring-your-own/invocations/langgraph-chat/main.py index 30b33efd0..0a3f8265d 100644 --- a/samples/python/hosted-agents/bring-your-own/invocations/langgraph-chat/main.py +++ b/samples/python/hosted-agents/bring-your-own/invocations/langgraph-chat/main.py @@ -48,8 +48,8 @@ from starlette.requests import Request from starlette.responses import JSONResponse, StreamingResponse -from azure.identity import DefaultAzureCredential, get_bearer_token_provider -from langchain_openai import ChatOpenAI +from azure.identity import DefaultAzureCredential +from langchain_azure_ai.chat_models import AzureAIOpenAIApiChatModel from langgraph.graph import StateGraph, START, END from langgraph.graph.message import add_messages from langgraph.prebuilt import ToolNode @@ -85,10 +85,6 @@ "Set it to your model deployment name as declared in agent.manifest.yaml." ) -_token_provider = get_bearer_token_provider( - DefaultAzureCredential(), "https://ai.azure.com/.default" -) - # ── Tools ──────────────────────────────────────────────────────────── @tool @@ -117,11 +113,10 @@ class State(TypedDict): def _build_graph() -> StateGraph: """Build and compile the LangGraph agent graph.""" - llm = ChatOpenAI( - base_url=f"{FOUNDRY_PROJECT_ENDPOINT}/openai/v1", - api_key=_token_provider, + llm = AzureAIOpenAIApiChatModel( + project_endpoint=FOUNDRY_PROJECT_ENDPOINT, + credential=DefaultAzureCredential(), model=AZURE_AI_MODEL_DEPLOYMENT_NAME, - use_responses_api=True, streaming=True, ) llm_with_tools = llm.bind_tools(TOOLS) diff --git a/samples/python/hosted-agents/bring-your-own/invocations/langgraph-chat/requirements.txt b/samples/python/hosted-agents/bring-your-own/invocations/langgraph-chat/requirements.txt index cd954ba5c..c95ee3e22 100644 --- a/samples/python/hosted-agents/bring-your-own/invocations/langgraph-chat/requirements.txt +++ b/samples/python/hosted-agents/bring-your-own/invocations/langgraph-chat/requirements.txt @@ -4,4 +4,4 @@ azure-identity==1.25.3 langgraph==1.1.8 langgraph-prebuilt==1.0.10 langchain-core==1.3.0 -langchain-openai==1.1.14 +langchain-azure-ai[opentelemetry]>=1.2.3 diff --git a/samples/python/hosted-agents/bring-your-own/invocations/langgraph-chat/test-payload.txt b/samples/python/hosted-agents/bring-your-own/invocations/langgraph-chat/test-payload.txt deleted file mode 100644 index c176c0980..000000000 --- a/samples/python/hosted-agents/bring-your-own/invocations/langgraph-chat/test-payload.txt +++ /dev/null @@ -1 +0,0 @@ -{"message":"What time is it right now?"} diff --git a/samples/python/hosted-agents/bring-your-own/invocations/notetaking-agent/README.md b/samples/python/hosted-agents/bring-your-own/invocations/notetaking-agent/README.md index 8b26365d7..ffb7eaea5 100644 --- a/samples/python/hosted-agents/bring-your-own/invocations/notetaking-agent/README.md +++ b/samples/python/hosted-agents/bring-your-own/invocations/notetaking-agent/README.md @@ -142,7 +142,7 @@ Error calling Azure OpenAI: Error code: 401 - {'error': {'code': 'PermissionDeni The identity running the agent does not have the required RBAC roles on the Azure AI Foundry project. Assign the following roles: - **Cognitive Services OpenAI User** -- **Azure AI User** +- **Foundry User** Use the Azure CLI to assign them: @@ -159,10 +159,10 @@ az role assignment create \ --role "Cognitive Services OpenAI User" \ --scope "/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.MachineLearningServices/workspaces/$PROJECT_NAME" -# Assign "Azure AI User" role +# Assign "Foundry User" role az role assignment create \ --assignee "$PRINCIPAL_ID" \ - --role "Azure AI User" \ + --role "Foundry User" \ --scope "/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.MachineLearningServices/workspaces/$PROJECT_NAME" ``` diff --git a/samples/python/hosted-agents/bring-your-own/invocations/notetaking-agent/test-payload.txt b/samples/python/hosted-agents/bring-your-own/invocations/notetaking-agent/test-payload.txt deleted file mode 100644 index 8cf3e2de0..000000000 --- a/samples/python/hosted-agents/bring-your-own/invocations/notetaking-agent/test-payload.txt +++ /dev/null @@ -1 +0,0 @@ -{"message":"save a note - book reservation for dinner"} diff --git a/samples/python/hosted-agents/bring-your-own/invocations/toolbox/README.md b/samples/python/hosted-agents/bring-your-own/invocations/toolbox/README.md index 8129644de..5c692bb76 100644 --- a/samples/python/hosted-agents/bring-your-own/invocations/toolbox/README.md +++ b/samples/python/hosted-agents/bring-your-own/invocations/toolbox/README.md @@ -153,7 +153,35 @@ azd ai agent monitor ``` For the full deployment guide, see [Azure AI Foundry hosted agents](https://aka.ms/azdaiagent/docs). +--- +## Supported Scenarios + +The sample toolbox can be configured for any of these 14 scenarios. For each scenario, create a `agent.manifest.yaml` file (see examples below) and pass it to `azd ai agent init -m `. + +
+View all 14 supported scenarios + +Refer to [`samples/python/toolbox/azd/README.md`](../../../../toolbox/azd/README.md#supported-scenarios) for complete inline documentation of all scenarios including: + +1. **Web Search** β€” Bing web search (no auth required) +2. **File Search** β€” Vector store RAG search +3. **Code Interpreter** β€” Python code execution +4. **MCP Key-Auth (GitHub)** β€” GitHub MCP with PAT +5. **MCP No-Auth** β€” Public MCP servers +6. **MCP OAuth (Managed)** β€” Foundry-managed OAuth +7. **MCP OAuth (Custom)** β€” Bring-your-own OAuth app +8. **MCP Agent Identity** β€” Entra ID agent identity +9. **Azure AI Search** β€” Search index queries +10. **A2A (Agent-to-Agent)** β€” Remote agent delegation +11. **Bing Custom Search** β€” Scoped web search +12. **OpenAPI Key-Auth** β€” REST API integration +13. **MCP OAuth (Entra Passthrough)** β€” User identity delegation +14. **Multi-Tool Toolbox** β€” Web search + GitHub MCP combined + +Each scenario includes a complete `agent.manifest.yaml` example with parameter definitions and resource configurations. + +
## Troubleshooting ### Images built on Apple Silicon or other ARM64 machines do not work on our service diff --git a/samples/python/hosted-agents/bring-your-own/invocations/toolbox/agent.manifest.yaml b/samples/python/hosted-agents/bring-your-own/invocations/toolbox/agent.manifest.yaml index 967e47b82..9932030bf 100644 --- a/samples/python/hosted-agents/bring-your-own/invocations/toolbox/agent.manifest.yaml +++ b/samples/python/hosted-agents/bring-your-own/invocations/toolbox/agent.manifest.yaml @@ -26,8 +26,10 @@ template: # Model deployment name β€” resolved from the resources section below. - name: AZURE_AI_MODEL_DEPLOYMENT_NAME value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" - - name: TOOLBOX_ENDPOINT - value: "{{TOOLBOX_ENDPOINT}}" + # Toolbox name β€” main.py derives the azd-injected + # TOOLBOX__MCP_ENDPOINT variable from this. + - name: TOOLBOX_NAME + value: "web-search-tools" resources: - kind: model id: gpt-4.1 diff --git a/samples/python/hosted-agents/bring-your-own/invocations/toolbox/main.py b/samples/python/hosted-agents/bring-your-own/invocations/toolbox/main.py index bd0b68de0..f3ca87b21 100644 --- a/samples/python/hosted-agents/bring-your-own/invocations/toolbox/main.py +++ b/samples/python/hosted-agents/bring-your-own/invocations/toolbox/main.py @@ -85,17 +85,29 @@ "Set it to your model deployment name as declared in agent.manifest.yaml." ) -# Platform injects TOOLBOX_{NAME}_MCP_ENDPOINT for declared toolbox resources. -# Fall back to TOOLBOX_ENDPOINT for local dev (.env). -TOOLBOX_ENDPOINT = ( - os.environ.get("TOOLBOX_WEB_SEARCH_TOOLS_MCP_ENDPOINT") - or os.environ.get("TOOLBOX_ENDPOINT", "") -) +# Toolbox MCP endpoint resolution (in priority order): +# 1. TOOLBOX_ENDPOINT β€” explicit full URL override (CI / local). +# 2. TOOLBOX__MCP_ENDPOINT β€” azd auto-injects this per toolbox declared +# in azure.yaml. Variable name = upper(name) with dashes -> underscores. +# 3. Construct from FOUNDRY_PROJECT_ENDPOINT + TOOLBOX_NAME as a final fallback. +_TOOLBOX_ENDPOINT_OVERRIDE = os.environ.get("TOOLBOX_ENDPOINT", "") +_TOOLBOX_NAME = os.environ.get("TOOLBOX_NAME", "") +if _TOOLBOX_ENDPOINT_OVERRIDE: + TOOLBOX_ENDPOINT = _TOOLBOX_ENDPOINT_OVERRIDE +elif _TOOLBOX_NAME: + _azd_injected_var = ( + f"TOOLBOX_{_TOOLBOX_NAME.upper().replace('-', '_')}_MCP_ENDPOINT" + ) + TOOLBOX_ENDPOINT = os.environ.get(_azd_injected_var) or ( + f"{_endpoint.rstrip('/')}/toolboxes/{_TOOLBOX_NAME}/mcp?api-version=v1" + ) +else: + TOOLBOX_ENDPOINT = "" if not TOOLBOX_ENDPOINT: raise EnvironmentError( - "TOOLBOX_ENDPOINT environment variable is not set. " - "Set it to your toolbox MCP endpoint URL, or declare the toolbox " - "in agent.manifest.yaml resources." + "Neither TOOLBOX_ENDPOINT nor TOOLBOX_NAME is set. " + "Set TOOLBOX_NAME (and declare a matching toolbox in agent.manifest.yaml " + "resources) or set TOOLBOX_ENDPOINT to a full toolbox MCP URL." ) # Ensure api-version query param is present. if "api-version=" not in TOOLBOX_ENDPOINT: diff --git a/samples/python/hosted-agents/bring-your-own/invocations/toolbox/test-payload.txt b/samples/python/hosted-agents/bring-your-own/invocations/toolbox/test-payload.txt deleted file mode 100644 index 8f78b4885..000000000 --- a/samples/python/hosted-agents/bring-your-own/invocations/toolbox/test-payload.txt +++ /dev/null @@ -1 +0,0 @@ -{"message":"Search the web for Azure AI Foundry news"} diff --git a/samples/python/hosted-agents/bring-your-own/responses/bring-your-own-toolbox/README.md b/samples/python/hosted-agents/bring-your-own/responses/bring-your-own-toolbox/README.md index a6d715b0e..dee43fe45 100644 --- a/samples/python/hosted-agents/bring-your-own/responses/bring-your-own-toolbox/README.md +++ b/samples/python/hosted-agents/bring-your-own/responses/bring-your-own-toolbox/README.md @@ -231,3 +231,33 @@ docker build --platform=linux/amd64 -t image . ``` This forces the image to be built for the required `amd64` architecture. + +--- + +## Supported Scenarios + +The sample toolbox can be configured for any of these 14 scenarios. For each scenario, create a `agent.manifest.yaml` file (see examples below) and pass it to `azd ai agent init -m `. + +
+View all 14 supported scenarios + +Refer to [`samples/python/toolbox/azd/README.md`](../../../../toolbox/azd/README.md#supported-scenarios) for complete inline documentation of all scenarios including: + +1. **Web Search** β€” Bing web search (no auth required) +2. **File Search** β€” Vector store RAG search +3. **Code Interpreter** β€” Python code execution +4. **MCP Key-Auth (GitHub)** β€” GitHub MCP with PAT +5. **MCP No-Auth** β€” Public MCP servers +6. **MCP OAuth (Managed)** β€” Foundry-managed OAuth +7. **MCP OAuth (Custom)** β€” Bring-your-own OAuth app +8. **MCP Agent Identity** β€” Entra ID agent identity +9. **Azure AI Search** β€” Search index queries +10. **A2A (Agent-to-Agent)** β€” Remote agent delegation +11. **Bing Custom Search** β€” Scoped web search +12. **OpenAPI Key-Auth** β€” REST API integration +13. **MCP OAuth (Entra Passthrough)** β€” User identity delegation +14. **Multi-Tool Toolbox** β€” Web search + GitHub MCP combined + +Each scenario includes a complete `agent.manifest.yaml` example with parameter definitions and resource configurations. + +
diff --git a/samples/python/hosted-agents/bring-your-own/responses/bring-your-own-toolbox/main.py b/samples/python/hosted-agents/bring-your-own/responses/bring-your-own-toolbox/main.py index 4f44994ba..e07abb695 100644 --- a/samples/python/hosted-agents/bring-your-own/responses/bring-your-own-toolbox/main.py +++ b/samples/python/hosted-agents/bring-your-own/responses/bring-your-own-toolbox/main.py @@ -57,6 +57,7 @@ import json import logging import os +import time import httpx from dotenv import load_dotenv @@ -90,15 +91,24 @@ "Set it to your model deployment name as declared in agent.manifest.yaml." ) -# TOOLBOX_NAME is declared in agent.manifest.yaml and resolved from the toolbox -# resource at deploy time. The full MCP URL is constructed from the project endpoint. -# Falls back to TOOLBOX_ENDPOINT for local testing / explicit override. +# Toolbox MCP endpoint resolution (in priority order): +# 1. TOOLBOX_ENDPOINT β€” explicit full URL override (CI / local). +# 2. TOOLBOX__MCP_ENDPOINT β€” azd auto-injects this per toolbox declared +# in azure.yaml. Variable name = upper(name) with dashes -> underscores. +# 3. Construct from FOUNDRY_PROJECT_ENDPOINT + TOOLBOX_NAME as a final fallback. +_TOOLBOX_ENDPOINT_OVERRIDE = os.getenv("TOOLBOX_ENDPOINT", "") _TOOLBOX_NAME = os.getenv("TOOLBOX_NAME", "") -TOOLBOX_ENDPOINT = ( - f"{_endpoint.rstrip('/')}/toolboxes/{_TOOLBOX_NAME}/mcp?api-version=v1" - if _TOOLBOX_NAME - else os.getenv("TOOLBOX_ENDPOINT", "") -) +if _TOOLBOX_ENDPOINT_OVERRIDE: + TOOLBOX_ENDPOINT = _TOOLBOX_ENDPOINT_OVERRIDE +elif _TOOLBOX_NAME: + _azd_injected_var = ( + f"TOOLBOX_{_TOOLBOX_NAME.upper().replace('-', '_')}_MCP_ENDPOINT" + ) + TOOLBOX_ENDPOINT = os.getenv(_azd_injected_var) or ( + f"{_endpoint.rstrip('/')}/toolboxes/{_TOOLBOX_NAME}/mcp?api-version=v1" + ) +else: + TOOLBOX_ENDPOINT = "" if not TOOLBOX_ENDPOINT: logger.warning( "TOOLBOX_NAME is not set β€” agent will start without toolbox tools. " @@ -238,9 +248,32 @@ def _ensure_tools(): _tools_initialized = True return logger.info("Connecting to toolbox: %s", TOOLBOX_ENDPOINT) - _mcp_client = _McpToolboxClient(TOOLBOX_ENDPOINT, _token_provider) - server_name = _mcp_client.initialize() - mcp_tools = _mcp_client.list_tools() + # Retry transient cold-start errors: the toolbox MCP proxy can briefly + # return empty tool lists or transient errors while the upstream toolbox + # container is still starting. Retry a few times before giving up. + mcp_tools: list[dict] = [] + server_name = "unknown" + last_exc: Exception | None = None + for attempt in range(1, 6): + try: + _mcp_client = _McpToolboxClient(TOOLBOX_ENDPOINT, _token_provider) + server_name = _mcp_client.initialize() + mcp_tools = _mcp_client.list_tools() + if mcp_tools: + break + logger.warning( + "Toolbox '%s' returned 0 tools on attempt %d; retrying", server_name, attempt, + ) + except Exception as exc: # noqa: BLE001 + last_exc = exc + logger.warning( + "Toolbox connect attempt %d failed: %s; retrying", attempt, exc, + ) + time.sleep(min(2 ** attempt, 15)) + if not mcp_tools and last_exc is not None: + # All attempts failed β€” propagate so the request returns a 5xx + # rather than silently caching an empty tool set. + raise last_exc logger.info("Toolbox '%s' connected: %d tool(s) discovered", server_name, len(mcp_tools)) for t in mcp_tools: @@ -250,7 +283,10 @@ def _ensure_tools(): "description": t.get("description", ""), "parameters": t.get("inputSchema", {"type": "object", "properties": {}}), }) - _tools_initialized = True + # Only mark initialized once we actually have tools β€” otherwise leave the + # flag False so the next inbound request retries. + if _tool_definitions: + _tools_initialized = True # ── Agentic loop ────────────────────────────────────────────────────────────── @@ -385,13 +421,34 @@ async def handler( yield stream.emit_completed() return - history = await context.get_history() + # Conversation history retrieval can fail (transient store errors, + # missing conversation context, etc.). Treat history as best-effort β€” + # an empty list still produces a coherent single-turn reply. + try: + history = await context.get_history() + except Exception as exc: # noqa: BLE001 + logger.warning("get_history failed; continuing without history: %s", exc) + history = [] input_items = _build_input(user_input, history) logger.info("Processing request %s", context.response_id) loop = asyncio.get_running_loop() - assistant_reply = await loop.run_in_executor(None, _run_agent_loop, input_items) + try: + assistant_reply = await asyncio.wait_for( + loop.run_in_executor(None, _run_agent_loop, input_items), + timeout=240.0, + ) + except asyncio.TimeoutError: + assistant_reply = ( + "I could not complete this request within the local timeout. " + "Please retry with a simpler prompt." + ) + except asyncio.CancelledError: + assistant_reply = "The request was cancelled before completion. Please retry." + except Exception as exc: # noqa: BLE001 + logger.exception("Agent loop failed: %s", exc) + assistant_reply = f"Agent loop failed: {exc}" message_item = stream.add_output_item_message() yield message_item.emit_added() diff --git a/samples/python/hosted-agents/bring-your-own/responses/bring-your-own-toolbox/requirements.txt b/samples/python/hosted-agents/bring-your-own/responses/bring-your-own-toolbox/requirements.txt index dee3dccf2..4bb4b83cc 100644 --- a/samples/python/hosted-agents/bring-your-own/responses/bring-your-own-toolbox/requirements.txt +++ b/samples/python/hosted-agents/bring-your-own/responses/bring-your-own-toolbox/requirements.txt @@ -1,6 +1,5 @@ -azure-ai-agentserver-responses==1.0.0b5 -azure-ai-agentserver-core==2.0.0b3 -azure-ai-projects==2.0.1 -azure-identity==1.25.3 +azure-ai-agentserver-responses==1.0.0b4 +azure-ai-projects>=2.0.0 +azure-identity>=1.25.0 httpx python-dotenv==1.1.1 diff --git a/samples/python/hosted-agents/bring-your-own/responses/bring-your-own-toolbox/test-payload.txt b/samples/python/hosted-agents/bring-your-own/responses/bring-your-own-toolbox/test-payload.txt deleted file mode 100644 index 6e144aa6a..000000000 --- a/samples/python/hosted-agents/bring-your-own/responses/bring-your-own-toolbox/test-payload.txt +++ /dev/null @@ -1,3 +0,0 @@ -what tools do you have -what tools do you have -what tools do you have diff --git a/samples/python/hosted-agents/bring-your-own/responses/langgraph-chat/Dockerfile b/samples/python/hosted-agents/bring-your-own/responses/langgraph-chat/Dockerfile index b89292edb..be42c57b0 100644 --- a/samples/python/hosted-agents/bring-your-own/responses/langgraph-chat/Dockerfile +++ b/samples/python/hosted-agents/bring-your-own/responses/langgraph-chat/Dockerfile @@ -2,6 +2,6 @@ FROM python:3.12-slim WORKDIR /app COPY . user_agent/ WORKDIR /app/user_agent -RUN if [ -f requirements.txt ]; then pip install -r requirements.txt; fi +RUN pip install --upgrade pip && if [ -f requirements.txt ]; then pip install -r requirements.txt; fi EXPOSE 8088 CMD ["python", "main.py"] diff --git a/samples/python/hosted-agents/bring-your-own/responses/langgraph-chat/main.py b/samples/python/hosted-agents/bring-your-own/responses/langgraph-chat/main.py index 77007436c..421ae96c5 100644 --- a/samples/python/hosted-agents/bring-your-own/responses/langgraph-chat/main.py +++ b/samples/python/hosted-agents/bring-your-own/responses/langgraph-chat/main.py @@ -41,8 +41,8 @@ from datetime import datetime, timezone from typing import Annotated -from azure.identity import DefaultAzureCredential, get_bearer_token_provider -from langchain_openai import ChatOpenAI +from azure.identity import DefaultAzureCredential +from langchain_azure_ai.chat_models import AzureAIOpenAIApiChatModel from langgraph.graph import StateGraph, START, END from langgraph.graph.message import add_messages from langgraph.prebuilt import ToolNode @@ -88,10 +88,6 @@ "Set it to your model deployment name as declared in agent.manifest.yaml." ) -_token_provider = get_bearer_token_provider( - DefaultAzureCredential(), "https://ai.azure.com/.default" -) - # ── Tools ──────────────────────────────────────────────────────────── @tool @@ -120,11 +116,10 @@ class State(TypedDict): def _build_graph() -> StateGraph: """Build and compile the LangGraph agent graph.""" - llm = ChatOpenAI( - base_url=f"{FOUNDRY_PROJECT_ENDPOINT}/openai/v1", - api_key=_token_provider, + llm = AzureAIOpenAIApiChatModel( + project_endpoint=FOUNDRY_PROJECT_ENDPOINT, + credential=DefaultAzureCredential(), model=AZURE_AI_MODEL_DEPLOYMENT_NAME, - use_responses_api=True, streaming=True, ) llm_with_tools = llm.bind_tools(TOOLS) diff --git a/samples/python/hosted-agents/bring-your-own/responses/langgraph-chat/requirements.txt b/samples/python/hosted-agents/bring-your-own/responses/langgraph-chat/requirements.txt index 0f821d9e3..1d41cff59 100644 --- a/samples/python/hosted-agents/bring-your-own/responses/langgraph-chat/requirements.txt +++ b/samples/python/hosted-agents/bring-your-own/responses/langgraph-chat/requirements.txt @@ -4,4 +4,4 @@ azure-identity==1.25.3 langgraph==1.1.8 langgraph-prebuilt==1.0.10 langchain-core==1.3.0 -langchain-openai==1.1.14 +langchain-azure-ai[opentelemetry]>=1.2.3 diff --git a/samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox-user-identity/.dockerignore b/samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox-user-identity/.dockerignore new file mode 100644 index 000000000..34ad309bd --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox-user-identity/.dockerignore @@ -0,0 +1,14 @@ +# Exclude files not needed in the container image +.env +.env.* +.git +.gitignore +.venv +__pycache__ +*.pyc +.agent.log +.agent.pid +.foundry-agent.json +.foundry-agent-build.log +node_modules + diff --git a/samples/python/toolbox/maf/.env.example b/samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox-user-identity/.env.example similarity index 94% rename from samples/python/toolbox/maf/.env.example rename to samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox-user-identity/.env.example index 8037a877c..88c41af1a 100644 --- a/samples/python/toolbox/maf/.env.example +++ b/samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox-user-identity/.env.example @@ -3,7 +3,7 @@ FOUNDRY_PROJECT_ENDPOINT= # Model deployment name β€” must match a deployment in your Foundry project. -MODEL_DEPLOYMENT_NAME= +AZURE_AI_MODEL_DEPLOYMENT_NAME= # Application Insights β€” auto-injected in hosted containers. # Set for local telemetry (optional but recommended). diff --git a/samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox-user-identity/Dockerfile b/samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox-user-identity/Dockerfile new file mode 100644 index 000000000..3aac3c8c4 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox-user-identity/Dockerfile @@ -0,0 +1,12 @@ +FROM mcr.microsoft.com/azurelinux/base/python:3.12 +WORKDIR /app +COPY . user_agent/ +WORKDIR /app/user_agent +# Upgrade pip first: the rpm-installed pip 24.2 in this base image cannot parse +# newer dependency markers (e.g. extra == "contentunderstanding" on +# langchain-azure-ai 1.2.3+). --ignore-installed avoids the +# "uninstall-no-record-file" error from the rpm-managed pip package. +RUN pip install --upgrade --ignore-installed pip +RUN if [ -f requirements.txt ]; then pip install -r requirements.txt; fi +EXPOSE 8088 +CMD ["python3", "main.py"] diff --git a/samples/python/toolbox/langgraph/README.md b/samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox-user-identity/README.md similarity index 63% rename from samples/python/toolbox/langgraph/README.md rename to samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox-user-identity/README.md index 13c4bc08c..3bbd55d28 100644 --- a/samples/python/toolbox/langgraph/README.md +++ b/samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox-user-identity/README.md @@ -8,11 +8,18 @@ Third-party samples contained in this folder are subject to their own designated Microsoft has no responsibility to you or others with respect to any of these samples or any resulting output. -# LangGraph Toolbox Agent (Responses Protocol) +# LangGraph Toolbox User Identity Agent (Responses Protocol) -A LangGraph ReAct agent that connects to an **toolbox in Microsoft Foundry** via MCP and +A LangGraph ReAct agent that connects to a **toolbox in Microsoft Foundry** via MCP and serves responses over the Foundry Responses Protocol. +This sample is configured for user-identity/OAuth scenarios with three toolbox MCP +connections: WorkIQ Mail, WorkIQ Calendar, and GitHub MCP. + +The sample deploys these tools into a dedicated toolbox named +`langgraph-toolbox-user-identity-tools` to avoid colliding with existing shared +toolboxes in the target Foundry project. + ## How It Works 1. On startup the agent calls `client.get_tools()` against the Toolbox MCP endpoint. @@ -20,8 +27,9 @@ serves responses over the Foundry Responses Protocol. 3. Incoming requests are handled by `ResponsesAgentServerHost` on port `8088`. 4. The agent is initialized **lazily** (once, on the first request) and reused for all subsequent turns β€” the MCP client is kept alive to prevent session garbage-collection. -5. When the toolbox requires OAuth consent (e.g. a GitHub connection that hasn't been - authorized yet), the MCP server returns error code `-32006`. The agent detects this, +5. When the toolbox requires OAuth consent (for example, GitHub MCP or user-identity + delegated tools that have not been authorized yet), the MCP server returns error code + `-32006`. The agent detects this, logs the consent URL, and surfaces it to the caller via a fallback tool instead of crashing. @@ -78,50 +86,122 @@ Invoke-RestMethod -Method POST http://localhost:8088/responses ` ## Deploy as a Hosted Agent -### Prerequisites for deployment +### Setup -- [Azure Developer CLI (`azd`)](https://learn.microsoft.com/azure/developer/azure-developer-cli/install-azd) installed -- The `azure.ai.agents` azd extension installed: +#### 1. Install Azure Developer CLI (`azd`) - ```bash - azd extension install azure.ai.agents - ``` +**Linux/macOS:** +```bash +curl -fsSL https://aka.ms/install-azd.sh | bash +``` -### Deploy steps +**Windows (PowerShell):** +```powershell +winget install microsoft.azd +``` + +See the [full installation docs](https://learn.microsoft.com/azure/developer/azure-developer-cli/install-azd) for other options. + +#### 2. Install the AI Agents azd extension ```bash -# 1. Log in to Azure -azd auth login +azd extension install azure.ai.agents +``` -# 2. Create a new directory and initialize the agent project -mkdir my-langgraph-agent && cd my-langgraph-agent -azd ai agent init \ - -m https://github.com/microsoft/hosted-agents-vnext-private-preview/blob/main/samples/python/toolbox/langgraph/agent.manifest.yaml \ - --project-id /subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//projects/ +To upgrade the extension later: + +```bash +azd extension upgrade azure.ai.agents ``` -After `azd ai agent init` completes, set required environment variables: +#### 3. Log in to Azure ```bash -# Enable vNext features (required during private preview) -azd env set enableHostedAgentVNext "true" +azd auth login +``` -# Set the model deployment name (must match a deployment in your Foundry project) -azd env set MODEL_DEPLOYMENT_NAME "gpt-4.1" +#### 4. Fix git CRLF setting (Windows only) -# Set the toolbox endpoint (full URL including ?api-version=v1) -azd env set TOOLBOX_ENDPOINT "https://.services.ai.azure.com/api/projects//toolboxes//mcp?api-version=v1" +```bash +git config --global core.autocrlf false ``` +### Quick Start (Deploy with azd) + +> **IMPORTANT:** The `-m` (or `--manifest`) flag is **required** for `azd ai agent init`. +> It tells the command where to find your agent definition and source files. +> +> `-m` can point to either: +> - **A specific `agent.manifest.yaml` file** β€” init copies all files from the same directory as the manifest +> - **A folder containing `agent.manifest.yaml`** β€” init copies all files from that folder + ```bash -# 3. Provision Azure infrastructure -azd provision +# 1. Create a new directory and initialize the agent project +mkdir my-langgraph-agent && cd my-langgraph-agent +PROJECT_ID="/subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//projects/" +azd ai agent init \ + -m /path/to/samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox-user-identity/agent.manifest.yaml \ + --project-id $PROJECT_ID \ + --no-prompt \ + -e my-env + +# 2. Set required environment variables +azd env set enableHostedAgentVNext "true" -e my-env +azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME "gpt-4o" -e my-env # must match the deployment name in azure.yaml + +# 3. Provision infrastructure and deploy the container +azd up -e my-env + +# 4. Invoke the deployed agent (run from the scaffolded project directory) +azd ai agent invoke --new-session "List toolbox tools and when consent is required." --timeout 120 +``` + +### Post-Init Checklist + +After `azd ai agent init`, perform these steps before `azd up` will work: + +| # | Action | Why | +|---|--------|-----| +| 1 | `azd env set enableHostedAgentVNext "true"` | Without this, container health probes fail | +| 2 | Edit `src//agent.yaml`: replace all `${{VAR}}` with `${VAR}` | Init scaffolds broken double-brace syntax that is NOT resolved at deploy time | +| 3 | Verify `agent.yaml` uses **flat format** (`kind: hosted` at root) | The nested `template:` format silently fails during deploy | +| 4 | `azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME ""` | Must match the deployment `name` in `azure.yaml`; platform injection is unreliable without this (container crashes on startup) | +| 5 | Verify `main.py` checks `FOUNDRY_PROJECT_ENDPOINT` first | Platform injects this var, NOT `AZURE_AI_PROJECT_ENDPOINT` | +| 6 | **If using existing project with AppInsights already connected:** `azd env set ENABLE_MONITORING "false"` | Provision fails with duplicate App Insights connection error | +| 7 | **If model region β‰  RG region:** edit generated `infra/main.parameters.json` β€” change `aiDeploymentsLocation` value from `${AZURE_LOCATION}` to `${AZURE_AI_DEPLOYMENTS_LOCATION}`, then `azd env set AZURE_AI_DEPLOYMENTS_LOCATION ""` | Init templates map model deployment location to `AZURE_LOCATION` which is wrong when model is in a different region | + +### What `azd ai agent init` Does -# 4. Build and deploy the container -azd deploy +`azd ai agent init` copies all source files (main.py, Dockerfile, requirements.txt, etc.) **verbatim** from the manifest directory into `src//` in the scaffolded project. It does NOT generate or modify main.py β€” it copies the exact file from your manifest. -# 5. Invoke the deployed agent -azd ai agent invoke --new-session "What tools do you have?" --timeout 120 +The init command also: +- Creates `azure.yaml` with service config, connections, and toolbox definitions +- Creates `infra/` directory with Bicep templates +- Creates `.azure//.env` with environment variables + +### Project Structure + +After `azd ai agent init`, you get: + +``` +my-project/ +β”œβ”€β”€ .azure/ +β”‚ └── / +β”‚ β”œβ”€β”€ .env # Environment variables (auto-populated by azd) +β”‚ └── config.json # Subscription + location config +β”œβ”€β”€ infra/ +β”‚ β”œβ”€β”€ main.bicep # Top-level Bicep template +β”‚ β”œβ”€β”€ main.parameters.json # Parameters (references .env values) +β”‚ └── core/ai/ +β”‚ β”œβ”€β”€ ai-project.bicep # Project + connection deployment +β”‚ └── connection.bicep # Connection resource template +β”œβ”€β”€ src/ +β”‚ └── / +β”‚ β”œβ”€β”€ agent.yaml # Agent definition (env vars, protocols) +β”‚ β”œβ”€β”€ main.py # Agent code +β”‚ β”œβ”€β”€ Dockerfile # Container build +β”‚ └── requirements.txt # Dependencies +└── azure.yaml # azd service + toolbox configuration ``` > **Tip:** `azd ai agent invoke` must be run from the scaffolded project directory diff --git a/samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox-user-identity/agent.manifest.yaml b/samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox-user-identity/agent.manifest.yaml new file mode 100644 index 000000000..83f770609 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox-user-identity/agent.manifest.yaml @@ -0,0 +1,65 @@ +name: toolbox-langgraph-user-identity +displayName: "LangGraph Toolbox User Identity Agent" +description: > + LangGraph ReAct agent wired to a Microsoft Foundry toolbox with user-identity + and OAuth-protected MCP tools. Includes WorkIQ mail, WorkIQ calendar, + and GitHub MCP connections. +metadata: + tags: + - AI Agent Hosting + - LangGraph + - Toolbox + - MCP + - OAuth2 + - User Identity + - Microsoft Foundry +template: + name: toolbox-langgraph-user-identity + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 + environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" + - name: TOOLBOX_NAME + value: "langgraph-toolbox-user-identity-tools" +resources: + - kind: model + id: gpt-4.1 + name: AZURE_AI_MODEL_DEPLOYMENT_NAME + - kind: connection + name: workiq-mail-conn + category: RemoteTool + authType: UserEntraToken + audience: ea9ffc3e-8a23-4a7d-836d-234d7c7565c1 + target: https://agent365.svc.cloud.microsoft/agents/servers/mcp_MailTools + - kind: connection + name: workiq-calendar-conn + category: RemoteTool + authType: UserEntraToken + audience: ea9ffc3e-8a23-4a7d-836d-234d7c7565c1 + target: https://agent365.svc.cloud.microsoft/agents/servers/mcp_CalendarTools + - kind: connection + name: github-oauth-conn + category: RemoteTool + authType: OAuth2 + target: https://api.githubcopilot.com/mcp + managedConnector: foundrygithubmcp + credentials: + type: OAuth2 + - kind: toolbox + name: langgraph-toolbox-user-identity-tools + tools: + - type: mcp + server_label: workiq-mail + project_connection_id: workiq-mail-conn + server_url: https://agent365.svc.cloud.microsoft/agents/servers/mcp_MailTools + - type: mcp + server_label: workiq-calendar + project_connection_id: workiq-calendar-conn + server_url: https://agent365.svc.cloud.microsoft/agents/servers/mcp_CalendarTools + - type: mcp + server_label: github + project_connection_id: github-oauth-conn + server_url: https://api.githubcopilot.com/mcp diff --git a/samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox-user-identity/agent.yaml b/samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox-user-identity/agent.yaml new file mode 100644 index 000000000..d57e28bf9 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox-user-identity/agent.yaml @@ -0,0 +1,18 @@ +kind: hosted +name: toolbox-langgraph-user-identity +description: LangGraph agent with toolbox MCP tools using user identity and OAuth. +metadata: + tags: + - AI Agent Hosting + - LangGraph +protocols: + - protocol: responses + version: 1.0.0 +resources: + cpu: "0.25" + memory: 0.5Gi +environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} + - name: TOOLBOX_NAME + value: ${TOOLBOX_NAME} diff --git a/samples/python/toolbox/langgraph/main.py b/samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox-user-identity/main.py similarity index 65% rename from samples/python/toolbox/langgraph/main.py rename to samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox-user-identity/main.py index 1ad2208ca..c6e8959bd 100644 --- a/samples/python/toolbox/langgraph/main.py +++ b/samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox-user-identity/main.py @@ -12,7 +12,7 @@ ## User-Defined Variables -- `MODEL_DEPLOYMENT_NAME` β€” chat model deployment name +- `AZURE_AI_MODEL_DEPLOYMENT_NAME` β€” chat model deployment name - `TOOLBOX_ENDPOINT` β€” full toolbox MCP endpoint URL ## Starting with an Existing Project Endpoint @@ -20,7 +20,7 @@ For local development, set the FOUNDRY_* variables in `.env`: ```bash export FOUNDRY_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" -export MODEL_DEPLOYMENT_NAME="gpt-4.1" +export AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4.1" export TOOLBOX_ENDPOINT="https://.services.ai.azure.com/api/projects//toolboxes//mcp?api-version=v1" ``` """ @@ -30,26 +30,14 @@ import os import pathlib import re +from urllib.parse import unquote, urlparse import httpx from dotenv import load_dotenv load_dotenv(override=False) -# ── Tracing ─────────────────────────────────────────────────────────────────── -import setup -setup.setup() - -from langchain_azure_ai.callbacks.tracers import enable_auto_tracing -enable_auto_tracing( - enable_content_recording=True, - trace_all_langgraph_nodes=True, - provider_name="azure_openai", - auto_configure_azure_monitor=False, -) - -from langchain_openai import AzureChatOpenAI -from langchain_core.tools import tool +from langchain_openai import ChatOpenAI from langgraph.prebuilt import create_react_agent from azure.ai.agentserver.responses import ( ResponseContext, @@ -60,7 +48,7 @@ ) from azure.ai.agentserver.responses.models import CreateResponse from azure.identity import DefaultAzureCredential, get_bearer_token_provider -from langchain_mcp_adapters.client import MultiServerMCPClient +from langchain_azure_ai.tools import AzureAIProjectToolbox # ── Agent name and logger ──────────────────────────────────────────────────── @@ -83,51 +71,65 @@ def _read_agent_name() -> str: if not PROJECT_ENDPOINT: raise ValueError("FOUNDRY_PROJECT_ENDPOINT must be set") -MODEL_DEPLOYMENT_NAME = os.getenv("MODEL_DEPLOYMENT_NAME", "") +MODEL_DEPLOYMENT_NAME = os.getenv("AZURE_AI_MODEL_DEPLOYMENT_NAME", "") if not MODEL_DEPLOYMENT_NAME: - raise ValueError("MODEL_DEPLOYMENT_NAME environment variable must be set") - -from urllib.parse import urlparse as _urlparse -_parsed = _urlparse(PROJECT_ENDPOINT) -azure_openai_endpoint = f"{_parsed.scheme}://{_parsed.netloc}" + raise ValueError("AZURE_AI_MODEL_DEPLOYMENT_NAME environment variable must be set") +_credential = DefaultAzureCredential() token_provider = get_bearer_token_provider( - DefaultAzureCredential(), + _credential, "https://ai.azure.com/.default", ) -llm = AzureChatOpenAI( + +class _AzureTokenAuth(httpx.Auth): + """httpx Auth that injects a fresh bearer token on every request.""" + + def auth_flow(self, request): + request.headers["Authorization"] = f"Bearer {token_provider()}" + yield request + + +_llm_http_client = httpx.Client(auth=_AzureTokenAuth()) +_llm_async_http_client = httpx.AsyncClient(auth=_AzureTokenAuth()) + +llm = ChatOpenAI( + base_url=f"{PROJECT_ENDPOINT.rstrip('/')}/openai/v1", + api_key="placeholder", # overridden by _AzureTokenAuth model=MODEL_DEPLOYMENT_NAME, - azure_endpoint=azure_openai_endpoint, - azure_ad_token_provider=token_provider, - api_version=os.environ.get("OPENAI_API_VERSION", "2025-03-01-preview"), + http_client=_llm_http_client, + http_async_client=_llm_async_http_client, ) # ── Toolbox MCP helpers ──────────────────────────────────────────────────── -# TOOLBOX_ENDPOINT is the full pre-constructed MCP URL including toolbox name -# and api-version. Declared in agent.manifest.yaml. -TOOLBOX_ENDPOINT = os.getenv("TOOLBOX_ENDPOINT", "") +# Toolbox MCP endpoint resolution (in priority order): +# 1. TOOLBOX_ENDPOINT β€” explicit full URL override (CI / local). +# 2. TOOLBOX__MCP_ENDPOINT β€” azd auto-injects this per toolbox declared +# in azure.yaml. Variable name = upper(name) with dashes -> underscores. +# 3. Construct from PROJECT_ENDPOINT + TOOLBOX_NAME as a final fallback. +_TOOLBOX_ENDPOINT_OVERRIDE = os.getenv("TOOLBOX_ENDPOINT", "") +_TOOLBOX_NAME = os.getenv("TOOLBOX_NAME", "") +if _TOOLBOX_ENDPOINT_OVERRIDE: + TOOLBOX_ENDPOINT = _TOOLBOX_ENDPOINT_OVERRIDE +elif _TOOLBOX_NAME: + _azd_injected_var = ( + f"TOOLBOX_{_TOOLBOX_NAME.upper().replace('-', '_')}_MCP_ENDPOINT" + ) + TOOLBOX_ENDPOINT = os.getenv(_azd_injected_var) or ( + f"{PROJECT_ENDPOINT.rstrip('/')}/toolboxes/{_TOOLBOX_NAME}/mcp?api-version=v1" + ) +else: + TOOLBOX_ENDPOINT = "" # Feature-flag header value (e.g. "Toolboxes=V1Preview"). _TOOLBOX_FEATURES = os.getenv("FOUNDRY_AGENT_TOOLBOX_FEATURES", "Toolboxes=V1Preview") -# ── Toolbox MCP auth ────────────────────────────────────────────────────── - -class _ToolboxAuth(httpx.Auth): - """httpx Auth that injects a fresh bearer token on every request. - - Uses ``get_bearer_token_provider`` so the underlying credential handles - caching and proactive token refresh automatically. - """ - - def __init__(self, token_provider): - self._get_token = token_provider - - def auth_flow(self, request): - request.headers["Authorization"] = f"Bearer {self._get_token()}" - yield request +def _toolbox_name_from_endpoint(endpoint: str) -> str | None: + """Extract toolbox name from endpoint URL path.""" + match = re.search(r"/toolboxes/([^/]+)", endpoint) + return unquote(match.group(1)) if match else None SYSTEM_PROMPT = """You are a helpful assistant with access to Azure AI Foundry toolbox tools. @@ -152,66 +154,29 @@ def create_agent(model, tools): async def quickstart(): - """Build and return a LangGraph agent wired to an MCP client. + """Build and return a LangGraph agent wired to a Foundry toolbox. - Connects to the Azure AI Foundry toolbox MCP endpoint specified in - TOOLBOX_MCP_ENDPOINT. - - When the toolbox requires OAuth consent (e.g. GitHub OAuth connections), - the MCP server responds with error code -32006 and the consent URL as the - message. This function detects that scenario, logs the URL, and re-raises - so operators can complete the OAuth flow before retrying. + Uses AzureAIProjectToolbox from langchain-azure-ai to resolve and load + toolbox tools from the project endpoint. """ - if not TOOLBOX_ENDPOINT: + # Resolve toolbox name: prefer parsing it from the resolved TOOLBOX_ENDPOINT + # (so an explicit endpoint override wins), fall back to TOOLBOX_NAME env var. + toolbox_name = _toolbox_name_from_endpoint(TOOLBOX_ENDPOINT) or _TOOLBOX_NAME + if not toolbox_name: raise ValueError( - "TOOLBOX_ENDPOINT must be set. Declare it in agent.manifest.yaml " - "or set it directly for local dev." + "Set TOOLBOX_NAME in the environment or provide TOOLBOX_ENDPOINT " + "that contains '/toolboxes/' in its path." ) - # Connect to the Azure AI Foundry toolbox MCP endpoint. logger.info(f"Connecting to toolbox: {TOOLBOX_ENDPOINT}") - credential = DefaultAzureCredential() - token_provider = get_bearer_token_provider(credential, "https://ai.azure.com/.default") - toolbox_auth = _ToolboxAuth(token_provider) extra_headers = {"Foundry-Features": _TOOLBOX_FEATURES} if _TOOLBOX_FEATURES else {} - - client = MultiServerMCPClient( - { - "toolbox": { - "url": TOOLBOX_ENDPOINT, - "transport": "streamable_http", - "headers": extra_headers, - "auth": toolbox_auth, - } - } + toolbox = AzureAIProjectToolbox( + project_endpoint=PROJECT_ENDPOINT, + toolbox_name=toolbox_name, + credential=DefaultAzureCredential(), + extra_headers=extra_headers, ) - - try: - tools = await client.get_tools() - except BaseException as exc: - # OAuth consent required β€” the MCP server returns error code -32006 - # with the consent URL as the message. The MCP client wraps this in - # one or more ExceptionGroup layers, so we recurse to find it. - if _is_consent_error(exc): - consent_url = _extract_consent_url(exc) - logger.warning( - "OAuth consent required. Open the following URL in a browser " - "to authorize, then restart the agent:\n\n %s\n", - consent_url, - ) - # Instead of crashing the container, return an agent with a - # fallback tool that surfaces the consent URL to the caller. - - @tool - def oauth_consent_required(query: str) -> str: - """Return instructions for completing OAuth consent.""" - return ( - f"OAuth consent is required before this agent's tools can " - f"be used. Please open the following URL in a browser to " - f"authorize access, then try again:\n\n {consent_url}" - ) - return create_agent(llm, [oauth_consent_required]), client - raise + tools = await toolbox.get_tools() # Enable error handling so that tool-call failures are returned as tool # messages instead of raising ToolException (which breaks the agent's @@ -235,7 +200,7 @@ def oauth_consent_required(query: str) -> str: schema["properties"] = props logger.info(f"Loaded {len(tools)} tools from MCP") - return create_agent(llm, tools), client + return create_agent(llm, tools), toolbox def _extract_assistant_text(result: dict) -> str: @@ -263,6 +228,25 @@ def _extract_assistant_text(result: dict) -> str: # Consent-URL error code returned by the Foundry MCP gateway. _CONSENT_ERROR_CODE = -32006 +_CONSENT_HOST = "consent.azure-apim.net" + + +def _contains_consent_host(text: str) -> bool: + """Return True if *text* contains a URL whose hostname is the consent host.""" + for token in re.findall(r"https?://[^\s'\"<>]+", text): + host = urlparse(token).hostname + if host and (host == _CONSENT_HOST or host.endswith(f".{_CONSENT_HOST}")): + return True + return False + + +def _extract_allowed_consent_url(text: str) -> str | None: + """Return the first URL in *text* whose hostname is exactly consent.azure-apim.net.""" + for candidate in re.findall(r"https?://[^\s)>\]\"']+", text): + parsed = urlparse(candidate) + if parsed.hostname == "consent.azure-apim.net": + return candidate + return None def _is_consent_error(exc: BaseException) -> bool: @@ -271,8 +255,8 @@ def _is_consent_error(exc: BaseException) -> bool: error_data = getattr(exc, "error", None) if error_data is not None and getattr(error_data, "code", None) == _CONSENT_ERROR_CODE: return True - # Fallback: check the string representation for the consent URL pattern - if "consent.azure-apim.net" in str(exc): + # Fallback: parse URL(s) from the exception text and validate host + if _contains_consent_host(str(exc)): return True # Recurse into ExceptionGroup / BaseExceptionGroup sub-exceptions if hasattr(exc, "exceptions"): @@ -284,10 +268,12 @@ def _extract_consent_url(exc: BaseException) -> str: """Walk nested exceptions and return the consent URL string.""" error_data = getattr(exc, "error", None) if error_data is not None and getattr(error_data, "code", None) == _CONSENT_ERROR_CODE: - return getattr(error_data, "message", str(exc)) + message = getattr(error_data, "message", str(exc)) + return _extract_allowed_consent_url(message) or message msg = str(exc) - if "consent.azure-apim.net" in msg: - return msg + matched_url = _extract_allowed_consent_url(msg) + if matched_url: + return matched_url if hasattr(exc, "exceptions"): for sub in exc.exceptions: url = _extract_consent_url(sub) @@ -334,6 +320,30 @@ async def _get_agent(): if _agent is not None: return _agent + # Retry transient cold-start errors / empty tool lists: the toolbox + # MCP proxy can briefly return zero tools while the upstream toolbox + # container is still starting. + last_exc: Exception | None = None + for attempt in range(1, 6): + try: + agent, mcp_client = await quickstart() + bound_tools = getattr(agent, "tools", None) or [] + if not bound_tools: + logger.warning( + "Toolbox returned 0 tools on attempt %d; retrying", attempt, + ) + await asyncio.sleep(min(2 ** attempt, 15)) + continue + _agent, _mcp_client = agent, mcp_client + return _agent + except Exception as exc: # noqa: BLE001 + last_exc = exc + logger.warning( + "Toolbox connect attempt %d failed: %s; retrying", attempt, exc, + ) + await asyncio.sleep(min(2 ** attempt, 15)) + if last_exc is not None: + raise last_exc _agent, _mcp_client = await quickstart() return _agent @@ -375,9 +385,6 @@ async def handle_response( assistant_reply = "I could not complete this request within the local timeout. Please retry with a simpler prompt." except asyncio.CancelledError: assistant_reply = "The request was cancelled before completion. Please retry." - except Exception as e: - logger.error(f"Failed to process request: {e}", exc_info=True) - assistant_reply = f"I encountered an error processing your request: {e}" message_item = stream.add_output_item_message() yield message_item.emit_added() diff --git a/samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox-user-identity/requirements.txt b/samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox-user-identity/requirements.txt new file mode 100644 index 000000000..c05853053 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox-user-identity/requirements.txt @@ -0,0 +1,11 @@ +azure-ai-agentserver-responses==1.0.0b4 +langchain-azure-ai>=1.2.2 +langchain-openai>=1.1.14 +langgraph>=1.1.8 +langchain-mcp-adapters>=0.2.2 +azure-ai-projects>=2.0.0 +azure-identity>=1.25.0 +python-dotenv==1.1.1 +starlette<1.0.0 +aiohttp +pydantic-core<2.46.0 diff --git a/samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox/Dockerfile b/samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox/Dockerfile index cd962d026..3aac3c8c4 100644 --- a/samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox/Dockerfile +++ b/samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox/Dockerfile @@ -2,6 +2,11 @@ FROM mcr.microsoft.com/azurelinux/base/python:3.12 WORKDIR /app COPY . user_agent/ WORKDIR /app/user_agent +# Upgrade pip first: the rpm-installed pip 24.2 in this base image cannot parse +# newer dependency markers (e.g. extra == "contentunderstanding" on +# langchain-azure-ai 1.2.3+). --ignore-installed avoids the +# "uninstall-no-record-file" error from the rpm-managed pip package. +RUN pip install --upgrade --ignore-installed pip RUN if [ -f requirements.txt ]; then pip install -r requirements.txt; fi EXPOSE 8088 CMD ["python3", "main.py"] diff --git a/samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox/agent.manifest.yaml b/samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox/agent.manifest.yaml index 04a2d8615..9867eba94 100644 --- a/samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox/agent.manifest.yaml +++ b/samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox/agent.manifest.yaml @@ -21,11 +21,29 @@ template: value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" - name: TOOLBOX_NAME value: "agent-tools" +# parameters: secret values prompted at init time (or set via azd env). +# azd uppercases the param name to find the env var: github_pat β†’ GITHUB_PAT. +parameters: + github_pat: + secret: true + description: GitHub Personal Access Token (classic ghp_... or fine-grained github_pat_...) + +# resources: connections and toolboxes scaffolded into azure.yaml by azd ai agent init. resources: - - kind: model - id: gpt-4.1-mini - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + - kind: connection + name: github-mcp-conn + target: https://api.githubcopilot.com/mcp + category: remoteTool + credentials: + type: CustomKeys + keys: + Authorization: "Bearer {{ github_pat }}" + - kind: toolbox name: agent-tools tools: - type: web_search + - type: mcp + server_label: github + server_url: https://api.githubcopilot.com/mcp + project_connection_id: github-mcp-conn diff --git a/samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox/main.py b/samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox/main.py index f0b12e517..74f94a895 100644 --- a/samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox/main.py +++ b/samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox/main.py @@ -30,7 +30,7 @@ import os import pathlib import re -from urllib.parse import urlparse +from urllib.parse import unquote, urlparse import httpx from dotenv import load_dotenv @@ -38,7 +38,6 @@ load_dotenv(override=False) from langchain_openai import ChatOpenAI -from langchain_core.tools import tool from langgraph.prebuilt import create_react_agent from azure.ai.agentserver.responses import ( ResponseContext, @@ -49,7 +48,7 @@ ) from azure.ai.agentserver.responses.models import CreateResponse from azure.identity import DefaultAzureCredential, get_bearer_token_provider -from langchain_mcp_adapters.client import MultiServerMCPClient +from langchain_azure_ai.tools import AzureAIProjectToolbox # ── Agent name and logger ──────────────────────────────────────────────────── @@ -104,19 +103,34 @@ def auth_flow(self, request): # ── Toolbox MCP helpers ──────────────────────────────────────────────────── -# TOOLBOX_NAME is declared in agent.manifest.yaml and resolved from the toolbox -# resource at deploy time. The full MCP URL is constructed from the project endpoint. -# Falls back to TOOLBOX_ENDPOINT for local testing / explicit override. +# Toolbox MCP endpoint resolution (in priority order): +# 1. TOOLBOX_ENDPOINT β€” explicit full URL override (CI / local). +# 2. TOOLBOX__MCP_ENDPOINT β€” azd auto-injects this per toolbox declared +# in azure.yaml. Variable name = upper(name) with dashes -> underscores. +# 3. Construct from PROJECT_ENDPOINT + TOOLBOX_NAME as a final fallback. +_TOOLBOX_ENDPOINT_OVERRIDE = os.getenv("TOOLBOX_ENDPOINT", "") _TOOLBOX_NAME = os.getenv("TOOLBOX_NAME", "") -TOOLBOX_ENDPOINT = ( - f"{PROJECT_ENDPOINT.rstrip('/')}/toolboxes/{_TOOLBOX_NAME}/mcp?api-version=v1" - if _TOOLBOX_NAME - else os.getenv("TOOLBOX_ENDPOINT", "") -) +if _TOOLBOX_ENDPOINT_OVERRIDE: + TOOLBOX_ENDPOINT = _TOOLBOX_ENDPOINT_OVERRIDE +elif _TOOLBOX_NAME: + _azd_injected_var = ( + f"TOOLBOX_{_TOOLBOX_NAME.upper().replace('-', '_')}_MCP_ENDPOINT" + ) + TOOLBOX_ENDPOINT = os.getenv(_azd_injected_var) or ( + f"{PROJECT_ENDPOINT.rstrip('/')}/toolboxes/{_TOOLBOX_NAME}/mcp?api-version=v1" + ) +else: + TOOLBOX_ENDPOINT = "" # Feature-flag header value (e.g. "Toolboxes=V1Preview"). _TOOLBOX_FEATURES = os.getenv("FOUNDRY_AGENT_TOOLBOX_FEATURES", "Toolboxes=V1Preview") + +def _toolbox_name_from_endpoint(endpoint: str) -> str | None: + """Extract toolbox name from endpoint URL path.""" + match = re.search(r"/toolboxes/([^/]+)", endpoint) + return unquote(match.group(1)) if match else None + SYSTEM_PROMPT = """You are a helpful assistant with access to Azure AI Foundry toolbox tools. When tool output includes Azure AI Search retrieval metadata, use citation-style @@ -140,64 +154,29 @@ def create_agent(model, tools): async def quickstart(): - """Build and return a LangGraph agent wired to an MCP client. + """Build and return a LangGraph agent wired to a Foundry toolbox. - Connects to the Azure AI Foundry toolbox MCP endpoint specified in - TOOLBOX_MCP_ENDPOINT. - - When the toolbox requires OAuth consent (e.g. GitHub OAuth connections), - the MCP server responds with error code -32006 and the consent URL as the - message. This function detects that scenario, logs the URL, and re-raises - so operators can complete the OAuth flow before retrying. + Uses AzureAIProjectToolbox from langchain-azure-ai to resolve and load + toolbox tools from the project endpoint. """ - if not TOOLBOX_ENDPOINT: + # Resolve toolbox name: prefer parsing it from the resolved TOOLBOX_ENDPOINT + # (so an explicit endpoint override wins), fall back to TOOLBOX_NAME env var. + toolbox_name = _toolbox_name_from_endpoint(TOOLBOX_ENDPOINT) or _TOOLBOX_NAME + if not toolbox_name: raise ValueError( - "TOOLBOX_ENDPOINT must be set. Declare it in agent.manifest.yaml " - "or set it directly for local dev." + "Set TOOLBOX_NAME in the environment or provide TOOLBOX_ENDPOINT " + "that contains '/toolboxes/' in its path." ) - # Connect to the Azure AI Foundry toolbox MCP endpoint. logger.info(f"Connecting to toolbox: {TOOLBOX_ENDPOINT}") - toolbox_auth = _AzureTokenAuth() extra_headers = {"Foundry-Features": _TOOLBOX_FEATURES} if _TOOLBOX_FEATURES else {} - - client = MultiServerMCPClient( - { - "toolbox": { - "url": TOOLBOX_ENDPOINT, - "transport": "streamable_http", - "headers": extra_headers, - "auth": toolbox_auth, - } - } + toolbox = AzureAIProjectToolbox( + project_endpoint=PROJECT_ENDPOINT, + toolbox_name=toolbox_name, + credential=DefaultAzureCredential(), + extra_headers=extra_headers, ) - - try: - tools = await client.get_tools() - except BaseException as exc: - # OAuth consent required β€” the MCP server returns error code -32006 - # with the consent URL as the message. The MCP client wraps this in - # one or more ExceptionGroup layers, so we recurse to find it. - if _is_consent_error(exc): - consent_url = _extract_consent_url(exc) - logger.warning( - "OAuth consent required. Open the following URL in a browser " - "to authorize, then restart the agent:\n\n %s\n", - consent_url, - ) - # Instead of crashing the container, return an agent with a - # fallback tool that surfaces the consent URL to the caller. - - @tool - def oauth_consent_required(query: str) -> str: - """Return instructions for completing OAuth consent.""" - return ( - f"OAuth consent is required before this agent's tools can " - f"be used. Please open the following URL in a browser to " - f"authorize access, then try again:\n\n {consent_url}" - ) - return create_agent(llm, [oauth_consent_required]), client - raise + tools = await toolbox.get_tools() # Enable error handling so that tool-call failures are returned as tool # messages instead of raising ToolException (which breaks the agent's @@ -221,7 +200,7 @@ def oauth_consent_required(query: str) -> str: schema["properties"] = props logger.info(f"Loaded {len(tools)} tools from MCP") - return create_agent(llm, tools), client + return create_agent(llm, tools), toolbox def _extract_assistant_text(result: dict) -> str: diff --git a/samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox/requirements.txt b/samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox/requirements.txt index 2599c3b08..c05853053 100644 --- a/samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox/requirements.txt +++ b/samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox/requirements.txt @@ -1,4 +1,5 @@ azure-ai-agentserver-responses==1.0.0b4 +langchain-azure-ai>=1.2.2 langchain-openai>=1.1.14 langgraph>=1.1.8 langchain-mcp-adapters>=0.2.2 diff --git a/samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox/test-payload.txt b/samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox/test-payload.txt deleted file mode 100644 index 6e144aa6a..000000000 --- a/samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox/test-payload.txt +++ /dev/null @@ -1,3 +0,0 @@ -what tools do you have -what tools do you have -what tools do you have diff --git a/samples/python/hosted-agents/bring-your-own/responses/openai-agents-sdk/.dockerignore b/samples/python/hosted-agents/bring-your-own/responses/openai-agents-sdk/.dockerignore new file mode 100644 index 000000000..c1b312c48 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/responses/openai-agents-sdk/.dockerignore @@ -0,0 +1,23 @@ +**/__pycache__/ +**/*.py[cod] +**/*.egg-info/ +.eggs/ + +# Virtual environments +.venv/ +venv/ +env/ + +# IDE settings +.vscode/ +.idea/ + +# Version control +.git/ +.gitignore + +# Docker files +.dockerignore + +# Local env files +.env diff --git a/samples/python/hosted-agents/bring-your-own/responses/openai-agents-sdk/.env.example b/samples/python/hosted-agents/bring-your-own/responses/openai-agents-sdk/.env.example new file mode 100644 index 000000000..2d2baf38a --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/responses/openai-agents-sdk/.env.example @@ -0,0 +1,10 @@ +# Foundry project endpoint β€” auto-injected in hosted containers. +# Only set manually if running without `azd ai agent run`. +# FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ + +# Model deployment name β€” must match a deployment in your Foundry project. +AZURE_AI_MODEL_DEPLOYMENT_NAME= + +# Application Insights β€” auto-injected in hosted containers. +# Set locally to send traces to your Application Insights instance. +# APPLICATIONINSIGHTS_CONNECTION_STRING= diff --git a/samples/python/hosted-agents/bring-your-own/responses/openai-agents-sdk/Dockerfile b/samples/python/hosted-agents/bring-your-own/responses/openai-agents-sdk/Dockerfile new file mode 100644 index 000000000..7350bc536 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/responses/openai-agents-sdk/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY . user_agent/ +WORKDIR /app/user_agent + +RUN pip install --no-input --upgrade pip && \ + if [ -f requirements.txt ]; then \ + pip install --no-input -r requirements.txt; \ + else \ + echo "No requirements.txt found"; \ + fi + +EXPOSE 8088 + +CMD ["python", "main.py"] diff --git a/samples/python/hosted-agents/bring-your-own/responses/openai-agents-sdk/README.md b/samples/python/hosted-agents/bring-your-own/responses/openai-agents-sdk/README.md new file mode 100644 index 000000000..e985a10b9 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/responses/openai-agents-sdk/README.md @@ -0,0 +1,169 @@ +# OpenAI Agents SDK β€” Responses Protocol (Streaming) + +**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. + +A minimal getting-started agent using the [OpenAI Python SDK](https://pypi.org/project/openai/) (Responses API) backed by **Microsoft Foundry** (no OpenAI API key required), with the [azure-ai-agentserver-responses](https://pypi.org/project/azure-ai-agentserver-responses/) protocol. + +Authentication uses `DefaultAzureCredential` via `AIProjectClient` β€” the same pattern used by other Foundry hosted-agent samples. + +## How It Works + +1. Receives requests via `POST /responses` +2. Reads input from the responses context (`context.get_input_text()`) +3. Reads platform-managed history (`context.get_history()`) +4. Streams text deltas from OpenAI Agents SDK events +5. Returns `TextResponse(...)` so the responses protocol SDK emits the response lifecycle/events + +## Environment Variables + +| Variable | Required | Description | +| -------- | -------- | ----------- | +| `FOUNDRY_PROJECT_ENDPOINT` | Yes | Foundry project endpoint (auto-injected in hosted containers; set by `azd ai agent run` locally) | +| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | Yes | Model deployment name in your Foundry project | + +## Running Locally + +### Prerequisites + +- Python 3.10+ +- A Microsoft Foundry project with a model deployment (e.g. `gpt-4o-mini`) +- Azure CLI logged in (`az login`) or another credential supported by `DefaultAzureCredential` + +### Using `azd` (Recommended) + +`azd ai agent run` automatically injects `FOUNDRY_PROJECT_ENDPOINT` and starts the agent: + +```bash +azd ai agent run +``` + +The agent starts on `http://localhost:8088/`. + +### Without `azd` + +```bash +pip install -r requirements.txt +cp .env.example .env # then set FOUNDRY_PROJECT_ENDPOINT and AZURE_AI_MODEL_DEPLOYMENT_NAME +python main.py +``` + +The agent starts on `http://localhost:8088/`. + +## Invoke with azd + +### Local + +**Bash:** + +```bash +azd ai agent invoke --local "What can you help me with?" +``` + +**PowerShell:** + +```powershell +azd ai agent invoke --local '{\"input\": \"What can you help me with?\"}' +``` + +### Test with curl + +```bash +# First message +curl -sS -N -X POST http://localhost:8088/responses \ + -H "Content-Type: application/json" \ + -d '{"input": "What is Microsoft Foundry?", "stream": true}' + +# Follow-up (multi-turn β€” same session remembers context) +curl -sS -N -X POST "http://localhost:8088/responses" \ + -H "Content-Type: application/json" \ + -d '{"input": "What hosted agent options does it offer?", "agent_session_id": "chat-001", "stream": true}' +``` + +### Streaming Behavior + +The responses protocol SDK emits lifecycle and content events automatically when +`TextResponse(...)` is returned. + +## Deploying the Agent to Microsoft Foundry + +Once you've tested locally, deploy to Microsoft Foundry: + +```bash +# Provision Azure resources (skip if already done during local setup) +azd provision + +# Build, push, and deploy the agent to Foundry +azd deploy +``` + +After deploying, invoke the agent running in Foundry: + +**Bash:** + +```bash +azd ai agent invoke '{"input": "What can you help me with?"}' +``` + +**PowerShell:** + +```powershell +azd ai agent invoke '{\"input\": \"What can you help me with?\"}' +``` + +To stream logs from the running agent: + +```bash +azd ai agent monitor +``` + +For the full deployment guide, see [Azure AI Foundry hosted agents](https://aka.ms/azdaiagent/docs). + +## Troubleshooting + +### `FOUNDRY_PROJECT_ENDPOINT` not set + +```text +EnvironmentError: FOUNDRY_PROJECT_ENDPOINT environment variable is not set. +``` + +Use `azd ai agent run` which sets this automatically, or set it manually: + +```bash +export FOUNDRY_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" +``` + +### `AZURE_AI_MODEL_DEPLOYMENT_NAME` not set + +Set it to the name of a model deployment in your Foundry project: + +```bash +export AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +### Authentication failure + +Ensure you are logged in with Azure CLI: + +```bash +az login +``` + +`DefaultAzureCredential` tries several credential sources in order. See the [azure-identity docs](https://learn.microsoft.com/python/api/azure-identity/azure.identity.defaultazurecredential) for details. + +### No streaming output + +Ensure you are using `curl -N` or another streaming-capable HTTP client. The agent uses `text/event-stream` media type. + +### Images built on Apple Silicon or other ARM64 machines do not work on our service + +We **recommend deploying with `azd deploy`**, which uses ACR remote build and always produces images with the correct architecture. + +If you choose to **build locally**, and your machine is **not `linux/amd64`** (for example, an Apple Silicon Mac), the image will **not be compatible with our service**, causing runtime failures. + +**Fix for local builds:** + +```bash +docker build --platform=linux/amd64 -t image . +``` + +This forces the image to be built for the required `amd64` architecture. diff --git a/samples/python/hosted-agents/bring-your-own/responses/openai-agents-sdk/agent.manifest.yaml b/samples/python/hosted-agents/bring-your-own/responses/openai-agents-sdk/agent.manifest.yaml new file mode 100644 index 000000000..374bf606b --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/responses/openai-agents-sdk/agent.manifest.yaml @@ -0,0 +1,34 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: openai-agents-sdk-invocations +displayName: "OpenAI Agents SDK (Responses)" +description: > + A getting-started agent that uses the OpenAI Agents SDK (openai-agents) with + Foundry Azure credential flow β€” no OPENAI_API_KEY required. The SDK's Agent + and Runner drive the agent loop through the Responses protocol, with + multi-turn conversation history managed by the platform. +metadata: + tags: + - AI Agent Hosting + - OpenAI SDK + - Responses Protocol + - Bring Your Own +template: + name: openai-agents-sdk-invocations + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 + environment_variables: + # FOUNDRY_PROJECT_ENDPOINT and APPLICATIONINSIGHTS_CONNECTION_STRING + # are injected by the platform (hosted) and translated by azd (local) + # β€” do NOT declare them here. + # + # Model deployment name β€” resolved from the resources section below. + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" +parameters: + properties: [] +resources: + - kind: model + id: gpt-4.1-mini + name: AZURE_AI_MODEL_DEPLOYMENT_NAME diff --git a/samples/python/hosted-agents/bring-your-own/responses/openai-agents-sdk/agent.yaml b/samples/python/hosted-agents/bring-your-own/responses/openai-agents-sdk/agent.yaml new file mode 100644 index 000000000..63b7e42fd --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/responses/openai-agents-sdk/agent.yaml @@ -0,0 +1,12 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +kind: hosted +name: openai-agents-sdk-invocations +protocols: + - protocol: responses + version: 1.0.0 +resources: + cpu: "0.25" + memory: 0.5Gi +environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} diff --git a/samples/python/hosted-agents/bring-your-own/responses/openai-agents-sdk/main.py b/samples/python/hosted-agents/bring-your-own/responses/openai-agents-sdk/main.py new file mode 100644 index 000000000..3a503f490 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/responses/openai-agents-sdk/main.py @@ -0,0 +1,104 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Getting-started: OpenAI Agents SDK with Foundry model auth and responses protocol. + +Uses the openai-agents SDK (Agent + Runner) backed by an Azure OpenAI client obtained +through Foundry's Azure credential flow β€” no OPENAI_API_KEY required. +""" + +import asyncio +import os + +from agents import Agent, Runner, set_default_openai_client, set_tracing_disabled +from agents.stream_events import RawResponsesStreamEvent +from azure.identity.aio import DefaultAzureCredential, get_bearer_token_provider +from openai import AsyncOpenAI + +from azure.ai.agentserver.responses import ( + CreateResponse, + ResponseContext, + ResponsesAgentServerHost, + TextResponse, +) +from azure.ai.agentserver.responses.models import ( + MessageContentInputTextContent, + MessageContentOutputTextContent, +) + +# Build an AsyncOpenAI client using Entra ID +_token_provider = get_bearer_token_provider( + DefaultAzureCredential(), "https://ai.azure.com/.default" +) +_async_oai_client = AsyncOpenAI( + base_url=f"{os.environ.get('FOUNDRY_PROJECT_ENDPOINT', '').rstrip('/')}/openai/v1", + api_key=_token_provider, +) + +# Point the openai-agents SDK at our Foundry-authenticated Azure OpenAI client. +# Disable SDK tracing β€” it uploads to platform.openai.com which requires an OpenAI API key. +set_default_openai_client(_async_oai_client) +set_tracing_disabled(True) + +_SYSTEM_PROMPT = "You are a helpful AI assistant. Be concise and informative." + +# Create the agent once at startup. The SDK manages the agent loop, tool calls, etc. +_agent = Agent( + name="foundry-assistant", + instructions=_SYSTEM_PROMPT, + model=os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME"), +) + +app = ResponsesAgentServerHost() + + +def _build_input_items(current_input: str, history: list) -> list[dict[str, str]]: + """Build openai-agents input from Responses protocol history items.""" + items: list[dict[str, str]] = [] + for item in history: + content = getattr(item, "content", None) + if not content: + continue + for part in content: + if isinstance(part, MessageContentOutputTextContent) and part.text: + items.append({"role": "assistant", "content": part.text}) + elif isinstance(part, MessageContentInputTextContent) and part.text: + items.append({"role": "user", "content": part.text}) + + items.append({"role": "user", "content": current_input}) + return items + + +@app.response_handler +async def handle_response( + request: CreateResponse, + context: ResponseContext, + _cancellation_signal: asyncio.Event, +): + user_message = (await context.get_input_text() or "").strip() + if not user_message: + user_message = "What can you help me with?" + + history = await context.get_history() + input_items = _build_input_items(user_message, history) + + async def stream_text(): + try: + async for event in Runner.run_streamed(_agent, input=input_items).stream_events(): + if not isinstance(event, RawResponsesStreamEvent): + continue + + raw_event = event.data + if getattr(raw_event, "type", None) != "response.output_text.delta": + continue + + delta = getattr(raw_event, "delta", None) + if isinstance(delta, str) and delta: + yield delta + except Exception as ex: + yield f"Error during openai-agents streaming: {ex}" + + return TextResponse(context, request, text=stream_text()) + + +if __name__ == "__main__": + app.run() diff --git a/samples/python/hosted-agents/bring-your-own/responses/openai-agents-sdk/requirements.txt b/samples/python/hosted-agents/bring-your-own/responses/openai-agents-sdk/requirements.txt new file mode 100644 index 000000000..207ec658d --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/responses/openai-agents-sdk/requirements.txt @@ -0,0 +1,3 @@ +azure-ai-agentserver-responses==1.0.0b3 +azure-identity==1.25.3 +openai-agents>=0.1.0 diff --git a/samples/python/hosted-agents/bring-your-own/voicelive/client/voicelive_client.py b/samples/python/hosted-agents/bring-your-own/voicelive/client/voicelive_client.py new file mode 100644 index 000000000..769d09b7b --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/voicelive/client/voicelive_client.py @@ -0,0 +1,504 @@ +#!/usr/bin/env python +# pylint: disable=line-too-long,useless-suppression + +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +""" +FILE: voicelive_sample.py + +DESCRIPTION: + This sample demonstrates how to connect to an Azure AI Foundry hosted agent + with the Azure VoiceLive SDK. + +USAGE: + python voicelive_sample.py + + Command-line arguments: + --endpoint The Azure VoiceLive endpoint (required) + --agent-name The name of your Azure AI Foundry agent (required) + --project-name The name of the Foundry project containing the agent (required) +""" + +from __future__ import annotations + +import argparse +import asyncio +import base64 +import logging +import queue +import signal +import sys +from typing import Optional, Union, cast + +# Audio processing imports +try: + import pyaudio +except ImportError: + print("This sample requires pyaudio. Install with:") + print(" Linux: sudo apt-get install -y portaudio19-dev libasound2-dev && pip install pyaudio") + print(" macOS: brew install portaudio && pip install pyaudio") + print(" Windows: pip install pyaudio") + sys.exit(1) + +# Azure VoiceLive SDK imports +from azure.ai.voicelive.aio import VoiceLiveConnection, connect, AgentSessionConfig +from azure.ai.voicelive.models import ( + AudioEchoCancellation, + AudioNoiseReduction, + AzureStandardVoice, + InputAudioFormat, + LlmInterimResponseConfig, + Modality, + OutputAudioFormat, + RequestSession, + ServerEventType, + ServerVad, +) +from azure.core.credentials_async import AsyncTokenCredential +from azure.identity.aio import DefaultAzureCredential + +# Set up logging +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") +logger = logging.getLogger(__name__) + + +class AudioProcessor: + """ + Handles real-time audio capture and playback for the voice assistant. + + Uses callback-based audio streaming for efficient capture and playback. + Implements sequence number based audio packet system for proper interrupt handling. + + Threading Architecture: + - Main thread: Event loop and UI + - PyAudio callback threads: Handle audio I/O in real-time + """ + + loop: asyncio.AbstractEventLoop + + class AudioPlaybackPacket: + """Represents a packet that can be sent to the audio playback queue.""" + + def __init__(self, seq_num: int, data: Optional[bytes]): + self.seq_num = seq_num + self.data = data + + def __init__(self, connection: VoiceLiveConnection): + self.connection = connection + self.audio = pyaudio.PyAudio() + + # Audio configuration - PCM16, 24kHz, mono + self.format = pyaudio.paInt16 + self.channels = 1 + self.rate = 24000 + self.chunk_size = 1200 # 50ms chunks + + # Capture and playback state + self.input_stream: Optional[pyaudio.Stream] = None + + # Playback with sequence numbers for interrupt handling + self.playback_queue: queue.Queue[AudioProcessor.AudioPlaybackPacket] = queue.Queue() + self.playback_base = 0 + self.next_seq_num = 0 + self.output_stream: Optional[pyaudio.Stream] = None + + logger.info("AudioProcessor initialized with 24kHz PCM16 mono audio") + + def start_capture(self): + """Start capturing audio from microphone using callback.""" + + def _capture_callback( + in_data, _frame_count, _time_info, _status_flags # data # number of frames # dictionary + ): + """Audio capture callback - runs in PyAudio thread.""" + audio_base64 = base64.b64encode(in_data).decode("utf-8") + future = asyncio.run_coroutine_threadsafe( + self.connection.input_audio_buffer.append(audio=audio_base64), self.loop + ) + future.add_done_callback( + lambda f: logger.error("Error in audio buffer append: %s", f.exception()) if f.exception() else None + ) + return (None, pyaudio.paContinue) + + if self.input_stream: + return + + # Store the current event loop for use in callbacks + self.loop = asyncio.get_running_loop() + + try: + self.input_stream = self.audio.open( + format=self.format, + channels=self.channels, + rate=self.rate, + input=True, + frames_per_buffer=self.chunk_size, + stream_callback=_capture_callback, + ) + logger.info("Started audio capture") + + except Exception: + logger.exception("Failed to start audio capture") + raise + + def start_playback(self): + """Initialize audio playback system using callback.""" + if self.output_stream: + return + + remaining = bytes() + + def _playback_callback(_in_data, frame_count, _time_info, _status_flags): + nonlocal remaining + frame_count *= pyaudio.get_sample_size(pyaudio.paInt16) + + out = remaining[:frame_count] + remaining = remaining[frame_count:] + + while len(out) < frame_count: + try: + packet = self.playback_queue.get_nowait() + except queue.Empty: + out = out + bytes(frame_count - len(out)) + continue + except Exception: + logger.exception("Error in audio playback") + raise + + if not packet or not packet.data: + # None packet indicates end of stream + logger.info("End of playback queue.") + break + + if packet.seq_num < self.playback_base: + # Skip requested - ignore skipped packet and clear remaining + if len(remaining) > 0: + remaining = bytes() + continue + + num_to_take = frame_count - len(out) + out = out + packet.data[:num_to_take] + remaining = packet.data[num_to_take:] + + if len(out) >= frame_count: + return (out, pyaudio.paContinue) + else: + return (out, pyaudio.paComplete) + + try: + self.output_stream = self.audio.open( + format=self.format, + channels=self.channels, + rate=self.rate, + output=True, + frames_per_buffer=self.chunk_size, + stream_callback=_playback_callback, + ) + logger.info("Audio playback system ready") + except Exception: + logger.exception("Failed to initialize audio playback") + raise + + def _get_and_increase_seq_num(self): + seq = self.next_seq_num + self.next_seq_num += 1 + return seq + + def queue_audio(self, audio_data: Optional[bytes]) -> None: + """Queue audio data for playback.""" + self.playback_queue.put( + AudioProcessor.AudioPlaybackPacket(seq_num=self._get_and_increase_seq_num(), data=audio_data) + ) + + def skip_pending_audio(self): + """Skip current audio in playback queue (used during interrupts).""" + self.playback_base = self._get_and_increase_seq_num() + + def shutdown(self): + """Clean up audio resources.""" + if self.input_stream: + self.input_stream.stop_stream() + self.input_stream.close() + self.input_stream = None + + logger.info("Stopped audio capture") + + # Inform thread to complete + if self.output_stream: + self.skip_pending_audio() + self.queue_audio(None) + self.output_stream.stop_stream() + self.output_stream.close() + self.output_stream = None + + logger.info("Stopped audio playback") + + if self.audio: + self.audio.terminate() + + logger.info("Audio processor cleaned up") + + +class AgentV2VoiceAssistant: + """ + Voice assistant using Azure AI Foundry agent with AgentSessionConfig. + + This demonstrates the new pattern where the agent is configured at + connection time using AgentSessionConfig, rather than as a tool in the session. + """ + + def __init__( + self, + endpoint: str, + credential: AsyncTokenCredential, + agent_config: AgentSessionConfig, + ) -> None: + self.endpoint = endpoint + self.credential = credential + self.agent_config = agent_config + self.connection: Optional[VoiceLiveConnection] = None + self.audio_processor: Optional[AudioProcessor] = None + self.session_ready = False + + async def start(self): + """Start the voice assistant session.""" + try: + logger.info( + "Connecting to VoiceLive API with agent %s for project %s", + self.agent_config.get("agent_name"), + self.agent_config.get("project_name"), + ) + + # Connect using AgentSessionConfig + async with connect( + endpoint=self.endpoint, + credential=self.credential, + agent_config=self.agent_config, # Agent configured at connection time + ) as connection: + conn = connection + self.connection = conn + + # Initialize audio processor + ap = AudioProcessor(conn) + self.audio_processor = ap + + # Configure session for voice conversation + await self._setup_session() + + # Start audio systems + ap.start_playback() + + logger.info("Voice assistant ready! Start speaking...") + print("\n" + "=" * 60) + print("🎀 AGENT V2 VOICE ASSISTANT READY") + print(f"Agent: {self.agent_config.get('agent_name')}") + print(f"Project: {self.agent_config.get('project_name')}") + print("Start speaking to begin conversation") + print("Press Ctrl+C to exit") + print("=" * 60 + "\n") + + # Process events + await self._process_events() + except Exception: + logger.exception("Voice assistant encountered an error") + raise + finally: + if self.audio_processor: + self.audio_processor.shutdown() + + async def _setup_session(self): + """Configure the VoiceLive session for audio conversation.""" + logger.info("Setting up voice conversation session...") + + voice_config = AzureStandardVoice(name="en-US-Ava:DragonHDLatestNeural") + + # Create turn detection configuration + turn_detection_config = ServerVad( + threshold=0.5, + prefix_padding_ms=300, + silence_duration_ms=500, + ) + + interim_response_config = LlmInterimResponseConfig(latency_threshold_ms=500) + + # Create session configuration + session_config = RequestSession( + modalities=[Modality.TEXT, Modality.AUDIO], + voice=voice_config, + input_audio_format=InputAudioFormat.PCM16, + output_audio_format=OutputAudioFormat.PCM16, + turn_detection=turn_detection_config, + input_audio_echo_cancellation=AudioEchoCancellation(), + input_audio_noise_reduction=AudioNoiseReduction(type="azure_deep_noise_suppression"), + interim_response=interim_response_config, + ) + + conn = self.connection + assert conn is not None, "Connection must be established before setting up session" + await conn.session.update(session=session_config) + + logger.info("Session configuration sent") + + async def _process_events(self): + """Process events from the VoiceLive connection.""" + try: + conn = self.connection + assert conn is not None, "Connection must be established before processing events" + async for event in conn: + await self._handle_event(event) + except Exception: + logger.exception("Error processing events") + raise + + async def _handle_event(self, event): + """Handle different types of events from VoiceLive.""" + logger.debug("Received event: %s", event.type) + ap = self.audio_processor + assert ap is not None, "AudioProcessor must be initialized" + + if event.type == ServerEventType.SESSION_UPDATED: + logger.info("Session ready: %s", event.session.id) + self.session_ready = True + + # Start audio capture once session is ready + ap.start_capture() + + elif event.type == ServerEventType.CONVERSATION_ITEM_INPUT_AUDIO_TRANSCRIPTION_COMPLETED: + print(f'πŸ‘€ You said: {event.get("transcript", "")}') + + elif event.type == ServerEventType.RESPONSE_TEXT_DONE: + #print(f'πŸ€– Agent responded with text: {event.get("text", "")}') + pass + + elif event.type == ServerEventType.RESPONSE_AUDIO_TRANSCRIPT_DONE: + print(f'πŸ€– Agent responded with audio transcript: {event.get("transcript", "")}') + + elif event.type == ServerEventType.INPUT_AUDIO_BUFFER_SPEECH_STARTED: + logger.info("User started speaking - stopping playback") + print("🎀 Listening...") + + # Skip queued audio + ap.skip_pending_audio() + + elif event.type == ServerEventType.INPUT_AUDIO_BUFFER_SPEECH_STOPPED: + logger.info("🎀 User stopped speaking") + print("πŸ€” Processing...") + + elif event.type == ServerEventType.RESPONSE_CREATED: + logger.info("πŸ€– Assistant response created") + + elif event.type == ServerEventType.RESPONSE_AUDIO_DELTA: + # Stream audio response to speakers + logger.debug("Received audio delta") + ap.queue_audio(event.delta) + + elif event.type == ServerEventType.RESPONSE_AUDIO_DONE: + logger.info("\nπŸ€– Assistant finished speaking") + + elif event.type == ServerEventType.RESPONSE_DONE: + logger.info("\nβœ… Response complete") + print("🎀 Ready for next input...") + + elif event.type == ServerEventType.ERROR: + logger.error("❌ VoiceLive error: %s", event.error.message) + print(f"Service returns error: {event.error}") + + elif event.type == ServerEventType.WARNING: + logger.warning("⚠️ VoiceLive warning: %s", event.warning.message) + print(f"Service returns warning: {event.warning}") + + elif event.type == ServerEventType.CONVERSATION_ITEM_CREATED: + logger.debug("Conversation item created: %s", event.item.id) + + elif event.type == ServerEventType.RESPONSE_AUDIO_TRANSCRIPT_DELTA: + logger.debug("Received audio transcript delta: %s", event.delta) + print(f'{event.delta}', end="", flush=True) + + else: + logger.debug("Unhandled event type: %s", event.type) + + +async def run_assistant(endpoint: str, agent_name: str, agent_project_name: str): + """Run the voice assistant.""" + agent_config: AgentSessionConfig = { + "agent_name": agent_name, + "project_name": agent_project_name, + } + + credential: AsyncTokenCredential = DefaultAzureCredential() + logger.info("Using DefaultAzureCredential") + + assistant = AgentV2VoiceAssistant( + endpoint=endpoint, + credential=credential, + agent_config=agent_config, + ) + + await assistant.start() + + +def main(args: argparse.Namespace): + """Main function.""" + + # Setup signal handlers for graceful shutdown + def signal_handler(_sig, _frame): + logger.info("Received shutdown signal") + raise KeyboardInterrupt() + + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + # Start the assistant + try: + asyncio.run(run_assistant(args.endpoint, args.agent_name, args.project_name)) + except KeyboardInterrupt: + print("\nπŸ‘‹ Voice assistant shut down. Goodbye!") + except Exception as e: + print("Fatal Error: ", e) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Agent V2 Voice Assistant with Azure VoiceLive SDK") + parser.add_argument("--endpoint", required=True, help="The Azure VoiceLive endpoint") + parser.add_argument("--agent-name", required=True, help="The name of your Azure AI Foundry agent") + parser.add_argument("--project-name", required=True, help="The Foundry project containing the agent") + args = parser.parse_args() + + # Check audio system + try: + p = pyaudio.PyAudio() + input_devices = [ + i + for i in range(p.get_device_count()) + if cast(Union[int, float], p.get_device_info_by_index(i).get("maxInputChannels", 0) or 0) > 0 + ] + output_devices = [ + i + for i in range(p.get_device_count()) + if cast(Union[int, float], p.get_device_info_by_index(i).get("maxOutputChannels", 0) or 0) > 0 + ] + p.terminate() + + if not input_devices: + print("❌ No audio input devices found. Please check your microphone.") + sys.exit(1) + if not output_devices: + print("❌ No audio output devices found. Please check your speakers.") + sys.exit(1) + + except Exception as e: + print(f"❌ Audio system check failed: {e}") + sys.exit(1) + + print("πŸŽ™οΈ Agent V2 Voice Assistant with Azure VoiceLive SDK") + print("=" * 50) + print(f"Agent: {args.agent_name}") + print(f"Project: {args.project_name}") + print("Using AgentSessionConfig for agent configuration") + print("=" * 50) + + main(args) diff --git a/samples/python/hosted-agents/bring-your-own/voicelive/hello-world-invocations-voicelive/.dockerignore b/samples/python/hosted-agents/bring-your-own/voicelive/hello-world-invocations-voicelive/.dockerignore new file mode 100644 index 000000000..b709ec79b --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/voicelive/hello-world-invocations-voicelive/.dockerignore @@ -0,0 +1,26 @@ +**/__pycache__/ +**/*.py[cod] +**/*.egg-info/ +.eggs/ + +# Virtual environments +.venv/ +venv/ +env/ + +# IDE settings +.vscode/ +.idea/ + +# Version control +.git/ +.gitignore + +# Docker files +.dockerignore + +# Docs +README.md + +# Local environment (never bake credentials into the image) +.env diff --git a/samples/python/toolbox/langgraph/.env.example b/samples/python/hosted-agents/bring-your-own/voicelive/hello-world-invocations-voicelive/.env.example similarity index 72% rename from samples/python/toolbox/langgraph/.env.example rename to samples/python/hosted-agents/bring-your-own/voicelive/hello-world-invocations-voicelive/.env.example index 8037a877c..86eb2456e 100644 --- a/samples/python/toolbox/langgraph/.env.example +++ b/samples/python/hosted-agents/bring-your-own/voicelive/hello-world-invocations-voicelive/.env.example @@ -1,13 +1,10 @@ # Foundry project endpoint β€” auto-injected in hosted containers. # Only set manually if running without `azd ai agent run`. -FOUNDRY_PROJECT_ENDPOINT= +# FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ # Model deployment name β€” must match a deployment in your Foundry project. -MODEL_DEPLOYMENT_NAME= +AZURE_AI_MODEL_DEPLOYMENT_NAME= # Application Insights β€” auto-injected in hosted containers. # Set for local telemetry (optional but recommended). # APPLICATIONINSIGHTS_CONNECTION_STRING=InstrumentationKey=... - -# Toolbox MCP endpoint β€” full URL including toolbox name and api-version. -TOOLBOX_ENDPOINT= diff --git a/samples/python/toolbox/maf/Dockerfile b/samples/python/hosted-agents/bring-your-own/voicelive/hello-world-invocations-voicelive/Dockerfile similarity index 64% rename from samples/python/toolbox/maf/Dockerfile rename to samples/python/hosted-agents/bring-your-own/voicelive/hello-world-invocations-voicelive/Dockerfile index cd962d026..b89292edb 100644 --- a/samples/python/toolbox/maf/Dockerfile +++ b/samples/python/hosted-agents/bring-your-own/voicelive/hello-world-invocations-voicelive/Dockerfile @@ -1,7 +1,7 @@ -FROM mcr.microsoft.com/azurelinux/base/python:3.12 +FROM python:3.12-slim WORKDIR /app COPY . user_agent/ WORKDIR /app/user_agent RUN if [ -f requirements.txt ]; then pip install -r requirements.txt; fi EXPOSE 8088 -CMD ["python3", "main.py"] +CMD ["python", "main.py"] diff --git a/samples/python/hosted-agents/bring-your-own/voicelive/hello-world-invocations-voicelive/README.md b/samples/python/hosted-agents/bring-your-own/voicelive/hello-world-invocations-voicelive/README.md new file mode 100644 index 000000000..46e61aab2 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/voicelive/hello-world-invocations-voicelive/README.md @@ -0,0 +1,227 @@ + +**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 note for [Agent Service](https://learn.microsoft.com/en-us/azure/ai-foundry/responsible-ai/agents/transparency-note). + +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 + +A minimal "hello world" hosted agent using the **Bring Your Own** approach with the **Invocations protocol** in Python, designed for **real-time voice interactions** with the [Azure VoiceLive SDK](https://pypi.org/project/azure-ai-voicelive/). It shows how to use the [`azure-ai-agentserver-invocations`](https://pypi.org/project/azure-ai-agentserver-invocations/) SDK to host a custom agent that calls a Foundry model via the Responses API and streams `output_audio_transcription` events compatible with VoiceLive. + +This is the simplest possible BYO integration β€” the protocol SDK handles the HTTP endpoints, session resolution, client header forwarding, and OpenTelemetry tracing. You supply the model call using the [Foundry SDK (`azure-ai-projects`)](https://pypi.org/project/azure-ai-projects/). + +Once the agent is deployed, you can interact with the agent using voice through the included [`voicelive_client.py`](voicelive_client.py) client. + +## How It Works + +### Model Integration + +The agent uses the Foundry SDK to create a Responses client from the project endpoint and model deployment name. When a request arrives, the handler looks up the session history by `session_id`, appends the new user message, calls the model via the Responses API with streaming, and returns a `StreamingResponse` of SSE events β€” `output_audio_transcription.delta` events during generation, an `output_audio_transcription.done` event with the full text, then a final `done` event. + +See [main.py](main.py) for the full implementation. + +### Agent Hosting + +The agent is hosted using the [Azure AI AgentServer Invocations SDK](https://pypi.org/project/azure-ai-agentserver-invocations/), which provisions a REST API endpoint compatible with the Azure AI Invocations protocol. + +### Agent Deployment + +The hosted agent can be developed and deployed to Microsoft Foundry using the [Azure Developer CLI](https://learn.microsoft.com/en-us/azure/foundry/agents/quickstarts/quickstart-hosted-agent?view=foundry&pivots=azd). + +## Running the Agent Locally + +### Prerequisites + +Before running this sample, ensure you have: + +1. **Azure Developer CLI (`azd`)** (recommended) + - [Install azd](https://learn.microsoft.com/en-us/azure/developer/azure-developer-cli/install-azd) and the AI agent extension: `azd ext install azure.ai.agents` + - Authenticated: `azd auth login` + +2. **Azure CLI** + - Installed and authenticated: `az login` + +3. **Python 3.10 or later** + - Verify your version: `python --version` + +> [!NOTE] +> You do **not** need an existing [Microsoft Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/what-is-foundry?view=foundry) project or model deployment to get started β€” `azd provision` creates them for you. If you already have a project, see the [note below](#using-azd-recommended-for-cli-workflows) on how to target it. + +### Environment Variables + +See [`.env.example`](.env.example) for the full list of environment variables this sample uses. + +| Variable | Required | Description | +|----------|----------|-------------| +| `FOUNDRY_PROJECT_ENDPOINT` | Yes | Foundry project endpoint. Auto-injected in hosted containers; set automatically by `azd ai agent run` locally. | +| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | Yes | Model deployment name β€” must match your Foundry project deployment. Declared in `agent.manifest.yaml`. | +| `APPLICATIONINSIGHTS_CONNECTION_STRING` | Recommended | Enables telemetry. Auto-injected in hosted containers; set manually for local dev. | + +**Local development (without `azd`):** + +```bash +cp .env.example .env +# Edit .env and fill in your values, then: +export $(grep -v '^#' .env | xargs) +``` + +> [!NOTE] +> When using `azd ai agent run`, environment variables are handled automatically β€” no manual setup needed. + +### Installing Dependencies + +> [!NOTE] +> If using `azd ai agent run`, dependencies are installed automatically β€” skip to [Running the Sample](#running-the-sample). + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +``` + +### Running the Sample + +The recommended way to run and test hosted agents locally is with the Azure Developer CLI (`azd`) or the Foundry VS Code extension. + +#### Using the Foundry VS Code Extension + +The [Foundry VS Code extension](https://learn.microsoft.com/en-us/azure/foundry/agents/quickstarts/quickstart-hosted-agent?view=foundry&pivots=vscode) has a built-in sample gallery. You can open this sample directly from the extension without cloning the repository β€” it scaffolds the project into a new workspace, generates `agent.yaml`, `.env`, and `.vscode/tasks.json` + `launch.json` automatically, and configures a one-click **F5** debug experience. + +Follow the [VS Code quickstart](https://learn.microsoft.com/en-us/azure/foundry/agents/quickstarts/quickstart-hosted-agent?view=foundry&pivots=vscode) for a full step-by-step walkthrough. + +#### Using [`azd`](https://learn.microsoft.com/en-us/azure/foundry/agents/quickstarts/quickstart-hosted-agent?view=foundry&pivots=azd) (recommended for CLI workflows) + +No cloning required. Create a new folder, point `azd` at the manifest on GitHub, and it sets up the sample and generates Bicep infrastructure, `agent.yaml`, and env config automatically: + +```bash +# Create a new folder for the agent and navigate into it +mkdir hello-world-agent && cd hello-world-agent + +# Initialize from the manifest β€” azd reads it, downloads the sample, +# and generates Bicep infrastructure, agent.yaml, and env config +azd ai agent init -m https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/bring-your-own/voicelive/hello-world-invocations-voicelive/agent.manifest.yaml + +# Provision Azure resources (Foundry project, model deployment, App Insights) +azd provision + +# Run the agent locally (handles env vars, dependency install, and startup) +azd ai agent run +``` + +> [!NOTE] +> If you've already cloned this repository, pass a local path to the manifest instead: +> `azd ai agent init -m /samples/python/hosted-agents/bring-your-own/voicelive/hello-world-invocations-voicelive/agent.manifest.yaml` + +> [!NOTE] +> If you already have a Foundry project and model deployment, add `-p -d ` to `azd ai agent init` to target existing resources. You can also skip provisioning entirely and configure env vars manually β€” see [Without `azd`](#without-azd). + +The agent starts on `http://localhost:8088/`. To invoke it: + +```bash +azd ai agent invoke --local "What is Microsoft Foundry?" +``` + +Or use curl directly. The `-N` flag disables output buffering so you see SSE tokens as they arrive: + +> [!NOTE] +> `agent_session_id` is optional. If omitted, the server auto-generates one and returns it in the `done` event (`session_id` field). To continue a conversation across turns, pass the same `agent_session_id` in each request. + +```bash +# Turn 1 β€” start a new conversation +curl -sS -N -X POST "http://localhost:8088/invocations?agent_session_id=chat-001" \ + -H "Content-Type: application/json" \ + -d '{"message": "What is Microsoft Foundry?"}' + +# Turn 2 β€” continue the same conversation +curl -sS -N -X POST "http://localhost:8088/invocations?agent_session_id=chat-001" \ + -H "Content-Type: application/json" \ + -d '{"message": "What hosted agent options does it offer?"}' +``` + +Each response is a stream of SSE events: `output_audio_transcription.delta` events with incremental text, an `output_audio_transcription.done` event with the complete reply, followed by a `done` event. + +#### Without `azd` + +If running without `azd`, set environment variables manually (see [Environment Variables](#environment-variables)), then: + +```bash +python main.py +``` + +### Deploying the Agent to Microsoft Foundry + +Once you've tested locally, deploy to Microsoft Foundry: + +```bash +# Provision Azure resources (skip if already done during local setup) +azd provision + +# Build, push, and deploy the agent to Foundry +azd deploy +``` + +After deploying, invoke the agent running in Foundry: + +```bash +azd ai agent invoke "What is Microsoft Foundry?" +``` + +To stream logs from the running agent: + +```bash +azd ai agent monitor +``` + +For the full deployment guide, see [Azure AI Foundry hosted agents](https://aka.ms/azdaiagent/docs). + +## Troubleshooting + +### Images built on Apple Silicon or other ARM64 machines do not work on our service + +We **recommend deploying with `azd deploy`**, which uses ACR remote build and always produces images with the correct architecture. + +If you choose to **build locally**, and your machine is **not `linux/amd64`** (for example, an Apple Silicon Mac), the image will **not be compatible with our service**, causing runtime failures. + +**Fix for local builds:** + +```bash +docker build --platform=linux/amd64 -t image . +``` + +This forces the image to be built for the required `amd64` architecture. + + +## Using the Agent with Voice Live + +Once the agent is deployed to Microsoft Foundry, you can interact with it using **real-time voice** through the [Azure VoiceLive SDK](https://pypi.org/project/azure-ai-voicelive/). The included [`voicelive_client.py`](voicelive_client.py) connects to a VoiceLive endpoint, streams microphone audio to the agent, and plays back the agent's spoken response. + +### Prerequisites + +- The agent must already be deployed to Microsoft Foundry (see [Deploying the Agent](#deploying-the-agent-to-microsoft-foundry)). +- An **Azure VoiceLive endpoint** β€” provision one through the Azure portal or your Foundry project. +- A working **microphone and speakers** on your machine. +- **Python 3.10+** with the following packages: + +```bash +pip install azure-ai-voicelive[aiohttp]==1.2.0b5 azure-identity pyaudio +``` + +> [!NOTE] +> On Linux, install PortAudio first: `sudo apt-get install -y portaudio19-dev libasound2-dev` + +### Running the Voice Live Client + +```bash +python voicelive_client.py \ + --endpoint "https://.services.ai.azure.com" \ + --agent-name "" \ + --project-name "" +``` + +The client authenticates using `DefaultAzureCredential` β€” make sure you are logged in (`az login`). + +Once connected, speak into your microphone. The agent will respond with synthesized speech. Press **Ctrl+C** to end the session. diff --git a/samples/python/hosted-agents/bring-your-own/voicelive/hello-world-invocations-voicelive/agent.manifest.yaml b/samples/python/hosted-agents/bring-your-own/voicelive/hello-world-invocations-voicelive/agent.manifest.yaml new file mode 100644 index 000000000..df51c7710 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/voicelive/hello-world-invocations-voicelive/agent.manifest.yaml @@ -0,0 +1,32 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: hello-world-python-invocations-voicelive +displayName: "Hello World (Python, Invocations, VoiceLive)" +description: > + Minimal Hello World agent using the Invocations protocol with a bring-your-own + approach, designed for VoiceLive compatibility. It can work with Voice Live SDK + to do real-time voice interactions. +metadata: + voiceLiveCompatible: "true" + tags: + - AI Agent Hosting + - Invocations Protocol + - Bring Your Own + - Python +template: + name: hello-world-python-invocations-voicelive + kind: hosted + protocols: + - protocol: invocations + version: 1.0.0 + environment_variables: + # FOUNDRY_PROJECT_ENDPOINT and APPLICATIONINSIGHTS_CONNECTION_STRING + # are injected by the platform (hosted) and translated by azd (local) + # β€” do NOT declare them here. + # + # Model deployment name β€” resolved from the resources section below. + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" +resources: + - kind: model + id: gpt-4.1-mini + name: AZURE_AI_MODEL_DEPLOYMENT_NAME diff --git a/samples/python/hosted-agents/bring-your-own/voicelive/hello-world-invocations-voicelive/agent.yaml b/samples/python/hosted-agents/bring-your-own/voicelive/hello-world-invocations-voicelive/agent.yaml new file mode 100644 index 000000000..146558d58 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/voicelive/hello-world-invocations-voicelive/agent.yaml @@ -0,0 +1,14 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +kind: hosted +name: hello-world-python-invocations-voicelive +metadata: + voiceLiveCompatible: "true" +protocols: + - protocol: invocations + version: 1.0.0 +resources: + cpu: "0.25" + memory: 1.5Gi +environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} diff --git a/samples/python/hosted-agents/bring-your-own/voicelive/hello-world-invocations-voicelive/main.py b/samples/python/hosted-agents/bring-your-own/voicelive/hello-world-invocations-voicelive/main.py new file mode 100644 index 000000000..4f6c64fb2 --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/voicelive/hello-world-invocations-voicelive/main.py @@ -0,0 +1,215 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Hello World β€” Bring Your Own Invocations agent. + +Minimal hosted agent that forwards user input to a Foundry model via the +Responses API and returns the reply through the Invocations protocol. + +This sample demonstrates the simplest possible BYO integration: the protocol +SDK (``azure-ai-agentserver-invocations``) handles the HTTP contract and +session resolution, and you supply the model call using the Foundry SDK. + +Unlike the Responses protocol, the Invocations protocol does **not** provide +built-in server-side conversation history. This agent maintains an in-memory +history store. In production, replace it with durable storage (Redis, Cosmos DB, etc.) +so history survives restarts. + +Required environment variables: + FOUNDRY_PROJECT_ENDPOINT: Foundry project endpoint (auto-injected in hosted containers) + AZURE_AI_MODEL_DEPLOYMENT_NAME: Model deployment name (declared in agent.manifest.yaml) + +Usage:: + + # Set environment variables + export FOUNDRY_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" + export AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4.1-mini" + + # Start the agent + python main.py + + # Turn 1 β€” start a new conversation + curl -sS -N -X POST "http://localhost:8088/invocations?agent_session_id=chat-001" \\ + -H "Content-Type: application/json" \\ + -d '{"message": "What is Microsoft Foundry?"}' + + # Turn 2 β€” continue the same conversation + curl -sS -N -X POST "http://localhost:8088/invocations?agent_session_id=chat-001" \\ + -H "Content-Type: application/json" \\ + -d '{"message": "What hosted agent options does it offer?"}' +""" + +import asyncio +import json +import logging +import os + +from starlette.requests import Request +from starlette.responses import JSONResponse, StreamingResponse + +from azure.ai.projects import AIProjectClient +from azure.identity import DefaultAzureCredential + +from azure.ai.agentserver.invocations import InvocationAgentServerHost + +logger = logging.getLogger(__name__) + +if not os.environ.get("APPLICATIONINSIGHTS_CONNECTION_STRING"): + logger.warning( + "APPLICATIONINSIGHTS_CONNECTION_STRING not set β€” traces will not be sent to " + "Application Insights. Set it to enable local telemetry. " + "(This variable is auto-injected in hosted Foundry containers β€” do not declare it in agent.manifest.yaml.)" + ) + +# Initialize Foundry project client β€” reads FOUNDRY_PROJECT_ENDPOINT. +# FOUNDRY_PROJECT_ENDPOINT is auto-injected in hosted Foundry containers. +# Locally, set it manually or use 'azd ai agent run' which sets it automatically. +_endpoint = os.environ.get("FOUNDRY_PROJECT_ENDPOINT") +if not _endpoint: + raise EnvironmentError( + "FOUNDRY_PROJECT_ENDPOINT environment variable is not set. " + "Set it to your Foundry project endpoint, or use 'azd ai agent run' " + "which sets it automatically." + ) + +_model = os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME") +if not _model: + raise EnvironmentError( + "AZURE_AI_MODEL_DEPLOYMENT_NAME environment variable is not set. " + "Set it to your model deployment name as declared in agent.manifest.yaml." + ) + +_credential = DefaultAzureCredential() +_project_client = AIProjectClient(endpoint=_endpoint, credential=_credential) + +# Use the Responses API β€” not chat.completions (Chat Completions API is legacy). +_responses_client = _project_client.get_openai_client().responses + +_SYSTEM_PROMPT = "You are a helpful AI assistant. Be concise and informative." + +app = InvocationAgentServerHost() + +# In-memory history store. +# WARNING: state is lost on restart. Use durable storage in production. +history = [] + + +async def _stream_reply(input_items: list[dict[str, str]]): + """Call the Foundry model and yield text deltas as they arrive. + + The Responses SDK uses a synchronous streaming iterator. We bridge it to + async by running it in a thread pool and forwarding each delta through an + ``asyncio.Queue`` so the event loop is never blocked. + """ + loop = asyncio.get_running_loop() + queue: asyncio.Queue[str | None] = asyncio.Queue() + + def _produce() -> None: + """Runs in a thread: streams from the model and enqueues each delta.""" + try: + for event in _responses_client.create( + model=_model, + instructions=_SYSTEM_PROMPT, + input=input_items, + store=False, # This agent owns history β€” no need to store at the model level + stream=True, + ): + if event.type == "response.output_text.delta": + loop.call_soon_threadsafe(queue.put_nowait, event.delta) + finally: + # None signals end of stream + loop.call_soon_threadsafe(queue.put_nowait, None) + + # Start sync streaming in a background thread; yield deltas as they arrive. + fut = loop.run_in_executor(None, _produce) + while (delta := await queue.get()) is not None: + yield delta + await fut # re-raise any exception that escaped the thread + + +# ── Required handler ────────────────────────────────────────────────────────── +# @app.invoke_handler is the only handler you must implement. It receives every +# POST /invocations request. The function name below is arbitrary. +# +# Two optional handlers exist for long-running operations (LRO): +# @app.get_invocation_handler β€” handle GET /invocations/{id} status polls +# @app.cancel_invocation_handler β€” handle DELETE /invocations/{id} cancellation +# For a simple streaming agent like this one, neither is needed. +# +# To serve an OpenAPI spec at GET /invocations/docs/openapi.json, pass it to +# the host constructor: InvocationAgentServerHost(openapi_spec={...}) +# ───────────────────────────────────────────────────────────────────────────── +@app.invoke_handler +async def handle_invoke(request: Request): + """Handle a streaming multi-turn chat request.""" + # Accept either a JSON object ({"message": "..."} or {"input": "..."}) or a + # plain-text body (e.g. sent directly from the Foundry portal chat UI). + try: + body = await request.body() + if not body: + raise ValueError("empty body") + try: + data = json.loads(body) + except json.JSONDecodeError: + user_message = body.decode("utf-8", errors="replace").strip() + else: + if isinstance(data, dict): + user_message = data.get("message") or data.get("input") or "" + else: + user_message = body.decode("utf-8", errors="replace").strip() + if not isinstance(user_message, str) or not user_message.strip(): + raise ValueError("missing message text") + except ValueError: + return JSONResponse( + status_code=400, + content={ + "error": "invalid_request", + "message": ( + 'Request body must be a non-empty JSON object with a "message" (or "input") ' + 'string, or a plain-text body, e.g. {"message": "What is Microsoft Foundry?"}' + ), + }, + ) + + # The Invocations SDK resolves session and invocation identity from the + # incoming request headers and exposes them via request.state. + session_id = request.state.session_id + invocation_id = request.state.invocation_id + + logger.info( + "Processing invocation %s (session %s)", invocation_id, session_id + ) + + history.append({"role": "user", "content": user_message}) + + # Build the Responses API input list from history. + # History is stored as {role, content} dicts β€” the same format the API accepts. + input_items = list(history) + + async def event_generator(): + full_reply = "" + try: + async for delta in _stream_reply(input_items): + full_reply += delta + yield f"data: {json.dumps({'type': 'output_audio_transcription.delta', 'delta': delta})}\n\n" + except Exception as exc: + msg = f"Error calling model: {exc}" + logger.error(msg) + full_reply = msg + yield f"data: {json.dumps({'type': 'output_audio_transcription.delta', 'delta': msg})}\n\n" + + yield f"data: {json.dumps({'type': 'output_audio_transcription.done', 'text': full_reply})}\n\n" + + yield f"data: {json.dumps({'type': 'done'})}\n\n" + + # Persist the assistant reply to history after streaming is complete. + if full_reply: + history.append({"role": "assistant", "content": full_reply}) + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + +app.run() diff --git a/samples/python/hosted-agents/bring-your-own/voicelive/hello-world-invocations-voicelive/requirements.txt b/samples/python/hosted-agents/bring-your-own/voicelive/hello-world-invocations-voicelive/requirements.txt new file mode 100644 index 000000000..3943fb2fa --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/voicelive/hello-world-invocations-voicelive/requirements.txt @@ -0,0 +1,3 @@ +azure-ai-agentserver-invocations==1.0.0b3 +azure-ai-projects==2.0.1 +azure-identity==1.25.3 diff --git a/samples/python/hosted-agents/bring-your-own/voicelive/hello-world-invocations-voicelive/test-payload.txt b/samples/python/hosted-agents/bring-your-own/voicelive/hello-world-invocations-voicelive/test-payload.txt new file mode 100644 index 000000000..0ab43ddfd --- /dev/null +++ b/samples/python/hosted-agents/bring-your-own/voicelive/hello-world-invocations-voicelive/test-payload.txt @@ -0,0 +1 @@ +{"type":"input_audio.transcription", "input":"What is Microsoft Foundry?"} diff --git a/samples/python/quickstart/responses/quickstart-responses.py b/samples/python/quickstart/responses/quickstart-responses.py index 16148ac51..7b5f6f745 100644 --- a/samples/python/quickstart/responses/quickstart-responses.py +++ b/samples/python/quickstart/responses/quickstart-responses.py @@ -13,7 +13,7 @@ # Run a responses API call response = openai.responses.create( - model="gpt-5-mini", # supports all Foundry direct models + model="gpt-5-mini", input="What is the size of France in square miles?", ) -print(f"Response output: {response.output_text}") \ No newline at end of file +print(f"Response output: {response.output_text}") diff --git a/samples/python/toolbox/README.md b/samples/python/toolbox/README.md deleted file mode 100644 index 7d1154208..000000000 --- a/samples/python/toolbox/README.md +++ /dev/null @@ -1,331 +0,0 @@ - -**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. - - -# Python Toolbox Samples - -Python samples for running Microsoft Foundry agents connected to a **toolbox in Foundry** via the -MCP Streamable HTTP protocol. Four framework options are provided β€” pick the one that -matches your existing stack. - -## Why Toolboxes? - -Building an AI agent is only half the story. The real magic happens when your agent can **do things** β€” search the web, read emails, query databases, call APIs. But wiring up each tool individually is tedious, fragile, and hard to manage across agents. - -**A toolbox is a reusable bundle of tools, managed in Foundry, that agents consume through a single, consistent interface.** - -| Without Toolbox | With Toolbox | -|---|---| -| Each agent manages its own tool connections | Tools are shared across agents from a central place | -| Auth tokens, retries, and schemas are your problem | Platform handles auth, versioning, and schema validation | -| Adding a tool means redeploying your agent | Add tools to a toolbox β€” agents discover them automatically | -| No standard protocol β€” every integration is custom | Industry-standard **MCP protocol** for all tools | - -### What You Can Put in a Toolbox - -| Tool Type | What It Does | Example | -|-----------|-------------|---------| -| **MCP Tool** | Connect to any MCP-compatible server | GitHub Copilot, custom APIs | -| **Web Search** | Search the internet for fresh information | Bing-powered web search | -| **File Search** | Search your uploaded documents (RAG) | Vector store search | -| **Azure AI Search** | Query Azure AI Search indexes | Enterprise knowledge bases | -| **OpenAPI Tool** | Call any REST API with an OpenAPI spec | Internal microservices | -| **Code Interpreter** | Run Python in a sandboxed environment | Data analysis, calculations | - -## Which sample should I use? - -| I want to… | Use | -|-------------|-----| -| Get started quickly with full `azd` deployment (infra + deploy) and GitHub toolbox | [`azd/`](./azd/) | -| Write a LangGraph agent with maximum flexibility | [`langgraph/`](./langgraph/) | -| Use the Microsoft Agent Framework SDK without LangChain/LangGraph | [`maf/`](./maf/) | -| Use GitHub Copilot SDK combined with local skills and toolbox tools | [`copilot-sdk/`](./copilot-sdk/) | - -## Sample Comparison - -| Capability | `azd/` | `langgraph/` | `maf/` | `copilot-sdk/` | -|-----------|:---:|:---:|:---:|:---:| -| Multi-turn conversation | βœ… | βœ… | βœ… | βœ… | -| Streaming (SSE) | βœ… | βœ… | βœ… | βœ… | -| OAuth consent handling | βœ… | βœ… | βœ… | βœ… | -| Tool schema sanitization | βœ… | βœ… | βœ… | βœ… | -| Tracing | βœ… | βœ… | βœ… | βœ… | -| SDK | LangGraph | LangGraph | Microsoft Agent Framework | GitHub Copilot SDK | - -All samples: -- Serve the **Responses Protocol** on port `8088` -- Authenticate to the toolbox endpoint using `DefaultAzureCredential` (bearer token, auto-refreshed) -- Read `FOUNDRY_PROJECT_ENDPOINT`, `FOUNDRY_AGENT_TOOLBOX_ENDPOINT`, and `AZURE_AI_MODEL_DEPLOYMENT_NAME` from the environment -- Send the `Foundry-Features: Toolboxes=V1Preview` header on every MCP request (required β€” requests without it are rejected) -- Support local dev via a `.env` file (copy `.env.example` β†’ `.env` and fill in values) - -## Supported Toolbox Tools - -Canonical tool and auth type definitions are documented in -[SUPPORTED_TOOLBOX_TOOLS.md](./SUPPORTED_TOOLBOX_TOOLS.md). - -For runnable SDK examples of creating every tool type (MCP, OpenAPI, Azure AI Search, -Bing, etc.), see [sample_toolboxes_crud.py](./sample_toolboxes_crud.py). - -## Prerequisites (all samples) - -- Python 3.12+ -- A [Microsoft Foundry](https://ai.azure.com) project -- A toolbox already created in that project β€” see [`sample_toolboxes_crud.py`](./sample_toolboxes_crud.py) to create one - (**The `azd/` sample creates the toolbox automatically during `azd deploy` β€” no pre-created toolbox needed**) -- Azure CLI installed and logged in: - - ```bash - az login - ``` - -## Getting Your `FOUNDRY_PROJECT_ENDPOINT` - -1. Go to [ai.azure.com](https://ai.azure.com) and open your project. -2. Navigate to **Settings** β†’ **Project details**. -3. Copy the **Project endpoint** β€” it looks like: - - ``` - https://.services.ai.azure.com/api/projects/ - ``` - -## What is a Toolbox? - -A **Toolbox** is a named collection of tools (MCP, OpenAPI, Azure AI Search, Web Search, -File Search, Code Interpreter, A2A) hosted in your Microsoft Foundry project. Agents -connect to a toolbox via its MCP endpoint and dynamically discover available tools at startup. - -The toolbox MCP endpoint URL supports two forms: - -``` -# Latest version: -https://.services.ai.azure.com/api/projects//toolboxes//mcp?api-version=v1 - -# Pinned to a specific version: -https://.services.ai.azure.com/api/projects//toolboxes//versions//mcp?api-version=v1 -``` - -> **Note:** The `?api-version=v1` query parameter is **required**. Requests without it return HTTP 400. - -Use [`sample_toolboxes_crud.py`](./sample_toolboxes_crud.py) to create a toolbox before running any of the agent samples. - -## Troubleshooting - -### Common Issues - -| Symptom | Cause | Fix | -|---------|-------|-----| -| HTTP 400 on MCP endpoint | Missing `?api-version=v1` in URL | Add `?api-version=v1` to `FOUNDRY_AGENT_TOOLBOX_ENDPOINT` | -| HTTP 401 on agent invoke | Agent's managed identity lacks RBAC | Assign "Cognitive Services OpenAI User" role to the agent's `instance_identity.principal_id` | -| "Multiple tools without identifiers" | More than one unnamed tool in a toolbox | Use `MCPTool` with `server_label` for named tools; only one unnamed tool (WebSearch, FileSearch, etc.) per toolbox | -| Agent returns empty response | RBAC propagation delay | Wait 2–5 minutes after role assignment, then retry | -| `session_not_ready` error | Container startup failure | Check `azd ai agent monitor --session-id ` for crash logs | -| Tool schemas rejected by OpenAI | MCP server returns malformed schemas | Sanitize schemas β€” add empty `properties` to `object` types missing them | - -### Validating a Toolbox Endpoint - -After creating a toolbox, confirm that the MCP endpoint works: - -1. Call `tools/list` β€” should return the tool list without errors. -2. Call `tools/call` on a specific tool β€” confirms end-to-end MCP protocol behavior. - -The full MCP endpoint URL has the form: - -``` -https://.services.ai.azure.com/api/projects//toolboxes//versions//mcp?api-version=v1 -``` - -> **Note:** The `?api-version=v1` query parameter is required. Omitting it returns HTTP 400. - -## Troubleshooting Multi-Tool Toolbox Creation - -When creating a toolbox with multiple tools, Foundry validates tool identity. - -### Symptom - -You may see this error when combining multiple tools that do not expose a unique identifier field: - -`(invalid_payload) Multiple tools without identifiers found. All tools except a single tool must have unique identifiers ('name' or 'server_label').` - -### Why This Happens - -- Some tool types do not accept `name` or `server_label` in toolbox definitions (for example `file_search`, `web_search`, `azure_ai_search`, `code_interpreter`). -- Foundry allows only one such unnamed tool in a single toolbox payload. - -### Fix Pattern - -- Keep at most one unnamed tool per toolbox. -- If you need multiple tools in one toolbox, add tools that provide identifiers, such as `MCPTool` with a unique `server_label`. - -The combinations in `sample_toolboxes_crud.py` use this pattern: - -- `multi-filesearch-codeinterp`: `FileSearchTool` + `MCPTool(server_label=...)` -- `multi-websearch-codeinterp`: `WebSearchTool` + `MCPTool(server_label=...)` -- `multi-aisearch-codeinterp`: `AzureAISearchTool` + `MCPTool(server_label=...)` - -### Quick Validation - -After creating a toolbox sample, validate the MCP endpoint with: - -1. `tools/list` -2. `tools/call` - -This confirms both toolbox provisioning and MCP protocol behavior end-to-end. - -## Source Data Patterns by Tool Type for Citation - -Different toolbox tools return citation/source data in different shapes inside the `tools/call` response. - -### Azure AI Search - -Citation data is in `result.structuredContent.documents[]`: - -| Field | Description | -|-------|-------------| -| `title` | Display label for the citation | -| `url` | Clickable source link | -| `id` | Stable source identifier | -| `score` | Relevance score | -| `knowledgeSourceIndex` | Knowledge source grouping/index | - -- `result.structuredContent.summary` β€” explains retrieval outcome (e.g. number of retrieved docs) -- `result.structuredContent.additionalProperties.num_docs_retrieved` β€” useful for diagnostics -- `result.content[]` β€” tool text output; this is response text, **not** the authoritative citation list - -### File Search - -Chunk metadata is embedded in the `tools/call` response as `γ€”index† filename† file_id〕` markers inside -`result.content[].resource.text`. Full metadata for each matched chunk is in the `_meta` block -of the same resource item: - -| Field | Location | Description | -|-------|----------|-------------| -| `title` | `resource._meta.title` | Source file name | -| `file_id` | `resource._meta.file_id` | Stable identifier for the source file | -| `document_chunk_id` | `resource._meta.document_chunk_id` | Identifier for the specific chunk | -| `score` | `resource._meta.score` | Relevance score for the chunk | - -Example `tools/call` response: - -```json -{ - "jsonrpc": "2.0", - "id": "fs-call-1", - "result": { - "content": [ - { - "type": "resource", - "resource": { - "uri": "file://assistant-tvfqncbtruyffxkfewenyy/", - "_meta": { - "title": "mcp-test-file.txt", - "file_id": "assistant-TVfQnCBtRuyfFxkfeweNYY", - "document_chunk_id": "f7327b7f-5ed0-43c6-9bee-e8e9552afcb5", - "score": 0.03333333507180214 - }, - "text": "# \u30100\u2020mcp-test-file.txt\u2020assistant-TVfQnCBtRuyfFxkfeweNYY\u3011\nContent Snippet:\nAzure OpenAI Service is a cloud service..." - } - } - ] - } -} -``` - -Use the `_meta` fields to build citation links or deep-link back to the source file. - -### Web Search - -The response is a single resource content item with the synthesized answer. URL citations are in -`result.content[].resource._meta.annotations[]`. - -| Field | Location | Description | -|-------|----------|-------------| -| `text` | `resource.text` | Synthesized answer with inline Markdown source links | -| `type` | `_meta.annotations[].type` | Always `"url_citation"` | -| `url` | `_meta.annotations[].url` | Source URL | -| `title` | `_meta.annotations[].title` | Source page title | -| `start_index` / `end_index` | `_meta.annotations[].start_index` / `end_index` | Character offsets into `resource.text` where the citation appears | -| `query` | `_meta.action.query` | The search query the model issued | - -Example `tools/call` response: - -```json -{ - "jsonrpc": "2.0", - "id": "ws-call-1", - "result": { - "_meta": { - "tool_configuration": { - "type": "web_search", - "name": "web-search-default" - } - }, - "content": [ - { - "type": "resource", - "resource": { - "uri": "about:web-search-answer", - "mimeType": "text/plain", - "text": "Here are the latest updates...\n\n- **GPT-image-1 Release** ([serverless-solutions.com](https://...))." - }, - "annotations": { "audience": ["assistant"] }, - "_meta": { - "annotations": [ - { - "type": "url_citation", - "url": "https://www.serverless-solutions.com/blog/...", - "title": "Microsoft Expands Azure AI Foundry with Powerful New OpenAI Models", - "start_index": 741, - "end_index": 879 - } - ], - "action": { - "type": "search", - "query": "Azure OpenAI service updates 2026", - "queries": ["Azure OpenAI service updates 2026"] - }, - "response_id": "resp_001fcebcc300..." - } - } - ], - "isError": false - } -} -``` - -## Key Concepts Reference - -### MCP Protocol - -Toolboxes use **Model Context Protocol (MCP)** β€” an open standard for tool communication: - -- **`tools/list`** β€” Returns all available tools with their names, descriptions, and input schemas -- **`tools/call`** β€” Invokes a specific tool with arguments and returns structured results - -All requests use JSON-RPC 2.0 format over HTTP POST. - -### Authentication - -- **Agent β†’ Toolbox:** Azure AD bearer token (scope: `https://ai.azure.com/.default`) -- **Toolbox β†’ External Services:** Managed by the platform via project connections (API keys, OAuth, managed identity) -- **Required header:** `Foundry-Features: Toolboxes=V1Preview` - -### Toolbox Endpoint Format - -``` -https://.services.ai.azure.com/api/projects//toolboxes//mcp?api-version=v1 -``` - -## Contributing - -This project welcomes contributions and suggestions. - -## Trademarks - -This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft trademarks or logos is subject to and must follow [Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. Any use of third-party trademarks or logos are subject to those third-party's policies. diff --git a/samples/python/toolbox/SUPPORTED_TOOLBOX_TOOLS.md b/samples/python/toolbox/SUPPORTED_TOOLBOX_TOOLS.md deleted file mode 100644 index e6d2ea302..000000000 --- a/samples/python/toolbox/SUPPORTED_TOOLBOX_TOOLS.md +++ /dev/null @@ -1,143 +0,0 @@ -# Supported Toolbox Tools - -Use this file as the single source of truth for toolbox tool support and authentication across Python toolbox samples. - -## Tool Support Matrix - -| Toolbox Tool Type | Supported Auth | -|-------------------|----------------| -| **MCP Tool** | Key-based, OAuth (identity passthrough), Entra ID (agent identity), Entra ID (managed identity) | -| **File Search Tool** | N/A | -| **OpenAPI Tool** | Anonymous, Key-based, Entra ID (managed identity on Foundry project) | -| **Azure AI Search Tool** | Key-based, Entra ID (agent identity), Entra ID (managed identity) | -| **Web Search Tool** | Anonymous, Key-based (domain-restricted via Bing Custom Search) | -| **Code Interpreter Tool** | N/A | -| **A2A Tool** (preview) | Key-based, OAuth (identity passthrough), Entra ID | - -## Detailed Tool Definitions - -### MCP Tool - -Connects to a remote Model Context Protocol server. - -| Parameter | Required | Description | -|-----------|----------|-------------| -| `server_label` | Yes | Unique label for this MCP server within the toolbox | -| `server_url` | Yes | HTTPS URL of the MCP server | -| `project_connection_id` | Yes | Project connection for auth (key, OAuth, Entra) | -| `allowed_tools` | No | List of tool names to expose (filters the full set) | -| `headers` | No | Extra HTTP headers sent with every MCP request | - -**Auth options:** - -| Mode | User context preserved | How to configure | -|------|------------------------|------------------| -| Key-based | No | Set `project_connection_id` to a Custom Keys connection holding the API key or PAT | -| OAuth identity passthrough | Yes | Set `project_connection_id` to an OAuth-type connection. At runtime the agent returns an `oauth_consent_request` with a consent URL | -| Entra ID - agent identity (preview) | No | Assign required roles to the agent identity on the underlying service | -| Entra ID - project managed identity | No | Assign required roles to the project managed identity | - -### File Search Tool - -Searches indexed files/documents via project vector stores. - -| Parameter | Required | Description | -|-----------|----------|-------------| -| `vector_store_ids` | Yes | One or more vector store IDs to search | - -Auth: N/A. - -### OpenAPI Tool - -Calls HTTP APIs described by an OpenAPI 3.0/3.1 specification. - -| Parameter | Required | Description | -|-----------|----------|-------------| -| `openapi.name` | Yes | Logical name for the tool | -| `openapi.spec` | Yes | Inline OpenAPI spec (dict) or a reference | -| `openapi.auth` | Yes | OpenAPI auth details object | - -**Auth options:** - -| Mode | How to configure | -|------|------------------| -| Anonymous | `OpenApiAnonymousAuthDetails()` | -| Key-based | Use project connection-backed OpenAPI auth details | -| Entra ID - managed identity (Foundry project) | Use managed auth details backed by the project managed identity | - -### Azure AI Search Tool - -Grounds responses in Azure AI Search indexes. - -| Parameter | Required | Description | -|-----------|----------|-------------| -| `project_connection_id` | Yes | Resource ID of the project connection to Azure AI Search | -| `index_name` | Yes | Name of the search index (case-sensitive) | -| `top_k` | No | Number of results to return (default: 5) | -| `query_type` | No | `simple`, `vector`, `semantic`, `vector_simple_hybrid`, or `vector_semantic_hybrid` | -| `filter` | No | OData filter applied to every query | - -**Auth options:** - -| Mode | How to configure | -|------|------------------| -| Key-based | Store the API key in the project connection | -| Entra ID - project managed identity | Assign Search Index Data Contributor and Search Service Contributor roles | -| Entra ID - agent identity | Assign the same roles to the agent identity | - -### Web Search Tool - -Enables web grounding (Bing search). Two modes: anonymous (general Bing) or domain-restricted via Bing Custom Search. - -| Parameter | Required | Description | -|-----------|----------|-------------| -| `custom_search_configuration` | No | Restrict to specific domains via Bing Custom Search | - -**Domain-restricted search** (`custom_search_configuration`) requires a `GroundingWithCustomSearch` connection: - -| Parameter | Required | Description | -|-----------|----------|-------------| -| `project_connection_id` | Yes | Connection name (references a `GroundingWithCustomSearch` connection) | -| `instance_name` | Yes | Name of the Bing Custom Search instance (e.g., `agentdoc`) | - -**Connection requirements** for Bing Custom Search: - -| Field | Value | -|-------|-------| -| `category` | `GroundingWithCustomSearch` | -| `authType` | `ApiKey` | -| `target` | `https://api.bing.microsoft.com/` | -| `credentials.key` | Bing API key | -| `metadata.type` | `bing_custom_search_preview` | -| `metadata.ApiType` | `Azure` | -| `metadata.ResourceId` | ARM resource ID of the `Microsoft.Bing/accounts` resource | - -> **Note:** Web Search tools only return results when called through the Responses API (which injects APIM model headers). Direct MCP `tools/call` also works via the MCP gateway when a valid model deployment exists on the project. - -### Code Interpreter Tool - -Runs Python code in a sandboxed environment for analysis, math, and chart generation. - -No required parameters. Auth: N/A. - -### A2A Tool (preview) - -Delegates tasks to another agent via the Agent-to-Agent protocol. The remote agent must expose an A2A endpoint with an agent card at `/.well-known/agent.json`. Tools are auto-discovered from the agent card's skills. - -| Parameter | Required | Description | -|-----------|----------|-------------| -| `name` | Yes | Logical name for the sub-agent tool | -| `project_connection_id` | Yes | Connection name pointing to the remote agent (`RemoteA2A` category) | -| `base_url` | No | Override the base URL from the connection (defaults to connection target) | -| `agent_card_path` | No | Override the agent card path (defaults to `/.well-known/agent.json`) | - -The connection must use `category: RemoteA2A` and `metadata.type: custom_A2A`. - -The MCP tool name is auto-generated as `{connection_name}.SendMessage` (e.g., `helloworld.SendMessage`). - -Auth options are the same as MCP Tool. - -## Notes - -- All tool types are served through the same Foundry MCP gateway endpoint. -- Use [sample_toolboxes_crud.py](./sample_toolboxes_crud.py) for runnable SDK examples. diff --git a/samples/python/toolbox/azd/.dockerignore b/samples/python/toolbox/azd/.dockerignore deleted file mode 100644 index a3b95a009..000000000 --- a/samples/python/toolbox/azd/.dockerignore +++ /dev/null @@ -1,28 +0,0 @@ -# Exclude files not needed in the container image -.env -.env.* -.git -.gitignore -.venv -__pycache__ -*.pyc -.agent.log -.agent.pid -.foundry-agent.json -.foundry-agent-build.log -node_modules - -# OS / editor junk -.DS_Store -Thumbs.db -.vscode -.idea - -# Windows user-profile directories (junction points cause access-denied errors) -AppData -Desktop -Documents -Downloads -Music -Pictures -Videos diff --git a/samples/python/toolbox/azd/Dockerfile b/samples/python/toolbox/azd/Dockerfile deleted file mode 100644 index cd962d026..000000000 --- a/samples/python/toolbox/azd/Dockerfile +++ /dev/null @@ -1,7 +0,0 @@ -FROM mcr.microsoft.com/azurelinux/base/python:3.12 -WORKDIR /app -COPY . user_agent/ -WORKDIR /app/user_agent -RUN if [ -f requirements.txt ]; then pip install -r requirements.txt; fi -EXPOSE 8088 -CMD ["python3", "main.py"] diff --git a/samples/python/toolbox/azd/README.md b/samples/python/toolbox/azd/README.md deleted file mode 100644 index 3172cecb2..000000000 --- a/samples/python/toolbox/azd/README.md +++ /dev/null @@ -1,1418 +0,0 @@ - -**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. - - -# LangGraph Toolbox Agent β€” azd Quick Start Guide - -A LangGraph ReAct agent connected to an **toolbox in Microsoft Foundry** via MCP, with a -complete `azd` workflow for init, provision, and deploy. - -## How It Works - -The agent (`main.py`) works exactly like the [LangGraph sample](../langgraph/) but is -structured for `azd` deployment: - -1. On startup, `quickstart()` calls `client.get_tools()` against the Toolbox MCP endpoint. -2. A `create_react_agent` is built from the loaded tools and an Azure OpenAI LLM. -3. `ResponsesAgentServerHost` serves requests on port `8088`. -4. OAuth consent errors (MCP code `-32006`) are caught and surfaced to the caller as a - fallback tool message β€” the agent doesn't crash. - -The `azd/` folder includes: -- `main.py` β€” LangGraph agent (same pattern as `../langgraph/`) -- `agent.yaml` β€” runtime manifest (shared across all scenarios) -- `agent.manifest.yaml` β€” default (GitHub MCP key-auth) scenario manifest for `azd ai agent init -m` - -See [Supported Scenarios](#supported-scenarios) for all 14 scenario manifests inline. - -## Prerequisites - -- Python 3.12+ -- [Azure Developer CLI](https://learn.microsoft.com/azure/developer/azure-developer-cli/install-azd) (`azd`) -- `azure.ai.agents` azd extension installed -- A Microsoft Foundry account and project -- `az login` completed -- **Owner** or **User Access Administrator** role on the subscription or resource group β€” `azd provision` creates RBAC role assignments; without this, provision appears to succeed but `azd ai agent invoke` fails with **424 PermissionDenied** - -> **No pre-created toolbox needed.** `azd deploy` creates the toolbox automatically in your Foundry project (along with the container image and agent version). You don't need to run `sample_toolboxes_crud.py` or create a toolbox manually before using this sample. - -## Setup - -### 1. Install Azure Developer CLI (`azd`) - -**Linux/macOS:** -```bash -curl -fsSL https://aka.ms/install-azd.sh | bash -``` - -**Windows (PowerShell):** -```powershell -winget install microsoft.azd -``` - -See the [full installation docs](https://learn.microsoft.com/azure/developer/azure-developer-cli/install-azd) for other options. - -### 2. Install the AI Agents azd extension - -```bash -azd extension install azure.ai.agents -``` - -To upgrade the extension later: - -```bash -azd extension upgrade azure.ai.agents -``` - -### 3. Log in to Azure - -```bash -azd auth login -``` - -### 4. Fix git CRLF setting (Windows only) - -```bash -git config --global core.autocrlf false -``` - -## Quick Start (Deploy with azd) - -> **IMPORTANT:** The `-m` (or `--manifest`) flag is **required** for `azd ai agent init`. -> It tells the command where to find your agent definition and source files. -> -> `-m` can point to either: -> - **A specific `agent.yaml` file** β€” init copies all files from the same directory as the manifest -> - **A folder containing `agent.yaml`** β€” init copies all files from that folder -> -> All files in the manifest directory (main.py, Dockerfile, requirements.txt, setup.py, etc.) -> are copied **verbatim** into the scaffolded project under `src//`. - -```powershell -# 1. Create a manifest directory with your agent.yaml + source files -mkdir my-agent/manifest -# Copy agent.yaml, main.py, Dockerfile, requirements.txt into my-agent/manifest/ - -# 2. Initialize the azd project (note: -m is REQUIRED) -cd my-agent -$PROJECT_ID = "/subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//projects/" -azd ai agent init -m https://raw.githubusercontent.com/microsoft/hosted-agents-vnext-private-preview/main/samples/python/toolbox/azd/agent.manifest.yaml --project-id $PROJECT_ID -e my-env -# Or equivalently: azd ai agent init -m manifest/ --project-id $PROJECT_ID -e my-env -# ↑ If your agent.yaml declares {{ param }} secrets (e.g., github_pat), you will be prompted to enter -# them interactively HERE β€” before init completes. This is the only safe time to supply credentials. -# NOTE: Do NOT use --no-prompt here β€” it skips the prompt and leaves {{ param }} credentials empty (see Troubleshooting: Credentials Empty with --no-prompt) - -# 3. CRITICAL post-init fixes (see "Post-Init Checklist" below) -azd env set enableHostedAgentVNext "true" -e my-env -azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME "gpt-4o" -e my-env # must match the deployment name in azure.yaml - -# 4. Provision infrastructure and deploy the agent -azd up -e my-env - -# 5. Invoke the agent (MUST run from the scaffolded project directory) -azd ai agent invoke --new-session "Hello, what tools do you have?" --timeout 120 -``` - -### Post-Init Checklist - -After `azd ai agent init`, you **must** perform these steps before provision/deploy will work: - -| # | Action | Why | -|---|--------|-----| -| 1 | `azd env set enableHostedAgentVNext "true"` | Without this, container health probes fail | -| 2 | Edit `src//agent.yaml`: replace all `${{VAR}}` with `${VAR}` | Init scaffolds broken double-brace syntax that is NOT resolved at deploy time | -| 3 | Verify `agent.yaml` uses **flat format** (`kind: hosted` at root) | The nested `template:` format silently fails during deploy | -| 4 | For connections with credentials (e.g., GitHub PAT): enter them at the interactive `azd ai agent init` prompt. If you used `--no-prompt`, set them directly in `azure.yaml` under `config.connections[].credentials.keys` | Do NOT use `azd env set` for JSON credential values β€” causes unmarshal errors (see [Credentials Empty with --no-prompt](#-param--credentials-empty-with---no-prompt-e2e-validated)) | -| 5 | Verify `main.py` checks `FOUNDRY_PROJECT_ENDPOINT` first | Platform injects this var, NOT `AZURE_AI_PROJECT_ENDPOINT` | -| 6 | `azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME ""` | Must match the deployment `name` in `azure.yaml`; platform injection is unreliable without this (container crashes on startup) | -| 7 | **If using existing project with AppInsights already connected:** `azd env set ENABLE_MONITORING "false"` | Provision fails with duplicate App Insights connection error | -| 8 | **If model region β‰  RG region:** edit generated `infra/main.parameters.json` β€” change `aiDeploymentsLocation` value from `${AZURE_LOCATION}` to `${AZURE_AI_DEPLOYMENTS_LOCATION}`, then `azd env set AZURE_AI_DEPLOYMENTS_LOCATION ""` | Init templates map model deployment location to `AZURE_LOCATION` which is wrong when model is in a different region | - -### What `azd ai agent init` Does - -`azd ai agent init` copies all source files (main.py, Dockerfile, requirements.txt, etc.) **verbatim** from the manifest directory into `src//` in the scaffolded project. It does NOT generate or modify main.py β€” it copies the exact file from your manifest. - -The init command also: -- Creates `azure.yaml` with service config, connections, and toolbox definitions -- Creates `infra/` directory with Bicep templates -- Creates `.azure//.env` with environment variables - -### Invoke Must Run from Scaffolded Directory - -`azd ai agent invoke` reads the `azure.yaml` and `.azure//` configuration from the **current working directory**. If you run it from a different directory, it fails with "no project found." - -```powershell -# WRONG β€” will fail -cd C:\some\other\dir -azd ai agent invoke --new-session "Hello" --timeout 120 - -# CORRECT β€” run from the scaffolded project -cd C:\Users\me\AppData\Local\Temp\my-scaffolded-project -azd ai agent invoke --new-session "Hello" --timeout 120 -``` - -The `--timeout 120` flag is recommended because agent cold starts can take up to 60 seconds. - ---- - -## Project Structure - -After `azd ai agent init`, you get: - -``` -my-project/ -β”œβ”€β”€ .azure/ -β”‚ └── / -β”‚ β”œβ”€β”€ .env # Environment variables (auto-populated by azd) -β”‚ └── config.json # Subscription + location config -β”œβ”€β”€ .github/ # CI/CD workflows -β”œβ”€β”€ infra/ -β”‚ β”œβ”€β”€ main.bicep # Top-level Bicep template -β”‚ β”œβ”€β”€ main.parameters.json # Parameters (references .env values) -β”‚ └── core/ -β”‚ └── ai/ -β”‚ β”œβ”€β”€ ai-project.bicep # Project + connection deployment -β”‚ └── connection.bicep # Connection resource template -β”œβ”€β”€ src/ -β”‚ └── / -β”‚ β”œβ”€β”€ agent.yaml # Agent definition (env vars, protocols) -β”‚ β”œβ”€β”€ main.py # Your agent code -β”‚ β”œβ”€β”€ Dockerfile # Container build -β”‚ β”œβ”€β”€ requirements.txt # Azure SDK preview packages -β”‚ └── requirements-pypi.txt # Public packages -β”œβ”€β”€ azure.yaml # azd service + toolbox configuration -└── .gitignore -``` - -### Key Files - -| File | Purpose | -|------|---------| -| `azure.yaml` | Defines services, connections, toolboxes, model deployments | -| `src//agent.yaml` | Agent manifest: name, env vars, protocols, resources | -| `.azure//.env` | Environment variables consumed by Bicep and Go extension | -| `infra/core/ai/connection.bicep` | Bicep template for creating connections | - ---- - -## azure.yaml Structure - -```yaml -requiredVersions: - extensions: - azure.ai.agents: '>=0.1.0-preview' -name: my-agent-project -services: - my-agent: - project: ./src/my-agent - host: azure.ai.agent - language: docker - docker: - remoteBuild: true - config: - container: - resources: - cpu: "0.25" - memory: 0.5Gi - deployments: # Model deployments - - model: - format: OpenAI - name: gpt-4o - version: "2024-11-20" - name: gpt-4o - sku: - capacity: 10 - name: GlobalStandard - connections: # Connection definitions - - name: my-connection - category: RemoteTool # or CognitiveSearch, RemoteA2A, etc. - authType: CustomKeys - target: https://example.com - credentials: - keys: - Authorization: "Bearer my-token" - toolboxes: # Toolbox definitions - - name: my-tools - description: My tool collection - tools: - - type: web_search - - type: mcp - server_label: my-mcp - project_connection_id: my-connection -infra: - provider: bicep - path: ./infra -``` - ---- - -## agent.yaml Structure - -The agent.yaml **MUST** use the flat format with `kind: hosted` at the top level. The nested `template:` format (where `kind` is under `template.kind`) is silently ignored by the Go extension during deploy. - -```yaml -# CORRECT β€” flat format (required) -kind: hosted -name: my-agent -description: My agent description -metadata: - tags: - - AI Agent Hosting -protocols: - - protocol: responses - version: 1.0.0 -environment_variables: - # FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_AGENT_TOOLBOX_* are injected - # automatically by the platform at runtime β€” do NOT declare them here. - - name: AZURE_OPENAI_ENDPOINT - value: ${AZURE_OPENAI_ENDPOINT} - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} -resources: - - kind: model - id: gpt-4o - name: chat -``` - -```yaml -# WRONG β€” nested format (deploy silently fails) -name: my-agent -template: - kind: hosted # ← This is NOT read by the Go extension - protocols: [...] -``` - -> **CRITICAL**: Use `${VAR}` (single-brace) syntax for environment variables in agent.yaml. -> The `${{VAR}}` (double-brace) syntax that `azd init` scaffolds is **NOT resolved** by the Go extension β€” the container receives the literal string `${{VAR}}`. -> -> **CRITICAL**: Use the **flat format** (with `kind:` at top level) β€” NOT the nested `template:` format. The Go extension requires `kind: hosted` at the top level. - ---- - -## Supported Scenarios - -The `agent.yaml` runtime definition is **identical for all scenarios** β€” only `agent.manifest.yaml` differs per scenario. `agent.manifest.yaml` drives `azd ai agent init`: it declares toolbox resources, connection definitions, and secret parameters that are prompted interactively. - -To deploy a scenario: - -1. Create a folder and copy the shared `agent.yaml` (below) into it. -2. Copy the scenario's `agent.manifest.yaml` (from a section below) into the same folder. -3. Run `azd ai agent init -m --project-id -e ` β€” enter required secrets at the interactive prompt. -4. Run `azd up -e `. - -### Shared `agent.yaml` - -> This file is the runtime container definition. `FOUNDRY_PROJECT_ENDPOINT` and `FOUNDRY_AGENT_TOOLBOX_*` are auto-injected by the platform β€” do not declare them here. - -```yaml -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml -kind: hosted -name: toolbox-azd -description: LangGraph ReAct agent wired to a toolbox in Microsoft Foundry via MCP. -metadata: - tags: - - AI Agent Hosting - - LangGraph -protocols: - - protocol: responses - version: 1.0.0 -environment_variables: - # FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_AGENT_TOOLBOX_* are injected - # automatically by the platform at runtime β€” do NOT declare them here. - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o} - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} -``` - ---- - -### 1. Web Search - -No connection or secrets required. The simplest toolbox scenario. - -**`agent.manifest.yaml`** - -```yaml -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml -name: toolbox-azd-web-search -displayName: "LangGraph Web Search Toolbox Agent" -description: > - LangGraph ReAct agent with a Bing web search toolbox. The simplest toolbox - scenario β€” no connections or secrets required. -metadata: - tags: - - AI Agent Hosting - - LangGraph - - Web Search - - Microsoft Foundry -template: - name: toolbox-azd - kind: hosted - protocols: - - protocol: responses - version: 1.0.0 - environment_variables: - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} - resources: - cpu: "0.25" - memory: 0.5Gi -parameters: - properties: [] -resources: - - kind: model - id: gpt-4o - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - - kind: toolbox - name: agent-tools - tools: - - type: web_search -``` - ---- - -### 2. File Search - -Requires a vector store in the same Foundry project. Prompted parameter: `file_search_vector_store_id`. - -**`agent.manifest.yaml`** - -```yaml -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml -name: toolbox-azd-file-search -displayName: "LangGraph File Search Toolbox Agent" -description: > - LangGraph ReAct agent with a File Search toolbox backed by an Azure AI - Foundry vector store. The vector store must exist in the same project. -metadata: - tags: - - AI Agent Hosting - - LangGraph - - File Search - - Microsoft Foundry -template: - name: toolbox-azd - kind: hosted - protocols: - - protocol: responses - version: 1.0.0 - environment_variables: - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} - resources: - cpu: "0.25" - memory: 0.5Gi -parameters: - properties: - - name: file_search_vector_store_id - secret: false - description: Vector store ID from the same Foundry project (e.g. vs_xxxxxxxxxxxx) -resources: - - kind: model - id: gpt-4o - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - - kind: toolbox - name: agent-tools - tools: - - type: file_search - vector_store_ids: - - "{{ file_search_vector_store_id }}" -``` - ---- - -### 3. Code Interpreter - -No secrets required. Executes Python code in a sandboxed environment. - -**`agent.manifest.yaml`** - -```yaml -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml -name: toolbox-azd-code-interpreter -displayName: "LangGraph Code Interpreter Toolbox Agent" -description: > - LangGraph ReAct agent with a Code Interpreter toolbox. Executes Python - code in a sandboxed environment via toolbox in Microsoft Foundry. -metadata: - tags: - - AI Agent Hosting - - LangGraph - - Code Interpreter - - Microsoft Foundry -template: - name: toolbox-azd - kind: hosted - protocols: - - protocol: responses - version: 1.0.0 - environment_variables: - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} - resources: - cpu: "0.25" - memory: 0.5Gi -parameters: - properties: [] -resources: - - kind: model - id: gpt-4o - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - - kind: toolbox - name: agent-tools - tools: - - type: code_interpreter -``` - ---- - -### 4. MCP Key-Auth (GitHub) - -Prompted parameter: `github_pat` (GitHub Personal Access Token, injected as Bearer token). - -**`agent.manifest.yaml`** - -```yaml -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml -name: toolbox-azd-mcp-keyauth -displayName: "LangGraph GitHub MCP Key-Auth Toolbox Agent" -description: > - LangGraph ReAct agent with a GitHub MCP toolbox using key-based authentication - (GitHub PAT injected as Bearer token). -metadata: - tags: - - AI Agent Hosting - - LangGraph - - MCP - - GitHub - - Microsoft Foundry -template: - name: toolbox-azd - kind: hosted - protocols: - - protocol: responses - version: 1.0.0 - environment_variables: - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} - resources: - cpu: "0.25" - memory: 0.5Gi -parameters: - properties: - - name: github_pat - secret: true - description: GitHub Personal Access Token (classic ghp_... or fine-grained github_pat_...) -resources: - - kind: model - id: gpt-4o - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - - kind: connection - name: github-mcp-conn - category: RemoteTool - authType: CustomKeys - target: https://api.githubcopilot.com/mcp - credentials: - type: CustomKeys - keys: - Authorization: "Bearer {{ github_pat }}" - - kind: toolbox - name: agent-tools - tools: - - type: mcp - server_label: github - project_connection_id: github-mcp-conn -``` - ---- - -### 5. MCP No-Auth - -Prompted parameter: `mcp_endpoint` (URL of the public MCP server). No credentials needed. - -**`agent.manifest.yaml`** - -```yaml -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml -name: toolbox-azd-mcp-noauth -displayName: "LangGraph Public MCP No-Auth Toolbox Agent" -description: > - LangGraph ReAct agent connected to a public MCP server that requires no - authentication. The server URL is proxied by toolbox in Microsoft Foundry. -metadata: - tags: - - AI Agent Hosting - - LangGraph - - MCP - - No-Auth - - Microsoft Foundry -template: - name: toolbox-azd - kind: hosted - protocols: - - protocol: responses - version: 1.0.0 - environment_variables: - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} - resources: - cpu: "0.25" - memory: 0.5Gi -parameters: - properties: - - name: mcp_endpoint - secret: false - description: URL of the public MCP server (e.g. https://gitmcp.io/Azure/azure-rest-api-specs) -resources: - - kind: model - id: gpt-4o - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - - kind: toolbox - name: agent-tools - tools: - - type: mcp - server_label: noauthmcp - server_url: "{{ mcp_endpoint }}" -``` - ---- - -### 6. MCP OAuth (Managed Connector) - -No secrets required β€” Foundry manages the OAuth app registration. First invocation returns a consent URL (MCP code `-32006`). - -**`agent.manifest.yaml`** - -```yaml -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml -name: toolbox-azd-mcp-oauth-managed -displayName: "LangGraph MCP OAuth2 Managed Connector Toolbox Agent" -description: > - LangGraph ReAct agent with a GitHub MCP toolbox using Microsoft Foundry's - managed OAuth connector. No client credentials needed β€” Foundry handles - the OAuth app registration. First invocation triggers a consent flow. -metadata: - tags: - - AI Agent Hosting - - LangGraph - - MCP - - OAuth2 - - GitHub - - Managed Connector - - Microsoft Foundry -template: - name: toolbox-azd - kind: hosted - protocols: - - protocol: responses - version: 1.0.0 - environment_variables: - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} - resources: - cpu: "0.25" - memory: 0.5Gi -parameters: - properties: [] -resources: - - kind: model - id: gpt-4o - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - - kind: connection - name: github-oauth-conn - category: RemoteTool - authType: OAuth2 - target: https://api.githubcopilot.com/mcp - connectorName: foundrygithubmcp - credentials: - type: OAuth2 - clientId: managed - clientSecret: managed - - kind: toolbox - name: agent-tools - tools: - - type: mcp - server_label: github - project_connection_id: github-oauth-conn -``` - ---- - -### 7. MCP OAuth (Custom App) - -Prompted parameters: `your_client_id`, `your_client_secret` (OAuth2 app registration). First invocation returns a consent URL (MCP code `-32006`). - -**`agent.manifest.yaml`** - -```yaml -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml -name: toolbox-azd-mcp-oauth-custom -displayName: "LangGraph MCP OAuth2 Custom App Registration Toolbox Agent" -description: > - LangGraph ReAct agent with a GitHub MCP toolbox using a bring-your-own - OAuth2 app registration. First invocation triggers a consent flow. -metadata: - tags: - - AI Agent Hosting - - LangGraph - - MCP - - OAuth2 - - GitHub - - Microsoft Foundry -template: - name: toolbox-azd - kind: hosted - protocols: - - protocol: responses - version: 1.0.0 - environment_variables: - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} - resources: - cpu: "0.25" - memory: 0.5Gi -parameters: - properties: - - name: your_client_id - secret: false - description: OAuth2 client ID from your app registration - - name: your_client_secret - secret: true - description: OAuth2 client secret from your app registration -resources: - - kind: model - id: gpt-4o - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - - kind: connection - name: github-oauth-custom-conn - category: RemoteTool - authType: OAuth2 - target: https://api.githubcopilot.com/mcp - credentials: - type: OAuth2 - clientId: "{{ your_client_id }}" - clientSecret: "{{ your_client_secret }}" - authorizationUrl: "https://github.com/login/oauth/authorize" - tokenUrl: "https://github.com/login/oauth/access_token" - refreshUrl: "https://github.com/login/oauth/access_token" - scopes: - - repo - - read:user - - kind: toolbox - name: agent-tools - tools: - - type: mcp - server_label: github - project_connection_id: github-oauth-custom-conn -``` - ---- - -### 8. MCP Agent Identity - -Prompted parameters: `entra_audience`, `mcp_target_url`. Assign an RBAC role to the agent's managed identity on the target MCP server before deploying. - -**`agent.manifest.yaml`** - -```yaml -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml -name: toolbox-azd-mcp-agent-identity -displayName: "LangGraph MCP Agent Identity Toolbox Agent" -description: > - LangGraph ReAct agent with an MCP toolbox using Microsoft Foundry's Agentic - Identity (agent managed identity) for Entra ID authentication to the MCP server. -metadata: - tags: - - AI Agent Hosting - - LangGraph - - MCP - - Agentic Identity - - Microsoft Foundry -template: - name: toolbox-azd - kind: hosted - protocols: - - protocol: responses - version: 1.0.0 - environment_variables: - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} - resources: - cpu: "0.25" - memory: 0.5Gi -parameters: - properties: - - name: entra_audience - secret: false - description: Entra ID audience for the target MCP server - - name: mcp_target_url - secret: false - description: URL of the MCP server that accepts agent identity tokens -resources: - - kind: model - id: gpt-4o - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - - kind: connection - name: language-mcp - category: RemoteTool - authType: AgenticIdentity - audience: "{{ entra_audience }}" - target: "{{ mcp_target_url }}" - - kind: toolbox - name: agent-tools - tools: - - type: mcp - server_label: language-mcp - project_connection_id: language-mcp -``` - ---- - -### 9. Azure AI Search - -Prompted parameters: `ai_search_endpoint`, `ai_search_key`, `ai_search_index_name`. - -**`agent.manifest.yaml`** - -```yaml -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml -name: toolbox-azd-ai-search -displayName: "LangGraph Azure AI Search Toolbox Agent" -description: > - LangGraph ReAct agent with an Azure AI Search toolbox. Queries an existing - search index via toolbox proxy in Microsoft Foundry. -metadata: - tags: - - AI Agent Hosting - - LangGraph - - Azure AI Search - - Microsoft Foundry -template: - name: toolbox-azd - kind: hosted - protocols: - - protocol: responses - version: 1.0.0 - environment_variables: - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} - resources: - cpu: "0.25" - memory: 0.5Gi -parameters: - properties: - - name: ai_search_endpoint - secret: false - description: Azure AI Search service endpoint (e.g. https://my-search.search.windows.net/) - - name: ai_search_key - secret: true - description: Azure AI Search admin key - - name: ai_search_index_name - secret: false - description: Name of the search index to query -resources: - - kind: model - id: gpt-4o - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - - kind: connection - name: aisearch-conn - category: CognitiveSearch - authType: ApiKey - target: "{{ ai_search_endpoint }}" - credentials: - type: ApiKey - key: "{{ ai_search_key }}" - - kind: toolbox - name: agent-tools - tools: - - type: azure_ai_search - index_name: "{{ ai_search_index_name }}" - project_connection_id: aisearch-conn -``` - ---- - -### 10. A2A (Agent-to-Agent) - -Prompted parameter: `a2a_agent_endpoint` (URL of the remote A2A-compatible agent). - -**`agent.manifest.yaml`** - -```yaml -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml -name: toolbox-azd-a2a -displayName: "LangGraph Agent-to-Agent (A2A) Toolbox Agent" -description: > - LangGraph ReAct agent with an Agent-to-Agent (A2A) toolbox. Calls a remote - A2A-compatible agent endpoint via toolbox proxy in Microsoft Foundry. -metadata: - tags: - - AI Agent Hosting - - LangGraph - - A2A - - Microsoft Foundry -template: - name: toolbox-azd - kind: hosted - protocols: - - protocol: responses - version: 1.0.0 - environment_variables: - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} - resources: - cpu: "0.25" - memory: 0.5Gi -parameters: - properties: - - name: a2a_agent_endpoint - secret: false - description: URL of the remote A2A-compatible agent endpoint (e.g. https://my-agent.azurecontainerapps.io) -resources: - - kind: model - id: gpt-4o - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - - kind: connection - name: a2a-conn - category: RemoteA2A - authType: None - target: "{{ a2a_agent_endpoint }}" - - kind: toolbox - name: agent-tools - tools: - - type: a2a_preview - project_connection_id: a2a-conn -``` - ---- - -### 11. Bing Custom Search - -Prompted parameters: `bing_api_key`, `bing_resource_id`, `bing_custom_instance`. - -**`agent.manifest.yaml`** - -```yaml -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml -name: toolbox-azd-bing-custom-search -displayName: "LangGraph Bing Custom Search Toolbox Agent" -description: > - LangGraph ReAct agent with a Bing Custom Search toolbox. Uses a - GroundingWithCustomSearch connection for scoped web search. -metadata: - tags: - - AI Agent Hosting - - LangGraph - - Bing Custom Search - - Microsoft Foundry -template: - name: toolbox-azd - kind: hosted - protocols: - - protocol: responses - version: 1.0.0 - environment_variables: - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} - resources: - cpu: "0.25" - memory: 0.5Gi -parameters: - properties: - - name: bing_api_key - secret: true - description: Bing Search API key - - name: bing_resource_id - secret: false - description: ARM resource ID of your Bing account - - name: bing_custom_instance - secret: false - description: Bing Custom Search instance name -resources: - - kind: model - id: gpt-4o - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - - kind: connection - name: bing-custom-conn - category: GroundingWithCustomSearch - authType: ApiKey - target: https://api.bing.microsoft.com/ - credentials: - type: ApiKey - key: "{{ bing_api_key }}" - metadata: - ResourceId: "{{ bing_resource_id }}" - type: bing_custom_search_preview - - kind: toolbox - name: agent-tools - tools: - - type: web_search - custom_search_configuration: - instance_name: "{{ bing_custom_instance }}" - project_connection_id: bing-custom-conn -``` - ---- - -### 12. OpenAPI Key-Auth - -Prompted parameter: `tripadvisor_api_key`. Replace the spec and connection with your own OpenAPI service. - -**`agent.manifest.yaml`** - -```yaml -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml -name: toolbox-azd-openapi-keyauth -displayName: "LangGraph OpenAPI Key-Auth Toolbox Agent" -description: > - LangGraph ReAct agent with an OpenAPI toolbox using key-based auth. - Uses TripAdvisor Content API as an example β€” replace the spec and - connection with your own OpenAPI service. -metadata: - tags: - - AI Agent Hosting - - LangGraph - - OpenAPI - - Microsoft Foundry -template: - name: toolbox-azd - kind: hosted - protocols: - - protocol: responses - version: 1.0.0 - environment_variables: - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} - resources: - cpu: "0.25" - memory: 0.5Gi -parameters: - properties: - - name: tripadvisor_api_key - secret: true - description: TripAdvisor Content API key (replace with your own OpenAPI service key) -resources: - - kind: model - id: gpt-4o - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - - kind: connection - name: tripadvisor-conn - category: CustomKeys - authType: CustomKeys - target: https://api.content.tripadvisor.com - credentials: - type: CustomKeys - keys: - key: "{{ tripadvisor_api_key }}" - - kind: toolbox - name: agent-tools - tools: - - type: openapi - openapi: - name: tripadvisor - spec: - openapi: "3.0.1" - info: - title: "TripAdvisor API" - version: "1.0" - servers: - - url: https://api.content.tripadvisor.com/api/v1 - paths: - /location/search: - get: - operationId: searchLocations - parameters: - - name: searchQuery - in: query - required: true - schema: - type: string - - name: key - in: query - required: true - schema: - type: string - responses: - "200": - description: OK - auth: - type: connection_auth - connection_id: tripadvisor-conn -``` - ---- - -### 13. MCP OAuth (Entra Passthrough) - -Prompted parameters: `entra_audience`, `entra_mcp_target`. Foundry proxies the caller's Entra identity to the downstream MCP server. - -**`agent.manifest.yaml`** - -```yaml -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml -name: toolbox-azd-mcp-entra-passthrough -displayName: "LangGraph MCP Entra Token Passthrough Toolbox Agent" -description: > - LangGraph ReAct agent with an MCP toolbox that uses Entra token passthrough. - Microsoft Foundry proxies the caller's Entra identity to the downstream MCP server. -metadata: - tags: - - AI Agent Hosting - - LangGraph - - MCP - - Entra Token Passthrough - - Microsoft Foundry -template: - name: toolbox-azd - kind: hosted - protocols: - - protocol: responses - version: 1.0.0 - environment_variables: - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} - resources: - cpu: "0.25" - memory: 0.5Gi -parameters: - properties: - - name: entra_audience - secret: false - description: Entra ID audience for the target MCP server - - name: entra_mcp_target - secret: false - description: URL of the MCP server that accepts Entra user tokens -resources: - - kind: model - id: gpt-4o - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - - kind: connection - name: entra-passthrough-conn - category: RemoteTool - authType: UserEntraToken - audience: "{{ entra_audience }}" - target: "{{ entra_mcp_target }}" - - kind: toolbox - name: agent-tools - tools: - - type: mcp - server_label: outlook-mail - project_connection_id: entra-passthrough-conn -``` - ---- - -### 14. Multi-Tool Toolbox - -Prompted parameter: `github_pat`. Combines Bing web search and GitHub MCP in one toolbox. - -**`agent.manifest.yaml`** - -```yaml -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml -name: toolbox-azd-multi-tool -displayName: "LangGraph Multi-Tool Toolbox Agent (Web Search + GitHub MCP)" -description: > - LangGraph ReAct agent with a combined toolbox: Bing web search plus GitHub - MCP tools via key-based auth. Demonstrates multiple tool types in one toolbox. -metadata: - tags: - - AI Agent Hosting - - LangGraph - - MCP - - Web Search - - GitHub - - Microsoft Foundry -template: - name: toolbox-azd - kind: hosted - protocols: - - protocol: responses - version: 1.0.0 - environment_variables: - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} - resources: - cpu: "0.25" - memory: 0.5Gi -parameters: - properties: - - name: github_pat - secret: true - description: GitHub Personal Access Token (classic ghp_... or fine-grained github_pat_...) -resources: - - kind: model - id: gpt-4o - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - - kind: connection - name: github-mcp-conn - category: RemoteTool - authType: CustomKeys - target: https://api.githubcopilot.com/mcp - credentials: - type: CustomKeys - keys: - Authorization: "Bearer {{ github_pat }}" - - kind: toolbox - name: agent-tools - tools: - - type: web_search - - type: mcp - server_label: github - project_connection_id: github-mcp-conn -``` - ---- - -## Troubleshooting - -> Items marked **[E2E-Validated]** were confirmed as blockers during April 2026 from-scratch `azd` testing (init β†’ provision β†’ deploy β†’ invoke). - -### `${{VAR}}` double-brace syntax in agent.yaml - -**Symptom:** -- `azd ai agent init` generates `${{VAR}}` in `src//agent.yaml`. -- At runtime, the container receives the literal string `${{VAR}}` instead of the resolved value. - -**Fix:** After init, replace all `${{VAR}}` with `${VAR}` (single-brace) in `src//agent.yaml`. - -### `azd ai agent invoke` fails with 424 PermissionDenied - -**Symptom:** `azd provision` completes successfully but `azd ai agent invoke` returns `424 PermissionDenied`. -**Root Cause:** `azd provision` creates RBAC role assignments on the Foundry project. This requires **Owner** or **User Access Administrator** on the subscription or resource group. If you only have Contributor, the Bicep deployment succeeds (role assignment resource is silently dropped) but the agent identity lacks the necessary permissions. -**Fix:** Ensure you have the **Owner** or **User Access Administrator** role before running `azd provision`, then re-run `azd provision` to create the missing role assignments. - -### Provision fails with "Only 100 connections are allowed" - -**Symptom:** `azd provision` returns 400 for connection creation. -**Fix:** The account has a 100-connection limit. Delete unused connections: -```powershell -$armToken = az account get-access-token --query accessToken -o tsv -$url = "https://management.azure.com/connections/?api-version=2025-04-01-preview" -Invoke-WebRequest -Method DELETE -Uri $url -Headers @{Authorization="Bearer $armToken"} -``` - -### Provision hangs on connection deployment - -**Symptom:** `azd provision` stalls for 30+ minutes on connection creation. -**Cause:** A previous deployment with the same connection name may be stuck. -**Fix:** Cancel the deployment via ARM API: -```powershell -$armToken = az account get-access-token --query accessToken -o tsv -$url = "https://management.azure.com/subscriptions//resourcegroups//providers/Microsoft.Resources/deployments/connection-/cancel?api-version=2021-04-01" -Invoke-RestMethod -Method POST -Uri $url -Headers @{Authorization="Bearer $armToken"} -``` - -### JSON escaping errors during provision - -**Symptom:** "Failed to unmarshal" errors for connection credentials. -**Fix:** Don't manually `azd env set` JSON values. Put credentials in `azure.yaml` `config.connections` and let the Go extension handle serialization. If you must inject manually, triple-escape: `\\\"` not `\"`. - -### MAF `MCPStreamableHTTPTool` CancelledError [E2E-Validated] - -**Symptom:** Agent returns "Request was cancelled" for every query. -**Root Cause:** The MAF `MCPStreamableHTTPTool` has an `asyncio.CancelledError` bug in its async context manager that causes tool calls to silently cancel. -**Fix:** Use the **LangGraph** agent pattern (with `langchain-mcp-adapters` `MultiServerMCPClient`) instead of MAF's `MCPStreamableHTTPTool`. This is why the `azd/main.py` sample uses LangGraph. - -### Platform Env Var Names Don't Match Code Defaults [E2E-Validated] - -**Symptom:** Container crashes with `ValueError: AZURE_AI_PROJECT_ENDPOINT environment variable must be set`. -**Root Cause:** The platform injects `FOUNDRY_PROJECT_ENDPOINT` (not `AZURE_AI_PROJECT_ENDPOINT`) and `AZURE_AI_MODEL_DEPLOYMENT_NAME` (not `MODEL_DEPLOYMENT_NAME`). -**Fix:** Always check the platform-injected name first, with a fallback: -```python -# CORRECT -PROJECT_ENDPOINT = os.getenv("FOUNDRY_PROJECT_ENDPOINT") or os.getenv("AZURE_AI_PROJECT_ENDPOINT") -MODEL = os.getenv("AZURE_AI_MODEL_DEPLOYMENT_NAME") or os.getenv("MODEL_DEPLOYMENT_NAME", "gpt-4o") -``` - -### Stale Toolbox Connection Reference [E2E-Validated] - -**Symptom:** Agent loads 0 tools ("no tools available") even though the toolbox exists. -**Root Cause:** After reprovisioning, the toolbox may still reference an old/deleted connection name (e.g., `github-mcp-connection` vs. `github-mcp-conn`). The `azd provision` creates the connection but doesn't update the toolbox's `project_connection_id`. -**Fix:** Ensure the connection `name` in `azure.yaml` exactly matches the `project_connection_id` in the toolbox config. If they're out of sync, delete the toolbox and redeploy: -```powershell -# Delete stale toolbox -$token = az account get-access-token --resource "https://ai.azure.com" -o tsv --query accessToken -curl -X DELETE "$ENDPOINT/toolsets/?api-version=v1" -H "Authorization: Bearer $token" -H "Foundry-Features: Toolsets=V1Preview" -# Then run azd deploy again -azd deploy -e --no-prompt -``` - -### `{{ param }}` Credentials Empty with `--no-prompt` [E2E-Validated] - -**Symptom:** Connection created with empty/invalid credentials; MCP returns "Authorization header is badly formatted". -**Root Cause:** The `{{ github_pat }}` template syntax in the manifest is resolved **interactively** at `azd ai agent init` time. With `--no-prompt`, secret parameters get empty values. -**Fix:** After init with `--no-prompt`, manually set the credential in `azure.yaml`: -```yaml -# In azure.yaml, under config.connections: -credentials: - keys: - Authorization: "Bearer ghp_xxxxx" # Your actual PAT -``` -Or set the env var before provision: `azd env set GITHUB_PAT "ghp_xxxxx"` - -### Toolbox MCP Endpoint Auto-Injection [E2E-Validated] - -**Symptom:** Agent code can't find the toolbox endpoint. -**Root Cause:** The platform auto-injects `TOOLBOX_{TOOLBOX_NAME}_MCP_ENDPOINT` (e.g., `TOOLBOX_AGENT_TOOLS_MCP_ENDPOINT` for a toolbox named `agent-tools`). The variable name is derived from the toolbox name in `azure.yaml`, with dashes converted to underscores and uppercased. -**Fix:** Agent code should check `TOOLBOX_{NAME}_MCP_ENDPOINT` first, then fall back to constructing the URL from `FOUNDRY_AGENT_TOOLBOX_ENDPOINT`. See the `main.py` sample for the correct pattern. - -### Connection Names Must Use Dashes, Not Underscores [E2E-Validated] - -**Symptom:** Bicep deployment fails on connection name validation. -**Fix:** Connection names in `azure.yaml` must use only alphanumeric characters, dashes, and dots. Replace underscores with dashes (e.g., `github-mcp-conn` not `github_mcp_conn`). - -### Connection `category` Must Be `RemoteTool` (Not `CustomKeys`) [E2E-Validated] - -**Symptom:** `azd provision` fails with "Object reference not set to an instance of an object" when creating a connection. -**Root Cause:** The `category` field specifies the connection *type* (e.g., `RemoteTool`, `CognitiveSearch`, `RemoteA2A`). Using `CustomKeys` as the `category` (confusing it with the `authType`) causes a null reference in the provisioning pipeline. -**Fix:** Always use `category: RemoteTool` for MCP or HTTP tool connections that authenticate with API keys. `CustomKeys` belongs in `authType` (in azure.yaml) or `credentials.type` (in agent.yaml), never in `category`: -```yaml -# CORRECT -connections: - - name: github-mcp-conn - category: RemoteTool # βœ… connection TYPE β€” not "CustomKeys" - authType: CustomKeys # βœ… auth METHOD β€” this is where CustomKeys goes - target: https://api.githubcopilot.com/mcp - credentials: - keys: - Authorization: "Bearer {{ github_pat }}" - -# WRONG -connections: - - name: github-mcp-conn - category: CustomKeys # ❌ causes "Object reference not set" during provision -``` - -### Provision Fails with Duplicate App Insights Connection [E2E-Validated] - -**Symptom:** `azd provision` fails with a deployment error about a duplicate Application Insights connection on the AI project. -**Root Cause:** The Bicep template tries to create an App Insights connection on the AI project, but one already exists (created earlier by the portal, another deployment, or a prior `azd provision` run). -**Fix:** Set `ENABLE_MONITORING=false` before provisioning to skip the App Insights connection deployment: -```powershell -azd env set ENABLE_MONITORING "false" -e my-env -azd provision -e my-env -``` - -### `aiDeploymentsLocation` Template Bug (Model Region Mismatch) [E2E-Validated] - -**Symptom:** `azd provision` fails with `InvalidResourceLocation` on the AI account or model deployment, even though `AZURE_LOCATION` is set correctly. -**Root Cause:** The `infra/main.parameters.json` generated by `azd ai agent init` maps `aiDeploymentsLocation` to `${AZURE_LOCATION}`. If your resource group is in one region (e.g., `westus2`) but your AI account and model deployment are in another (e.g., `northcentralus`), provision fails because it tries to deploy the model to the wrong region. -**Fix:** After `azd ai agent init`, manually edit `infra/main.parameters.json`: -```json -// Change this (generated by init β€” WRONG when model region β‰  RG region): -"aiDeploymentsLocation": { - "value": "${AZURE_LOCATION}" -} -// To this: -"aiDeploymentsLocation": { - "value": "${AZURE_AI_DEPLOYMENTS_LOCATION}" -} -``` -Then set the model region: -```powershell -azd env set AZURE_AI_DEPLOYMENTS_LOCATION "northcentralus" -e my-env # region where your AI account is -``` - ---- - -## Command Reference - -| Command | Purpose | -|---------|---------| -| `azd ai agent init -m --project-id -e ` | Initialize azd project from manifest (omit `--no-prompt` for credential prompts) | -| `azd provision -e ` | Deploy infrastructure (connections via Bicep) | -| `azd deploy -e ` | Deploy agent (toolboxes + container + agent version) | -| `azd ai agent invoke --new-session "" --timeout 120` | Send message to deployed agent | -| `azd ai agent monitor --tail 50` | View agent container logs | -| `azd env set "" -e ` | Set environment variable | -| `azd env get-values -e ` | List all environment variables | - ---- - -## Verifying Toolbox via MCP API (Direct) - -You can verify toolboxes without deploying an agent by calling the MCP endpoint directly: - -```python -import httpx -from azure.identity import DefaultAzureCredential - -cred = DefaultAzureCredential() -token = cred.get_token("https://ai.azure.com/.default").token -endpoint = "https://.services.ai.azure.com/api/projects/" -headers = { - "Authorization": f"Bearer {token}", - "Content-Type": "application/json", - "Foundry-Features": "Toolboxes=V1Preview", -} - -# Initialize -init = {"jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": "2025-03-26", "capabilities": {}, - "clientInfo": {"name": "test", "version": "1.0"}}} -httpx.post(f"{endpoint}/toolboxes//versions//mcp?api-version=v1", - json=init, headers=headers, timeout=30) - -# List tools -resp = httpx.post(f"{endpoint}/toolboxes//versions//mcp?api-version=v1", - json={"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}, - headers=headers, timeout=60) -tools = resp.json()["result"]["tools"] -print(f"Tools ({len(tools)}):", [t["name"] for t in tools]) - -# Call a tool -resp = httpx.post(f"{endpoint}/toolboxes//versions//mcp?api-version=v1", - json={"jsonrpc": "2.0", "id": 3, "method": "tools/call", - "params": {"name": "web_search", - "arguments": {"search_query": "test"}}}, - headers=headers, timeout=120) -print(resp.json()["result"]["content"]) -``` - -> **CRITICAL:** The MCP endpoint requires both the `Foundry-Features: Toolboxes=V1Preview` header AND the `?api-version=v1` query parameter. The URL format is `/toolboxes//versions//mcp?api-version=v1` where `` is the toolbox version number (e.g., `1`). - ---- - -## Contributing - -This project welcomes contributions and suggestions. - -## Trademarks - -This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft trademarks or logos is subject to and must follow [Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. Any use of third-party trademarks or logos are subject to those third-party's policies. diff --git a/samples/python/toolbox/azd/agent.manifest.yaml b/samples/python/toolbox/azd/agent.manifest.yaml deleted file mode 100644 index 7c2fd4659..000000000 --- a/samples/python/toolbox/azd/agent.manifest.yaml +++ /dev/null @@ -1,29 +0,0 @@ -name: toolbox-azd -description: > - LangGraph ReAct agent wired to an toolbox MCP endpoint in Microsoft Foundry. - Demonstrates end-to-end hosted deployment via azd: toolbox provisioning, - container build, and agent deployment with platform-injected toolbox credentials. -metadata: - tags: - - AI Agent Hosting - - LangGraph - - Toolbox - - MCP - - Microsoft Foundry -template: - name: toolbox-azd - kind: hosted - protocols: - - protocol: responses - version: 1.0.0 - environment_variables: - - name: MODEL_DEPLOYMENT_NAME - value: "{{MODEL_DEPLOYMENT_NAME}}" - - name: TOOLBOX_ENDPOINT - value: "{{TOOLBOX_ENDPOINT}}" -parameters: - properties: [] -resources: - - kind: model - id: gpt-4.1-mini - name: MODEL_DEPLOYMENT_NAME diff --git a/samples/python/toolbox/azd/agent.yaml b/samples/python/toolbox/azd/agent.yaml deleted file mode 100644 index 4c7cd84aa..000000000 --- a/samples/python/toolbox/azd/agent.yaml +++ /dev/null @@ -1,48 +0,0 @@ -kind: hosted -name: toolbox-azd-test -description: LangGraph agent wired for toolbox MCP in Microsoft Foundry. -metadata: - tags: - - AI Agent Hosting - - LangGraph - -# template: contains the ContainerAgent definition (kind: hosted). -# These fields are used to generate src//agent.yaml during init. -template: - kind: hosted - protocols: - - protocol: responses - version: 1.0.0 - environment_variables: - # FOUNDRY_PROJECT_ENDPOINT is injected by the platform β€” do NOT declare it here. - - name: MODEL_DEPLOYMENT_NAME - value: ${MODEL_DEPLOYMENT_NAME=gpt-4.1} - - name: TOOLBOX_ENDPOINT - value: ${TOOLBOX_ENDPOINT} - -# parameters: secret values prompted at init time (or set via azd env). -# azd uppercases the param name to find the env var: github_pat β†’ GITHUB_PAT. -parameters: - github_pat: - secret: true - description: GitHub Personal Access Token (classic ghp_... or fine-grained github_pat_...) - -# resources: connections and toolboxes scaffolded into azure.yaml by azd ai agent init. -resources: - - kind: connection - name: github-mcp-conn - target: https://api.githubcopilot.com/mcp - category: remoteTool - credentials: - type: CustomKeys - keys: - Authorization: "Bearer {{ github_pat }}" - - - kind: toolbox - name: agent-tools - tools: - - type: web_search - - type: mcp - server_label: github - server_url: https://api.githubcopilot.com/mcp - project_connection_id: github-mcp-conn diff --git a/samples/python/toolbox/azd/azd-samples/a2a/.ci-skip b/samples/python/toolbox/azd/azd-samples/a2a/.ci-skip deleted file mode 100644 index 8b1378917..000000000 --- a/samples/python/toolbox/azd/azd-samples/a2a/.ci-skip +++ /dev/null @@ -1 +0,0 @@ - diff --git a/samples/python/toolbox/azd/azd-samples/a2a/agent.manifest.yaml b/samples/python/toolbox/azd/azd-samples/a2a/agent.manifest.yaml deleted file mode 100644 index 40e3a4db5..000000000 --- a/samples/python/toolbox/azd/azd-samples/a2a/agent.manifest.yaml +++ /dev/null @@ -1,45 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml -name: toolbox-azd-a2a -displayName: "LangGraph Agent-to-Agent (A2A) Toolbox Agent" -description: > - LangGraph ReAct agent with an Agent-to-Agent (A2A) toolbox. Calls a remote - A2A-compatible agent endpoint via toolbox proxy in Microsoft Foundry. -metadata: - tags: - - AI Agent Hosting - - LangGraph - - A2A - - Microsoft Foundry -template: - name: toolbox-azd - kind: hosted - protocols: - - protocol: responses - version: 1.0.0 - environment_variables: - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} - resources: - cpu: "0.25" - memory: 0.5Gi -parameters: - properties: - - name: a2a_agent_endpoint - secret: false - description: URL of the remote A2A-compatible agent endpoint (e.g. https://my-agent.azurecontainerapps.io) -resources: - - kind: model - id: gpt-4o - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - - kind: connection - name: a2a-conn - category: RemoteA2A - authType: None - target: "{{ a2a_agent_endpoint }}" - - kind: toolbox - name: agent-tools - tools: - - type: a2a_preview - project_connection_id: a2a-conn diff --git a/samples/python/toolbox/azd/azd-samples/a2a/agent.yaml b/samples/python/toolbox/azd/azd-samples/a2a/agent.yaml deleted file mode 100644 index 6e2c9f853..000000000 --- a/samples/python/toolbox/azd/azd-samples/a2a/agent.yaml +++ /dev/null @@ -1,21 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml -# This file lives in src//agent.yaml inside the deployed container. -# It is the RUNTIME definition only Β— no parameters or resources. -# The init-time configuration (connections, toolboxes, parameters to prompt) lives in agent.manifest.yaml. -kind: hosted -name: toolbox-azd -description: LangGraph ReAct agent wired to an toolbox in Microsoft Foundry via MCP. -metadata: - tags: - - AI Agent Hosting - - LangGraph -protocols: - - protocol: responses - version: 1.0.0 -environment_variables: - # FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_AGENT_TOOLBOX_* are injected - # automatically by the platform at runtime Β— do NOT declare them here. - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o} - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} \ No newline at end of file diff --git a/samples/python/toolbox/azd/azd-samples/ai-search/.ci-skip b/samples/python/toolbox/azd/azd-samples/ai-search/.ci-skip deleted file mode 100644 index e69de29bb..000000000 diff --git a/samples/python/toolbox/azd/azd-samples/ai-search/agent.manifest.yaml b/samples/python/toolbox/azd/azd-samples/ai-search/agent.manifest.yaml deleted file mode 100644 index 2b5bc17ef..000000000 --- a/samples/python/toolbox/azd/azd-samples/ai-search/agent.manifest.yaml +++ /dev/null @@ -1,55 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml -name: toolbox-azd-ai-search -displayName: "LangGraph Azure AI Search Toolbox Agent" -description: > - LangGraph ReAct agent with an Azure AI Search toolbox. Queries an existing - search index via toolbox proxy in Microsoft Foundry. -metadata: - tags: - - AI Agent Hosting - - LangGraph - - Azure AI Search - - Microsoft Foundry -template: - name: toolbox-azd - kind: hosted - protocols: - - protocol: responses - version: 1.0.0 - environment_variables: - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} - resources: - cpu: "0.25" - memory: 0.5Gi -parameters: - properties: - - name: ai_search_endpoint - secret: false - description: Azure AI Search service endpoint (e.g. https://my-search.search.windows.net/) - - name: ai_search_key - secret: true - description: Azure AI Search admin key - - name: ai_search_index_name - secret: false - description: Name of the search index to query -resources: - - kind: model - id: gpt-4o - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - - kind: connection - name: aisearch-conn - category: CognitiveSearch - authType: ApiKey - target: "{{ ai_search_endpoint }}" - credentials: - type: ApiKey - key: "{{ ai_search_key }}" - - kind: toolbox - name: agent-tools - tools: - - type: azure_ai_search - index_name: "{{ ai_search_index_name }}" - project_connection_id: aisearch-conn diff --git a/samples/python/toolbox/azd/azd-samples/ai-search/agent.yaml b/samples/python/toolbox/azd/azd-samples/ai-search/agent.yaml deleted file mode 100644 index 6e2c9f853..000000000 --- a/samples/python/toolbox/azd/azd-samples/ai-search/agent.yaml +++ /dev/null @@ -1,21 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml -# This file lives in src//agent.yaml inside the deployed container. -# It is the RUNTIME definition only Β— no parameters or resources. -# The init-time configuration (connections, toolboxes, parameters to prompt) lives in agent.manifest.yaml. -kind: hosted -name: toolbox-azd -description: LangGraph ReAct agent wired to an toolbox in Microsoft Foundry via MCP. -metadata: - tags: - - AI Agent Hosting - - LangGraph -protocols: - - protocol: responses - version: 1.0.0 -environment_variables: - # FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_AGENT_TOOLBOX_* are injected - # automatically by the platform at runtime Β— do NOT declare them here. - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o} - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} \ No newline at end of file diff --git a/samples/python/toolbox/azd/azd-samples/bing-custom-search/.ci-skip b/samples/python/toolbox/azd/azd-samples/bing-custom-search/.ci-skip deleted file mode 100644 index e69de29bb..000000000 diff --git a/samples/python/toolbox/azd/azd-samples/bing-custom-search/agent.manifest.yaml b/samples/python/toolbox/azd/azd-samples/bing-custom-search/agent.manifest.yaml deleted file mode 100644 index 7cd579e9a..000000000 --- a/samples/python/toolbox/azd/azd-samples/bing-custom-search/agent.manifest.yaml +++ /dev/null @@ -1,59 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml -name: toolbox-azd-bing-custom-search -displayName: "LangGraph Bing Custom Search Toolbox Agent" -description: > - LangGraph ReAct agent with a Bing Custom Search toolbox. Uses a - GroundingWithCustomSearch connection for scoped web search. -metadata: - tags: - - AI Agent Hosting - - LangGraph - - Bing Custom Search - - Microsoft Foundry -template: - name: toolbox-azd - kind: hosted - protocols: - - protocol: responses - version: 1.0.0 - environment_variables: - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} - resources: - cpu: "0.25" - memory: 0.5Gi -parameters: - properties: - - name: bing_api_key - secret: true - description: Bing Search API key - - name: bing_resource_id - secret: false - description: ARM resource ID of your Bing account - - name: bing_custom_instance - secret: false - description: Bing Custom Search instance name -resources: - - kind: model - id: gpt-4o - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - - kind: connection - name: bing-custom-conn - category: GroundingWithCustomSearch - authType: ApiKey - target: https://api.bing.microsoft.com/ - credentials: - type: ApiKey - key: "{{ bing_api_key }}" - metadata: - ResourceId: "{{ bing_resource_id }}" - type: bing_custom_search_preview - - kind: toolbox - name: agent-tools - tools: - - type: web_search - custom_search_configuration: - instance_name: "{{ bing_custom_instance }}" - project_connection_id: bing-custom-conn diff --git a/samples/python/toolbox/azd/azd-samples/bing-custom-search/agent.yaml b/samples/python/toolbox/azd/azd-samples/bing-custom-search/agent.yaml deleted file mode 100644 index 6e2c9f853..000000000 --- a/samples/python/toolbox/azd/azd-samples/bing-custom-search/agent.yaml +++ /dev/null @@ -1,21 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml -# This file lives in src//agent.yaml inside the deployed container. -# It is the RUNTIME definition only Β— no parameters or resources. -# The init-time configuration (connections, toolboxes, parameters to prompt) lives in agent.manifest.yaml. -kind: hosted -name: toolbox-azd -description: LangGraph ReAct agent wired to an toolbox in Microsoft Foundry via MCP. -metadata: - tags: - - AI Agent Hosting - - LangGraph -protocols: - - protocol: responses - version: 1.0.0 -environment_variables: - # FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_AGENT_TOOLBOX_* are injected - # automatically by the platform at runtime Β— do NOT declare them here. - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o} - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} \ No newline at end of file diff --git a/samples/python/toolbox/azd/azd-samples/code-interpreter/agent.manifest.yaml b/samples/python/toolbox/azd/azd-samples/code-interpreter/agent.manifest.yaml deleted file mode 100644 index ce28b2a98..000000000 --- a/samples/python/toolbox/azd/azd-samples/code-interpreter/agent.manifest.yaml +++ /dev/null @@ -1,36 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml -name: toolbox-azd-code-interpreter -displayName: "LangGraph Code Interpreter Toolbox Agent" -description: > - LangGraph ReAct agent with a Code Interpreter toolbox. Executes Python - code in a sandboxed environment via toolbox in Microsoft Foundry. -metadata: - tags: - - AI Agent Hosting - - LangGraph - - Code Interpreter - - Microsoft Foundry -template: - name: toolbox-azd - kind: hosted - protocols: - - protocol: responses - version: 1.0.0 - environment_variables: - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} - resources: - cpu: "0.25" - memory: 0.5Gi -parameters: - properties: [] -resources: - - kind: model - id: gpt-4o - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - - kind: toolbox - name: agent-tools - tools: - - type: code_interpreter diff --git a/samples/python/toolbox/azd/azd-samples/code-interpreter/agent.yaml b/samples/python/toolbox/azd/azd-samples/code-interpreter/agent.yaml deleted file mode 100644 index 6e2c9f853..000000000 --- a/samples/python/toolbox/azd/azd-samples/code-interpreter/agent.yaml +++ /dev/null @@ -1,21 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml -# This file lives in src//agent.yaml inside the deployed container. -# It is the RUNTIME definition only Β— no parameters or resources. -# The init-time configuration (connections, toolboxes, parameters to prompt) lives in agent.manifest.yaml. -kind: hosted -name: toolbox-azd -description: LangGraph ReAct agent wired to an toolbox in Microsoft Foundry via MCP. -metadata: - tags: - - AI Agent Hosting - - LangGraph -protocols: - - protocol: responses - version: 1.0.0 -environment_variables: - # FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_AGENT_TOOLBOX_* are injected - # automatically by the platform at runtime Β— do NOT declare them here. - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o} - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} \ No newline at end of file diff --git a/samples/python/toolbox/azd/azd-samples/file-search/.ci-skip b/samples/python/toolbox/azd/azd-samples/file-search/.ci-skip deleted file mode 100644 index 8b1378917..000000000 --- a/samples/python/toolbox/azd/azd-samples/file-search/.ci-skip +++ /dev/null @@ -1 +0,0 @@ - diff --git a/samples/python/toolbox/azd/azd-samples/file-search/agent.manifest.yaml b/samples/python/toolbox/azd/azd-samples/file-search/agent.manifest.yaml deleted file mode 100644 index eab09292c..000000000 --- a/samples/python/toolbox/azd/azd-samples/file-search/agent.manifest.yaml +++ /dev/null @@ -1,41 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml -name: toolbox-azd-file-search -displayName: "LangGraph File Search Toolbox Agent" -description: > - LangGraph ReAct agent with a File Search toolbox backed by an Azure AI - Foundry vector store. The vector store must exist in the same project. -metadata: - tags: - - AI Agent Hosting - - LangGraph - - File Search - - Microsoft Foundry -template: - name: toolbox-azd - kind: hosted - protocols: - - protocol: responses - version: 1.0.0 - environment_variables: - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} - resources: - cpu: "0.25" - memory: 0.5Gi -parameters: - properties: - - name: file_search_vector_store_id - secret: false - description: Vector store ID from the same Foundry project (e.g. vs_xxxxxxxxxxxx) -resources: - - kind: model - id: gpt-4o - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - - kind: toolbox - name: agent-tools - tools: - - type: file_search - vector_store_ids: - - "{{ file_search_vector_store_id }}" diff --git a/samples/python/toolbox/azd/azd-samples/file-search/agent.yaml b/samples/python/toolbox/azd/azd-samples/file-search/agent.yaml deleted file mode 100644 index 6e2c9f853..000000000 --- a/samples/python/toolbox/azd/azd-samples/file-search/agent.yaml +++ /dev/null @@ -1,21 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml -# This file lives in src//agent.yaml inside the deployed container. -# It is the RUNTIME definition only Β— no parameters or resources. -# The init-time configuration (connections, toolboxes, parameters to prompt) lives in agent.manifest.yaml. -kind: hosted -name: toolbox-azd -description: LangGraph ReAct agent wired to an toolbox in Microsoft Foundry via MCP. -metadata: - tags: - - AI Agent Hosting - - LangGraph -protocols: - - protocol: responses - version: 1.0.0 -environment_variables: - # FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_AGENT_TOOLBOX_* are injected - # automatically by the platform at runtime Β— do NOT declare them here. - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o} - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} \ No newline at end of file diff --git a/samples/python/toolbox/azd/azd-samples/mcp-agent-identity/.ci-skip b/samples/python/toolbox/azd/azd-samples/mcp-agent-identity/.ci-skip deleted file mode 100644 index 8b1378917..000000000 --- a/samples/python/toolbox/azd/azd-samples/mcp-agent-identity/.ci-skip +++ /dev/null @@ -1 +0,0 @@ - diff --git a/samples/python/toolbox/azd/azd-samples/mcp-agent-identity/agent.manifest.yaml b/samples/python/toolbox/azd/azd-samples/mcp-agent-identity/agent.manifest.yaml deleted file mode 100644 index 15c764ba3..000000000 --- a/samples/python/toolbox/azd/azd-samples/mcp-agent-identity/agent.manifest.yaml +++ /dev/null @@ -1,51 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml -name: toolbox-azd-mcp-agent-identity -displayName: "LangGraph MCP Agent Identity Toolbox Agent" -description: > - LangGraph ReAct agent with an MCP toolbox using Microsoft Foundry's Agentic - Identity (agent managed identity) for Entra ID authentication to the MCP server. -metadata: - tags: - - AI Agent Hosting - - LangGraph - - MCP - - Agentic Identity - - Microsoft Foundry -template: - name: toolbox-azd - kind: hosted - protocols: - - protocol: responses - version: 1.0.0 - environment_variables: - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} - resources: - cpu: "0.25" - memory: 0.5Gi -parameters: - properties: - - name: entra_audience - secret: false - description: Entra ID audience for the target MCP server - - name: mcp_target_url - secret: false - description: URL of the MCP server that accepts agent identity tokens -resources: - - kind: model - id: gpt-4o - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - - kind: connection - name: language-mcp - category: RemoteTool - authType: AgenticIdentity - audience: "{{ entra_audience }}" - target: "{{ mcp_target_url }}" - - kind: toolbox - name: agent-tools - tools: - - type: mcp - server_label: language-mcp - project_connection_id: language-mcp diff --git a/samples/python/toolbox/azd/azd-samples/mcp-agent-identity/agent.yaml b/samples/python/toolbox/azd/azd-samples/mcp-agent-identity/agent.yaml deleted file mode 100644 index 6e2c9f853..000000000 --- a/samples/python/toolbox/azd/azd-samples/mcp-agent-identity/agent.yaml +++ /dev/null @@ -1,21 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml -# This file lives in src//agent.yaml inside the deployed container. -# It is the RUNTIME definition only Β— no parameters or resources. -# The init-time configuration (connections, toolboxes, parameters to prompt) lives in agent.manifest.yaml. -kind: hosted -name: toolbox-azd -description: LangGraph ReAct agent wired to an toolbox in Microsoft Foundry via MCP. -metadata: - tags: - - AI Agent Hosting - - LangGraph -protocols: - - protocol: responses - version: 1.0.0 -environment_variables: - # FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_AGENT_TOOLBOX_* are injected - # automatically by the platform at runtime Β— do NOT declare them here. - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o} - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} \ No newline at end of file diff --git a/samples/python/toolbox/azd/azd-samples/mcp-entra-passthrough/.ci-skip b/samples/python/toolbox/azd/azd-samples/mcp-entra-passthrough/.ci-skip deleted file mode 100644 index 8b1378917..000000000 --- a/samples/python/toolbox/azd/azd-samples/mcp-entra-passthrough/.ci-skip +++ /dev/null @@ -1 +0,0 @@ - diff --git a/samples/python/toolbox/azd/azd-samples/mcp-entra-passthrough/agent.manifest.yaml b/samples/python/toolbox/azd/azd-samples/mcp-entra-passthrough/agent.manifest.yaml deleted file mode 100644 index b74e6e9c9..000000000 --- a/samples/python/toolbox/azd/azd-samples/mcp-entra-passthrough/agent.manifest.yaml +++ /dev/null @@ -1,51 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml -name: toolbox-azd-mcp-entra-passthrough -displayName: "LangGraph MCP Entra Token Passthrough Toolbox Agent" -description: > - LangGraph ReAct agent with an MCP toolbox that uses Entra token passthrough. - Microsoft Foundry proxies the caller's Entra identity to the downstream MCP server. -metadata: - tags: - - AI Agent Hosting - - LangGraph - - MCP - - Entra Token Passthrough - - Microsoft Foundry -template: - name: toolbox-azd - kind: hosted - protocols: - - protocol: responses - version: 1.0.0 - environment_variables: - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} - resources: - cpu: "0.25" - memory: 0.5Gi -parameters: - properties: - - name: entra_audience - secret: false - description: Entra ID audience for the target MCP server - - name: entra_mcp_target - secret: false - description: URL of the MCP server that accepts Entra user tokens -resources: - - kind: model - id: gpt-4o - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - - kind: connection - name: entra-passthrough-conn - category: RemoteTool - authType: UserEntraToken - audience: "{{ entra_audience }}" - target: "{{ entra_mcp_target }}" - - kind: toolbox - name: agent-tools - tools: - - type: mcp - server_label: outlook-mail - project_connection_id: entra-passthrough-conn diff --git a/samples/python/toolbox/azd/azd-samples/mcp-entra-passthrough/agent.yaml b/samples/python/toolbox/azd/azd-samples/mcp-entra-passthrough/agent.yaml deleted file mode 100644 index 6e2c9f853..000000000 --- a/samples/python/toolbox/azd/azd-samples/mcp-entra-passthrough/agent.yaml +++ /dev/null @@ -1,21 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml -# This file lives in src//agent.yaml inside the deployed container. -# It is the RUNTIME definition only Β— no parameters or resources. -# The init-time configuration (connections, toolboxes, parameters to prompt) lives in agent.manifest.yaml. -kind: hosted -name: toolbox-azd -description: LangGraph ReAct agent wired to an toolbox in Microsoft Foundry via MCP. -metadata: - tags: - - AI Agent Hosting - - LangGraph -protocols: - - protocol: responses - version: 1.0.0 -environment_variables: - # FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_AGENT_TOOLBOX_* are injected - # automatically by the platform at runtime Β— do NOT declare them here. - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o} - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} \ No newline at end of file diff --git a/samples/python/toolbox/azd/azd-samples/mcp-keyauth/.ci-skip b/samples/python/toolbox/azd/azd-samples/mcp-keyauth/.ci-skip deleted file mode 100644 index e69de29bb..000000000 diff --git a/samples/python/toolbox/azd/azd-samples/mcp-keyauth/agent.manifest.yaml b/samples/python/toolbox/azd/azd-samples/mcp-keyauth/agent.manifest.yaml deleted file mode 100644 index a90a4851e..000000000 --- a/samples/python/toolbox/azd/azd-samples/mcp-keyauth/agent.manifest.yaml +++ /dev/null @@ -1,51 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml -name: toolbox-azd-mcp-keyauth -displayName: "LangGraph GitHub MCP Key-Auth Toolbox Agent" -description: > - LangGraph ReAct agent with a GitHub MCP toolbox using key-based authentication - (GitHub PAT injected as Bearer token). -metadata: - tags: - - AI Agent Hosting - - LangGraph - - MCP - - GitHub - - Microsoft Foundry -template: - name: toolbox-azd - kind: hosted - protocols: - - protocol: responses - version: 1.0.0 - environment_variables: - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} - resources: - cpu: "0.25" - memory: 0.5Gi -parameters: - properties: - - name: github_pat - secret: true - description: GitHub Personal Access Token (classic ghp_... or fine-grained github_pat_...) -resources: - - kind: model - id: gpt-4o - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - - kind: connection - name: github-mcp-conn - category: RemoteTool - authType: CustomKeys - target: https://api.githubcopilot.com/mcp - credentials: - type: CustomKeys - keys: - Authorization: "Bearer {{ github_pat }}" - - kind: toolbox - name: agent-tools - tools: - - type: mcp - server_label: github - project_connection_id: github-mcp-conn diff --git a/samples/python/toolbox/azd/azd-samples/mcp-keyauth/agent.yaml b/samples/python/toolbox/azd/azd-samples/mcp-keyauth/agent.yaml deleted file mode 100644 index 6e2c9f853..000000000 --- a/samples/python/toolbox/azd/azd-samples/mcp-keyauth/agent.yaml +++ /dev/null @@ -1,21 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml -# This file lives in src//agent.yaml inside the deployed container. -# It is the RUNTIME definition only Β— no parameters or resources. -# The init-time configuration (connections, toolboxes, parameters to prompt) lives in agent.manifest.yaml. -kind: hosted -name: toolbox-azd -description: LangGraph ReAct agent wired to an toolbox in Microsoft Foundry via MCP. -metadata: - tags: - - AI Agent Hosting - - LangGraph -protocols: - - protocol: responses - version: 1.0.0 -environment_variables: - # FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_AGENT_TOOLBOX_* are injected - # automatically by the platform at runtime Β— do NOT declare them here. - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o} - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} \ No newline at end of file diff --git a/samples/python/toolbox/azd/azd-samples/mcp-noauth/.ci-skip b/samples/python/toolbox/azd/azd-samples/mcp-noauth/.ci-skip deleted file mode 100644 index 8b1378917..000000000 --- a/samples/python/toolbox/azd/azd-samples/mcp-noauth/.ci-skip +++ /dev/null @@ -1 +0,0 @@ - diff --git a/samples/python/toolbox/azd/azd-samples/mcp-noauth/agent.manifest.yaml b/samples/python/toolbox/azd/azd-samples/mcp-noauth/agent.manifest.yaml deleted file mode 100644 index 4eee54dbd..000000000 --- a/samples/python/toolbox/azd/azd-samples/mcp-noauth/agent.manifest.yaml +++ /dev/null @@ -1,42 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml -name: toolbox-azd-mcp-noauth -displayName: "LangGraph Public MCP No-Auth Toolbox Agent" -description: > - LangGraph ReAct agent connected to a public MCP server that requires no - authentication. The server URL is proxied by toolbox in Microsoft Foundry. -metadata: - tags: - - AI Agent Hosting - - LangGraph - - MCP - - No-Auth - - Microsoft Foundry -template: - name: toolbox-azd - kind: hosted - protocols: - - protocol: responses - version: 1.0.0 - environment_variables: - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} - resources: - cpu: "0.25" - memory: 0.5Gi -parameters: - properties: - - name: mcp_endpoint - secret: false - description: URL of the public MCP server (e.g. https://gitmcp.io/Azure/azure-rest-api-specs) -resources: - - kind: model - id: gpt-4o - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - - kind: toolbox - name: agent-tools - tools: - - type: mcp - server_label: noauthmcp - server_url: "{{ mcp_endpoint }}" diff --git a/samples/python/toolbox/azd/azd-samples/mcp-noauth/agent.yaml b/samples/python/toolbox/azd/azd-samples/mcp-noauth/agent.yaml deleted file mode 100644 index 6e2c9f853..000000000 --- a/samples/python/toolbox/azd/azd-samples/mcp-noauth/agent.yaml +++ /dev/null @@ -1,21 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml -# This file lives in src//agent.yaml inside the deployed container. -# It is the RUNTIME definition only Β— no parameters or resources. -# The init-time configuration (connections, toolboxes, parameters to prompt) lives in agent.manifest.yaml. -kind: hosted -name: toolbox-azd -description: LangGraph ReAct agent wired to an toolbox in Microsoft Foundry via MCP. -metadata: - tags: - - AI Agent Hosting - - LangGraph -protocols: - - protocol: responses - version: 1.0.0 -environment_variables: - # FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_AGENT_TOOLBOX_* are injected - # automatically by the platform at runtime Β— do NOT declare them here. - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o} - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} \ No newline at end of file diff --git a/samples/python/toolbox/azd/azd-samples/mcp-oauth-custom/.ci-skip b/samples/python/toolbox/azd/azd-samples/mcp-oauth-custom/.ci-skip deleted file mode 100644 index e69de29bb..000000000 diff --git a/samples/python/toolbox/azd/azd-samples/mcp-oauth-custom/agent.manifest.yaml b/samples/python/toolbox/azd/azd-samples/mcp-oauth-custom/agent.manifest.yaml deleted file mode 100644 index c1431c6fd..000000000 --- a/samples/python/toolbox/azd/azd-samples/mcp-oauth-custom/agent.manifest.yaml +++ /dev/null @@ -1,61 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml -name: toolbox-azd-mcp-oauth-custom -displayName: "LangGraph MCP OAuth2 Custom App Registration Toolbox Agent" -description: > - LangGraph ReAct agent with a GitHub MCP toolbox using a bring-your-own - OAuth2 app registration. First invocation triggers a consent flow. -metadata: - tags: - - AI Agent Hosting - - LangGraph - - MCP - - OAuth2 - - GitHub - - Microsoft Foundry -template: - name: toolbox-azd - kind: hosted - protocols: - - protocol: responses - version: 1.0.0 - environment_variables: - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} - resources: - cpu: "0.25" - memory: 0.5Gi -parameters: - properties: - - name: your_client_id - secret: false - description: OAuth2 client ID from your app registration - - name: your_client_secret - secret: true - description: OAuth2 client secret from your app registration -resources: - - kind: model - id: gpt-4o - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - - kind: connection - name: github-oauth-custom-conn - category: RemoteTool - authType: OAuth2 - target: https://api.githubcopilot.com/mcp - credentials: - type: OAuth2 - clientId: "{{ your_client_id }}" - clientSecret: "{{ your_client_secret }}" - authorizationUrl: "https://github.com/login/oauth/authorize" - tokenUrl: "https://github.com/login/oauth/access_token" - refreshUrl: "https://github.com/login/oauth/access_token" - scopes: - - repo - - read:user - - kind: toolbox - name: agent-tools - tools: - - type: mcp - server_label: github - project_connection_id: github-oauth-custom-conn diff --git a/samples/python/toolbox/azd/azd-samples/mcp-oauth-custom/agent.yaml b/samples/python/toolbox/azd/azd-samples/mcp-oauth-custom/agent.yaml deleted file mode 100644 index 6e2c9f853..000000000 --- a/samples/python/toolbox/azd/azd-samples/mcp-oauth-custom/agent.yaml +++ /dev/null @@ -1,21 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml -# This file lives in src//agent.yaml inside the deployed container. -# It is the RUNTIME definition only Β— no parameters or resources. -# The init-time configuration (connections, toolboxes, parameters to prompt) lives in agent.manifest.yaml. -kind: hosted -name: toolbox-azd -description: LangGraph ReAct agent wired to an toolbox in Microsoft Foundry via MCP. -metadata: - tags: - - AI Agent Hosting - - LangGraph -protocols: - - protocol: responses - version: 1.0.0 -environment_variables: - # FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_AGENT_TOOLBOX_* are injected - # automatically by the platform at runtime Β— do NOT declare them here. - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o} - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} \ No newline at end of file diff --git a/samples/python/toolbox/azd/azd-samples/mcp-oauth-managed/.ci-skip b/samples/python/toolbox/azd/azd-samples/mcp-oauth-managed/.ci-skip deleted file mode 100644 index 8b1378917..000000000 --- a/samples/python/toolbox/azd/azd-samples/mcp-oauth-managed/.ci-skip +++ /dev/null @@ -1 +0,0 @@ - diff --git a/samples/python/toolbox/azd/azd-samples/mcp-oauth-managed/agent.manifest.yaml b/samples/python/toolbox/azd/azd-samples/mcp-oauth-managed/agent.manifest.yaml deleted file mode 100644 index 321ae9089..000000000 --- a/samples/python/toolbox/azd/azd-samples/mcp-oauth-managed/agent.manifest.yaml +++ /dev/null @@ -1,52 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml -name: toolbox-azd-mcp-oauth-managed -displayName: "LangGraph MCP OAuth2 Managed Connector Toolbox Agent" -description: > - LangGraph ReAct agent with a GitHub MCP toolbox using Microsoft Foundry's - managed OAuth connector. No client credentials needed β€” Foundry handles - the OAuth app registration. First invocation triggers a consent flow. -metadata: - tags: - - AI Agent Hosting - - LangGraph - - MCP - - OAuth2 - - GitHub - - Managed Connector - - Microsoft Foundry -template: - name: toolbox-azd - kind: hosted - protocols: - - protocol: responses - version: 1.0.0 - environment_variables: - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} - resources: - cpu: "0.25" - memory: 0.5Gi -parameters: - properties: [] -resources: - - kind: model - id: gpt-4o - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - - kind: connection - name: github-oauth-conn - category: RemoteTool - authType: OAuth2 - target: https://api.githubcopilot.com/mcp - connectorName: foundrygithubmcp - credentials: - type: OAuth2 - clientId: managed - clientSecret: managed - - kind: toolbox - name: agent-tools - tools: - - type: mcp - server_label: github - project_connection_id: github-oauth-conn diff --git a/samples/python/toolbox/azd/azd-samples/mcp-oauth-managed/agent.yaml b/samples/python/toolbox/azd/azd-samples/mcp-oauth-managed/agent.yaml deleted file mode 100644 index 6e2c9f853..000000000 --- a/samples/python/toolbox/azd/azd-samples/mcp-oauth-managed/agent.yaml +++ /dev/null @@ -1,21 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml -# This file lives in src//agent.yaml inside the deployed container. -# It is the RUNTIME definition only Β— no parameters or resources. -# The init-time configuration (connections, toolboxes, parameters to prompt) lives in agent.manifest.yaml. -kind: hosted -name: toolbox-azd -description: LangGraph ReAct agent wired to an toolbox in Microsoft Foundry via MCP. -metadata: - tags: - - AI Agent Hosting - - LangGraph -protocols: - - protocol: responses - version: 1.0.0 -environment_variables: - # FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_AGENT_TOOLBOX_* are injected - # automatically by the platform at runtime Β— do NOT declare them here. - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o} - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} \ No newline at end of file diff --git a/samples/python/toolbox/azd/azd-samples/multi-tool/.ci-skip b/samples/python/toolbox/azd/azd-samples/multi-tool/.ci-skip deleted file mode 100644 index e69de29bb..000000000 diff --git a/samples/python/toolbox/azd/azd-samples/multi-tool/agent.manifest.yaml b/samples/python/toolbox/azd/azd-samples/multi-tool/agent.manifest.yaml deleted file mode 100644 index 71536e525..000000000 --- a/samples/python/toolbox/azd/azd-samples/multi-tool/agent.manifest.yaml +++ /dev/null @@ -1,53 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml -name: toolbox-azd-multi-tool -displayName: "LangGraph Multi-Tool Toolbox Agent (Web Search + GitHub MCP)" -description: > - LangGraph ReAct agent with a combined toolbox: Bing web search plus GitHub - MCP tools via key-based auth. Demonstrates multiple tool types in one toolbox. -metadata: - tags: - - AI Agent Hosting - - LangGraph - - MCP - - Web Search - - GitHub - - Microsoft Foundry -template: - name: toolbox-azd - kind: hosted - protocols: - - protocol: responses - version: 1.0.0 - environment_variables: - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} - resources: - cpu: "0.25" - memory: 0.5Gi -parameters: - properties: - - name: github_pat - secret: true - description: GitHub Personal Access Token (classic ghp_... or fine-grained github_pat_...) -resources: - - kind: model - id: gpt-4o - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - - kind: connection - name: github-mcp-conn - category: RemoteTool - authType: CustomKeys - target: https://api.githubcopilot.com/mcp - credentials: - type: CustomKeys - keys: - Authorization: "Bearer {{ github_pat }}" - - kind: toolbox - name: agent-tools - tools: - - type: web_search - - type: mcp - server_label: github - project_connection_id: github-mcp-conn diff --git a/samples/python/toolbox/azd/azd-samples/multi-tool/agent.yaml b/samples/python/toolbox/azd/azd-samples/multi-tool/agent.yaml deleted file mode 100644 index 6e2c9f853..000000000 --- a/samples/python/toolbox/azd/azd-samples/multi-tool/agent.yaml +++ /dev/null @@ -1,21 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml -# This file lives in src//agent.yaml inside the deployed container. -# It is the RUNTIME definition only Β— no parameters or resources. -# The init-time configuration (connections, toolboxes, parameters to prompt) lives in agent.manifest.yaml. -kind: hosted -name: toolbox-azd -description: LangGraph ReAct agent wired to an toolbox in Microsoft Foundry via MCP. -metadata: - tags: - - AI Agent Hosting - - LangGraph -protocols: - - protocol: responses - version: 1.0.0 -environment_variables: - # FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_AGENT_TOOLBOX_* are injected - # automatically by the platform at runtime Β— do NOT declare them here. - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o} - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} \ No newline at end of file diff --git a/samples/python/toolbox/azd/azd-samples/openapi-keyauth/.ci-skip b/samples/python/toolbox/azd/azd-samples/openapi-keyauth/.ci-skip deleted file mode 100644 index e69de29bb..000000000 diff --git a/samples/python/toolbox/azd/azd-samples/openapi-keyauth/agent.manifest.yaml b/samples/python/toolbox/azd/azd-samples/openapi-keyauth/agent.manifest.yaml deleted file mode 100644 index 86b9f0de0..000000000 --- a/samples/python/toolbox/azd/azd-samples/openapi-keyauth/agent.manifest.yaml +++ /dev/null @@ -1,79 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml -name: toolbox-azd-openapi-keyauth -displayName: "LangGraph OpenAPI Key-Auth Toolbox Agent" -description: > - LangGraph ReAct agent with an OpenAPI toolbox using key-based auth. - Uses TripAdvisor Content API as an example β€” replace the spec and - connection with your own OpenAPI service. -metadata: - tags: - - AI Agent Hosting - - LangGraph - - OpenAPI - - Microsoft Foundry -template: - name: toolbox-azd - kind: hosted - protocols: - - protocol: responses - version: 1.0.0 - environment_variables: - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} - resources: - cpu: "0.25" - memory: 0.5Gi -parameters: - properties: - - name: tripadvisor_api_key - secret: true - description: TripAdvisor Content API key (replace with your own OpenAPI service key) -resources: - - kind: model - id: gpt-4o - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - - kind: connection - name: tripadvisor-conn - category: CustomKeys - authType: CustomKeys - target: https://api.content.tripadvisor.com - credentials: - type: CustomKeys - keys: - key: "{{ tripadvisor_api_key }}" - - kind: toolbox - name: agent-tools - tools: - - type: openapi - openapi: - name: tripadvisor - spec: - openapi: "3.0.1" - info: - title: "TripAdvisor API" - version: "1.0" - servers: - - url: https://api.content.tripadvisor.com/api/v1 - paths: - /location/search: - get: - operationId: searchLocations - parameters: - - name: searchQuery - in: query - required: true - schema: - type: string - - name: key - in: query - required: true - schema: - type: string - responses: - "200": - description: OK - auth: - type: connection_auth - connection_id: tripadvisor-conn diff --git a/samples/python/toolbox/azd/azd-samples/openapi-keyauth/agent.yaml b/samples/python/toolbox/azd/azd-samples/openapi-keyauth/agent.yaml deleted file mode 100644 index 6e2c9f853..000000000 --- a/samples/python/toolbox/azd/azd-samples/openapi-keyauth/agent.yaml +++ /dev/null @@ -1,21 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml -# This file lives in src//agent.yaml inside the deployed container. -# It is the RUNTIME definition only Β— no parameters or resources. -# The init-time configuration (connections, toolboxes, parameters to prompt) lives in agent.manifest.yaml. -kind: hosted -name: toolbox-azd -description: LangGraph ReAct agent wired to an toolbox in Microsoft Foundry via MCP. -metadata: - tags: - - AI Agent Hosting - - LangGraph -protocols: - - protocol: responses - version: 1.0.0 -environment_variables: - # FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_AGENT_TOOLBOX_* are injected - # automatically by the platform at runtime Β— do NOT declare them here. - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o} - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} \ No newline at end of file diff --git a/samples/python/toolbox/azd/azd-samples/web-search/agent.yaml b/samples/python/toolbox/azd/azd-samples/web-search/agent.yaml deleted file mode 100644 index 6e2c9f853..000000000 --- a/samples/python/toolbox/azd/azd-samples/web-search/agent.yaml +++ /dev/null @@ -1,21 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml -# This file lives in src//agent.yaml inside the deployed container. -# It is the RUNTIME definition only Β— no parameters or resources. -# The init-time configuration (connections, toolboxes, parameters to prompt) lives in agent.manifest.yaml. -kind: hosted -name: toolbox-azd -description: LangGraph ReAct agent wired to an toolbox in Microsoft Foundry via MCP. -metadata: - tags: - - AI Agent Hosting - - LangGraph -protocols: - - protocol: responses - version: 1.0.0 -environment_variables: - # FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_AGENT_TOOLBOX_* are injected - # automatically by the platform at runtime Β— do NOT declare them here. - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o} - - name: TOOLBOX_NAME - value: ${TOOLBOX_NAME=agent-tools} \ No newline at end of file diff --git a/samples/python/toolbox/azd/main.py b/samples/python/toolbox/azd/main.py deleted file mode 100644 index 22b7eb5cf..000000000 --- a/samples/python/toolbox/azd/main.py +++ /dev/null @@ -1,372 +0,0 @@ -"""LangGraph ReAct Agent with Azure AI Foundry Toolbox MCP Support. - -This is the default agent for the azd toolbox sample. It uses LangGraph with -langchain-mcp-adapters to connect to an Azure AI Foundry toolbox MCP endpoint. - -Platform-injected environment variables (set automatically at runtime): - - FOUNDRY_PROJECT_ENDPOINT - project endpoint - - FOUNDRY_AGENT_TOOLBOX_ENDPOINT - base URL for toolbox MCP proxy - - FOUNDRY_AGENT_TOOLBOX_FEATURES - feature-flag headers - -User-defined environment variables (declared in agent.manifest.yaml): - - MODEL_DEPLOYMENT_NAME - model deployment name - - TOOLBOX_ENDPOINT - full toolbox MCP endpoint URL - -Usage:: - - # Set required environment variables - export FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ - export MODEL_DEPLOYMENT_NAME=gpt-4.1 - export TOOLBOX_ENDPOINT=https://.services.ai.azure.com/api/projects//toolboxes//mcp?api-version=v1 - - # Start the agent - python main.py - - # Invoke - curl -X POST http://localhost:8088/responses \\ - -H "Content-Type: application/json" \\ - -d '{"input": "What tools do you have?"}' -""" - -import asyncio -import logging -import os -from urllib.parse import urlparse as _urlparse - -import httpx - -os.environ["ENABLE_APPLICATION_INSIGHTS_LOGGER"] = "false" - -# ── Tracing ─────────────────────────────────────────────────────────────────── -import setup -setup.setup() - -from langchain_azure_ai.callbacks.tracers import enable_auto_tracing -enable_auto_tracing( - enable_content_recording=True, - trace_all_langgraph_nodes=True, - provider_name="azure_openai", - auto_configure_azure_monitor=False, -) - -logger = logging.getLogger(__name__) - -from langchain_openai import AzureChatOpenAI -from langchain_core.tools import tool -from langgraph.prebuilt import create_react_agent -from azure.ai.agentserver.responses import ( - ResponseContext, - ResponseEventStream, - ResponsesAgentServerHost, - ResponsesServerOptions, - get_input_expanded, -) -from azure.ai.agentserver.responses.models import CreateResponse -from azure.identity import DefaultAzureCredential, get_bearer_token_provider -from langchain_mcp_adapters.client import MultiServerMCPClient - -# ── Configuration ───────────────────────────────────────────────────────────── - -PROJECT_ENDPOINT = os.getenv("FOUNDRY_PROJECT_ENDPOINT", "") -if not PROJECT_ENDPOINT: - raise ValueError("FOUNDRY_PROJECT_ENDPOINT must be set") - -MODEL_DEPLOYMENT_NAME = os.getenv("MODEL_DEPLOYMENT_NAME", "") -if not MODEL_DEPLOYMENT_NAME: - raise ValueError("MODEL_DEPLOYMENT_NAME environment variable must be set") - -# Derive Azure OpenAI endpoint from the project endpoint (strip /api/projects/...) -_parsed = _urlparse(PROJECT_ENDPOINT) -azure_openai_endpoint = f"{_parsed.scheme}://{_parsed.netloc}" - -token_provider = get_bearer_token_provider( - DefaultAzureCredential(), - "https://ai.azure.com/.default", -) - -llm = AzureChatOpenAI( - model=MODEL_DEPLOYMENT_NAME, - azure_endpoint=azure_openai_endpoint, - azure_ad_token_provider=token_provider, - api_version=os.environ.get("OPENAI_API_VERSION", "2025-03-01-preview"), -) - -# ── Toolbox MCP endpoint resolution ────────────────────────────────────────── - -# TOOLBOX_ENDPOINT is the full pre-constructed MCP URL including toolbox name -# and api-version. Declared in agent.manifest.yaml. -TOOLBOX_ENDPOINT = os.getenv("TOOLBOX_ENDPOINT", "") - -# Feature-flag header value (e.g. "Toolboxes=V1Preview"). -_TOOLBOX_FEATURES = os.getenv("FOUNDRY_AGENT_TOOLBOX_FEATURES", "Toolboxes=V1Preview") - -# ── Toolbox MCP auth ────────────────────────────────────────────────────────── - -class _ToolboxAuth(httpx.Auth): - """httpx Auth that injects a fresh bearer token on every request. - - Uses ``get_bearer_token_provider`` so the underlying credential handles - caching and proactive token refresh automatically. - """ - - def __init__(self, token_provider): - self._get_token = token_provider - - def auth_flow(self, request): - request.headers["Authorization"] = f"Bearer {self._get_token()}" - yield request - - -# ── Agent ───────────────────────────────────────────────────────────────────── - -SYSTEM_PROMPT = """You are a helpful assistant with access to Azure AI Foundry toolbox tools. - -Use the available tools to help answer user questions accurately and concisely. - -When tool output includes Azure AI Search retrieval metadata, treat -result.structuredContent.documents[] as the citation source. - -For citations, prefer these document fields: -- title -- url -- score - -If citations are present, include a short Sources section. Do not invent -citations when metadata is not available. -""" - - -def create_agent(model, tools): - return create_react_agent(model, tools, prompt=SYSTEM_PROMPT) - - -async def quickstart(): - """Build and return a LangGraph agent wired to an MCP client. - - Connects to the Azure AI Foundry toolbox MCP endpoint specified in - TOOLBOX_ENDPOINT. - - When the toolbox requires OAuth consent (e.g. GitHub OAuth connections), - the MCP server responds with error code -32006 and the consent URL as the - message. This function detects that scenario, logs the URL, and returns - an agent with a fallback tool that surfaces the consent URL to the caller. - """ - if not TOOLBOX_ENDPOINT: - raise ValueError( - "TOOLBOX_ENDPOINT must be set. Declare it in agent.manifest.yaml " - "or set it directly for local dev." - ) - - logger.info("Connecting to toolbox: %s", TOOLBOX_ENDPOINT) - credential = DefaultAzureCredential() - tp = get_bearer_token_provider(credential, "https://ai.azure.com/.default") - toolbox_auth = _ToolboxAuth(tp) - extra_headers = {"Foundry-Features": _TOOLBOX_FEATURES} if _TOOLBOX_FEATURES else {} - - client = MultiServerMCPClient( - { - "toolbox": { - "url": TOOLBOX_ENDPOINT, - "transport": "streamable_http", - "headers": extra_headers, - "auth": toolbox_auth, - } - } - ) - - try: - tools = await client.get_tools() - except BaseException as exc: - # OAuth consent required - MCP error code -32006 - if _is_consent_error(exc): - consent_url = _extract_consent_url(exc) - logger.warning( - "OAuth consent required. Open the following URL in a browser " - "to authorize, then restart the agent:\n\n %s\n", - consent_url, - ) - - @tool - def oauth_consent_required(query: str) -> str: - """Return instructions for completing OAuth consent.""" - return ( - f"OAuth consent is required before this agent's tools can " - f"be used. Please open the following URL in a browser to " - f"authorize access, then try again:\n\n {consent_url}" - ) - return create_agent(llm, [oauth_consent_required]), client - raise - - # Enable error handling so tool-call failures don't crash the conversation. - for t in tools: - t.handle_tool_error = True - - # Sanitize tool schemas - some MCP servers return malformed schemas. - for t in tools: - schema = t.args_schema if isinstance(t.args_schema, dict) else None - if schema is None: - continue - if schema.get("type") == "object" and "properties" not in schema: - schema["properties"] = {} - props = schema.get("properties", {}) - required = schema.get("required", []) - if required and not props: - for field_name in required: - props[field_name] = {"type": "string"} - schema["properties"] = props - - logger.info("Loaded %d tools from MCP", len(tools)) - return create_agent(llm, tools), client - - -def _extract_assistant_text(result: dict) -> str: - """Best-effort extraction of assistant text from a LangGraph response.""" - messages = result.get("messages", []) if isinstance(result, dict) else [] - for msg in reversed(messages): - msg_type = getattr(msg, "type", "") - if msg_type != "ai": - continue - content = getattr(msg, "content", "") - if isinstance(content, str): - return content - if isinstance(content, list): - parts: list[str] = [] - for item in content: - if isinstance(item, str): - parts.append(item) - elif isinstance(item, dict) and isinstance(item.get("text"), str): - parts.append(item["text"]) - if parts: - return "\n".join(parts) - return "" - - -_CONSENT_ERROR_CODE = -32006 - - -def _is_consent_error(exc: BaseException) -> bool: - """Return True if *exc* (or any nested sub-exception) is an MCP consent-URL error.""" - error_data = getattr(exc, "error", None) - if error_data is not None and getattr(error_data, "code", None) == _CONSENT_ERROR_CODE: - return True - if "consent.azure-apim.net" in str(exc): - return True - if hasattr(exc, "exceptions"): - return any(_is_consent_error(sub) for sub in exc.exceptions) - return False - - -def _extract_consent_url(exc: BaseException) -> str: - """Walk nested exceptions and return the consent URL string.""" - error_data = getattr(exc, "error", None) - if error_data is not None and getattr(error_data, "code", None) == _CONSENT_ERROR_CODE: - return getattr(error_data, "message", str(exc)) - msg = str(exc) - if "consent.azure-apim.net" in msg: - return msg - if hasattr(exc, "exceptions"): - for sub in exc.exceptions: - url = _extract_consent_url(sub) - if url: - return url - return str(exc) - - -def _get_input_text(request: CreateResponse) -> str | None: - """Extract plain text from a CreateResponse input.""" - inp = request.input - if isinstance(inp, str): - return inp - items = get_input_expanded(request) - for item in items: - content = getattr(item, "content", None) - if content is None: - continue - if isinstance(content, str): - return content - if isinstance(content, list): - for part in content: - text = getattr(part, "text", None) - if text: - return text - return None - - -# ── Server ──────────────────────────────────────────────────────────────────── - -server = ResponsesAgentServerHost( - options=ResponsesServerOptions(default_fetch_history_count=20), -) - -_agent = None -_mcp_client = None # Keep MCP client alive to prevent session GC -_agent_lock = asyncio.Lock() - - -async def _get_agent(): - global _agent, _mcp_client - if _agent is not None: - return _agent - async with _agent_lock: - if _agent is not None: - return _agent - _agent, _mcp_client = await quickstart() - return _agent - - -@server.response_handler -async def handle_response( - request: CreateResponse, - context: ResponseContext, - cancellation_signal: asyncio.Event, -): - stream = ResponseEventStream( - response_id=context.response_id, - model=getattr(request, "model", None), - ) - - yield stream.emit_created() - yield stream.emit_in_progress() - - user_input = _get_input_text(request) or "" - if not user_input: - message_item = stream.add_output_item_message() - yield message_item.emit_added() - for event in message_item.text_content("No input provided."): - yield event - yield message_item.emit_done() - yield stream.emit_completed() - return - - try: - agent = await _get_agent() - result = await asyncio.wait_for( - agent.ainvoke({"messages": [("user", user_input)]}), - timeout=240.0, - ) - assistant_reply = _extract_assistant_text(result) - if not assistant_reply: - assistant_reply = "(Agent completed without text response)" - except asyncio.TimeoutError: - assistant_reply = "I could not complete this request within the local timeout. Please retry with a simpler prompt." - except asyncio.CancelledError: - assistant_reply = "The request was cancelled before completion. Please retry." - except Exception as e: - logger.error("Failed to process request: %s", e, exc_info=True) - assistant_reply = f"I encountered an error processing your request: {e}" - - message_item = stream.add_output_item_message() - yield message_item.emit_added() - - text_content = message_item.add_text_content() - yield text_content.emit_added() - yield text_content.emit_delta(assistant_reply) - yield text_content.emit_text_done() - yield text_content.emit_done() - yield message_item.emit_done() - - yield stream.emit_completed() - - -if __name__ == "__main__": - server.run() diff --git a/samples/python/toolbox/azd/requirements.txt b/samples/python/toolbox/azd/requirements.txt deleted file mode 100644 index 783af6419..000000000 --- a/samples/python/toolbox/azd/requirements.txt +++ /dev/null @@ -1,12 +0,0 @@ -azure-ai-agentserver-core[tracing]==2.0.0b2 -azure-ai-agentserver-responses==1.0.0b4 -langchain-openai>=1.1.14 -langgraph>=1.1.8 -langchain-mcp-adapters>=0.2.2 -langchain-azure-ai[opentelemetry]>=1.2.2 -azure-ai-projects>=1.0.0b10 -python-dotenv==1.1.1 -starlette<1.0.0 -aiohttp -# Pin to avoid Azure SDK feed 401 on pydantic-core>=2.46.0 downloads -pydantic-core<2.46.0 diff --git a/samples/python/toolbox/azd/setup.py b/samples/python/toolbox/azd/setup.py deleted file mode 100644 index 946a14553..000000000 --- a/samples/python/toolbox/azd/setup.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Telemetry bootstrap β€” import before any LangChain/LangGraph code.""" - -import logging -import os - -from dotenv import load_dotenv - -load_dotenv(override=False) - -logger = logging.getLogger(__name__) - - -def setup(): - """Discover App Insights connection string. - - Must be called before AgentHost init so the connection string is - in the environment when TracingHelper configures the exporter. - """ - conn_str = os.environ.get("APPLICATIONINSIGHTS_CONNECTION_STRING") - - if not conn_str: - try: - from azure.ai.projects import AIProjectClient - from azure.identity import DefaultAzureCredential - endpoint = ( - os.environ.get("FOUNDRY_PROJECT_ENDPOINT") - or os.environ.get("AZURE_AI_PROJECT_ENDPOINT") - ) - if endpoint: - client = AIProjectClient(credential=DefaultAzureCredential(), endpoint=endpoint) - conn_str = client.telemetry.get_application_insights_connection_string() - if conn_str: - os.environ["APPLICATIONINSIGHTS_CONNECTION_STRING"] = conn_str - logger.info("Discovered App Insights connection string") - except Exception as e: - logger.warning("App Insights discovery failed: %s", e) - - return conn_str diff --git a/samples/python/toolbox/copilot-sdk/.dockerignore b/samples/python/toolbox/copilot-sdk/.dockerignore deleted file mode 100644 index dcd44cfdf..000000000 --- a/samples/python/toolbox/copilot-sdk/.dockerignore +++ /dev/null @@ -1,4 +0,0 @@ -.git -.env -__pycache__ -*.pyc diff --git a/samples/python/toolbox/copilot-sdk/.env.example b/samples/python/toolbox/copilot-sdk/.env.example deleted file mode 100644 index 637041891..000000000 --- a/samples/python/toolbox/copilot-sdk/.env.example +++ /dev/null @@ -1,14 +0,0 @@ -# Foundry project endpoint β€” auto-injected in hosted containers. -# Only set manually if running without `azd ai agent run`. -# FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ - -# GitHub token for Copilot authentication (required) -# Create a fine-grained PAT at: https://github.com/settings/personal-access-tokens/new -# -> Account permissions -> Copilot Requests -> Read-only -GITHUB_TOKEN= - -# Optional: override the Copilot model -# GITHUB_COPILOT_MODEL= - -# Toolbox MCP endpoint β€” full URL including toolbox name and api-version. -# TOOLBOX_ENDPOINT=https://.services.ai.azure.com/api/projects//toolboxes//mcp?api-version=v1 diff --git a/samples/python/toolbox/copilot-sdk/Dockerfile b/samples/python/toolbox/copilot-sdk/Dockerfile deleted file mode 100644 index d33f61033..000000000 --- a/samples/python/toolbox/copilot-sdk/Dockerfile +++ /dev/null @@ -1,7 +0,0 @@ -FROM mcr.microsoft.com/azurelinux/base/python:3.12 -WORKDIR /app -COPY . user_agent/ -WORKDIR /app/user_agent -RUN if [ -f requirements.txt ]; then pip install --pre -r requirements.txt; fi -EXPOSE 8088 -CMD ["python3", "main.py"] diff --git a/samples/python/toolbox/copilot-sdk/_telemetry.py b/samples/python/toolbox/copilot-sdk/_telemetry.py deleted file mode 100644 index 85d30e1ec..000000000 --- a/samples/python/toolbox/copilot-sdk/_telemetry.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Azure Monitor configuration. - -Disables the built-in Application Insights logger on import so that -FoundryCBAgent.init_tracing() does not emit duplicate telemetry. -Must be imported before the server starts. -""" - -import os - -os.environ["ENABLE_APPLICATION_INSIGHTS_LOGGER"] = "false" diff --git a/samples/python/toolbox/copilot-sdk/agent.py b/samples/python/toolbox/copilot-sdk/agent.py deleted file mode 100644 index b53973b93..000000000 --- a/samples/python/toolbox/copilot-sdk/agent.py +++ /dev/null @@ -1,508 +0,0 @@ -"""CopilotToolboxAgent β€” Copilot SDK agent with toolbox MCP in Foundry tools. - -Bridges the Copilot SDK with toolbox in Foundry by: -1. Connecting to the toolbox MCP endpoint via HTTP (JSON-RPC) -2. Discovering available tools via tools/list -3. Creating Copilot SDK Tool wrappers that forward calls to the MCP endpoint -4. Passing the tools to create_session for the agent to use -""" - -import asyncio -import json -import logging -import os -import subprocess -import sys -from types import SimpleNamespace -from typing import Any - -import httpx -from copilot import CopilotClient, SubprocessConfig -from copilot.tools import Tool, ToolInvocation, ToolResult -from copilot.generated.session_events import SessionEvent, SessionEventType - -logger = logging.getLogger("copilot_toolbox_agent") - - -def _approve_all(request, context): - """Auto-approve all permission requests (no interactive user in container).""" - return {"kind": "approved"} - - -def _get_toolbox_token() -> str: - """Get bearer token for the toolbox MCP endpoint (scope: https://ai.azure.com/.default).""" - try: - from azure.identity import DefaultAzureCredential - credential = DefaultAzureCredential() - token = credential.get_token("https://ai.azure.com/.default") - return token.token - except Exception: - az_cmd = "az.cmd" if sys.platform == "win32" else "az" - result = subprocess.run( - [az_cmd, "account", "get-access-token", "--resource", "https://ai.azure.com", - "--query", "accessToken", "-o", "tsv"], - capture_output=True, text=True, timeout=30, - ) - if result.returncode != 0: - raise RuntimeError(f"Failed to get token: {result.stderr}") - return result.stdout.strip() - - -def _get_toolbox_headers(token: str) -> dict: - """Get required headers for toolbox MCP calls.""" - return { - "Content-Type": "application/json", - "Authorization": f"Bearer {token}", - "Foundry-Features": "Toolboxes=V1Preview", - } - - -# ── MCP Bridge ────────────────────────────────────────────────────────────── - - -class McpBridge: - """HTTP-based MCP client that connects to a toolbox MCP endpoint in Foundry.""" - - def __init__(self, endpoint: str, token: str): - self.endpoint = endpoint - self.headers = _get_toolbox_headers(token) - self._session_id: str | None = None - self._client = httpx.AsyncClient(timeout=60.0) - self._req_id = 0 - - def _next_id(self) -> int: - self._req_id += 1 - return self._req_id - - async def initialize(self) -> str: - """Send MCP initialize + notifications/initialized.""" - resp = await self._client.post( - self.endpoint, headers=self.headers, - json={ - "jsonrpc": "2.0", "id": self._next_id(), "method": "initialize", - "params": { - "protocolVersion": "2025-03-26", - "capabilities": {}, - "clientInfo": {"name": "copilot-toolbox-bridge", "version": "1.0.0"}, - }, - }, - ) - resp.raise_for_status() - data = resp.json() - self._session_id = resp.headers.get("mcp-session-id") - - headers = dict(self.headers) - if self._session_id: - headers["mcp-session-id"] = self._session_id - await self._client.post( - self.endpoint, headers=headers, - json={"jsonrpc": "2.0", "method": "notifications/initialized"}, - ) - return data.get("result", {}).get("serverInfo", {}).get("name", "unknown") - - async def list_tools(self) -> list[dict]: - """Call tools/list and return the tools array.""" - headers = dict(self.headers) - if self._session_id: - headers["mcp-session-id"] = self._session_id - resp = await self._client.post( - self.endpoint, headers=headers, - json={"jsonrpc": "2.0", "id": self._next_id(), "method": "tools/list", "params": {}}, - ) - resp.raise_for_status() - return resp.json().get("result", {}).get("tools", []) - - async def call_tool(self, name: str, arguments: dict) -> str: - """Call tools/call and return the text result.""" - headers = dict(self.headers) - if self._session_id: - headers["mcp-session-id"] = self._session_id - resp = await self._client.post( - self.endpoint, headers=headers, - json={ - "jsonrpc": "2.0", "id": self._next_id(), "method": "tools/call", - "params": {"name": name, "arguments": arguments}, - }, - ) - resp.raise_for_status() - result = resp.json().get("result", {}) - return _format_tool_result_with_citations(result) - - async def close(self): - await self._client.aclose() - - -def _sanitize_tool_name(name: str) -> str: - """Copilot SDK rejects tool names with dots β€” replace with underscores.""" - return name.replace(".", "_").replace("-", "_") - - -def _extract_ai_search_citations(result: dict[str, Any]) -> list[dict[str, Any]]: - """Extract citation metadata from Azure AI Search tool outputs. - - Citation pattern is represented as: - result.structuredContent.documents[] - """ - structured = result.get("structuredContent") - if not isinstance(structured, dict): - return [] - - docs = structured.get("documents") - if not isinstance(docs, list): - return [] - - citations: list[dict[str, Any]] = [] - for doc in docs: - if not isinstance(doc, dict): - continue - title = doc.get("title") or doc.get("id") or "source" - url = doc.get("url") - score = doc.get("score") - citations.append( - { - "title": title, - "url": url, - "score": score, - } - ) - return citations - - -def _format_tool_result_with_citations(result: dict[str, Any]) -> str: - """Return tool text output, appending normalized citation metadata when present.""" - content = result.get("content", []) - texts = [c.get("text", "") for c in content if isinstance(c, dict) and c.get("type") == "text"] - base_text = "\n".join(t for t in texts if t).strip() - - citations = _extract_ai_search_citations(result) - if not citations: - if base_text: - return base_text - return json.dumps(result) - - lines = ["", "Sources:"] - for idx, c in enumerate(citations, start=1): - title = c.get("title") or "source" - url = c.get("url") or "" - score = c.get("score") - if score is not None: - lines.append(f"{idx}. {title} (score: {score})") - else: - lines.append(f"{idx}. {title}") - if url: - lines.append(f" {url}") - - citation_block = "\n".join(lines) - if base_text: - return f"{base_text}\n{citation_block}" - return citation_block.lstrip() - - -def _make_copilot_tools(bridge: McpBridge, mcp_tools: list[dict]) -> list[Tool]: - """Convert MCP tool definitions into Copilot SDK Tool objects. - - Tool names are sanitized (dots/hyphens β†’ underscores) because the Copilot - API rejects names with those characters. The original MCP name is kept - for the ``tools/call`` RPC. - """ - tools = [] - for mcp_tool in mcp_tools: - mcp_name = mcp_tool["name"] # original MCP name - sdk_name = _sanitize_tool_name(mcp_name) # safe for Copilot SDK - desc = mcp_tool.get("description", f"MCP tool: {mcp_name}") - schema = mcp_tool.get("inputSchema", {"type": "object", "properties": {}}) - - def _make_handler(original_name): - async def handler(invocation: ToolInvocation) -> ToolResult: - args = invocation.arguments if isinstance(invocation.arguments, dict) else {} - try: - result_text = await bridge.call_tool(original_name, args) - return ToolResult(text_result_for_llm=result_text) - except Exception as e: - logger.warning("Tool %s failed: %s", original_name, e) - return ToolResult(text_result_for_llm="", result_type="error", error=str(e)) - return handler - - tools.append(Tool( - name=sdk_name, - description=desc, - parameters=schema, - handler=_make_handler(mcp_name), - skip_permission=True, - )) - return tools - - -def _make_stream_event_handler(queue: "asyncio.Queue[SimpleNamespace | Exception | None]"): - """Build an event handler that maps Copilot SDK events to queued text chunks. - - Surfaces tool execution, reasoning, and skill events as inline annotations. - """ - active_tools: dict[str, str] = {} - - def _tool_name(event_data) -> str: - return ( - getattr(event_data, "tool_name", None) - or getattr(event_data, "mcp_tool_name", None) - or "tool" - ) - - def handler(event: SessionEvent) -> None: - etype = event.type - - if etype == SessionEventType.ASSISTANT_MESSAGE_DELTA: - if event.data.delta_content: - queue.put_nowait(SimpleNamespace(text=event.data.delta_content)) - - elif etype == SessionEventType.TOOL_EXECUTION_START: - name = _tool_name(event.data) - call_id = getattr(event.data, "tool_call_id", None) - if call_id: - active_tools[call_id] = name - queue.put_nowait(SimpleNamespace( - text=f"\n> Calling `{name}` ...\n", - annotation=True, - )) - elif etype == SessionEventType.TOOL_EXECUTION_PROGRESS: - msg = getattr(event.data, "progress_message", None) - if msg: - queue.put_nowait(SimpleNamespace(text=f"> {msg}\n", annotation=True)) - elif etype == SessionEventType.TOOL_EXECUTION_COMPLETE: - call_id = getattr(event.data, "tool_call_id", None) - name = active_tools.pop(call_id, None) if call_id else None - if not name: - name = _tool_name(event.data) - queue.put_nowait(SimpleNamespace( - text=f"> `{name}` done\n", - annotation=True, - )) - elif etype == SessionEventType.SKILL_INVOKED: - name = getattr(event.data, "tool_name", None) or "skill" - queue.put_nowait(SimpleNamespace( - text=f"\n> Skill: `{name}`\n", - annotation=True, - )) - - elif etype == SessionEventType.ASSISTANT_REASONING_DELTA: - if getattr(event.data, "delta_content", None): - queue.put_nowait(SimpleNamespace( - text=event.data.delta_content, - annotation=True, - )) - - elif etype == SessionEventType.ASSISTANT_TURN_START: - queue.put_nowait(SimpleNamespace( - text="\n> Processing...\n", - annotation=True, - )) - - elif etype == SessionEventType.SESSION_IDLE: - queue.put_nowait(None) - elif etype == SessionEventType.SESSION_ERROR: - queue.put_nowait(RuntimeError(getattr(event.data, "message", None) or "Session error")) - - return handler - - -class CopilotToolboxAgent: - """Wraps CopilotClient with toolbox MCP integration. - - Connects to the toolbox MCP endpoint in Foundry via HTTP, discovers tools, - and registers them as Copilot SDK custom tools. - """ - - def __init__( - self, - *, - skill_directories: list[str] | None = None, - toolbox_endpoint: str | None = None, - ): - self._skill_directories = skill_directories or [] - self._toolbox_endpoint = toolbox_endpoint - self._client: CopilotClient | None = None - self._bridge: McpBridge | None = None - self._toolbox_tools: list[Tool] = [] - self._sessions: dict[str, object] = {} - - async def start(self) -> None: - if self._client is not None: - return - github_token = os.environ.get("GITHUB_TOKEN") - config = SubprocessConfig(github_token=github_token) if github_token else None - self._client = CopilotClient(config, auto_start=False) - await self._client.start() - - # Connect to toolbox MCP and discover tools - if not self._toolbox_endpoint: - raise ValueError( - "Toolbox endpoint is required. Set FOUNDRY_AGENT_TOOLBOX_ENDPOINT " - "(platform-injected) or TOOLBOX_MCP_ENDPOINT (local dev)." - ) - token = _get_toolbox_token() - self._bridge = McpBridge(self._toolbox_endpoint, token) - server_name = await self._bridge.initialize() - mcp_tools = await self._bridge.list_tools() - self._toolbox_tools = _make_copilot_tools(self._bridge, mcp_tools) - logger.info( - "Toolbox '%s' connected: %d tools discovered", - server_name, len(self._toolbox_tools), - ) - - async def stop(self) -> None: - if self._client is not None: - for conv_id, session in list(self._sessions.items()): - try: - await session.disconnect() - except Exception: - logger.debug("Failed to disconnect session for %s", conv_id, exc_info=True) - self._sessions.clear() - await self._client.stop() - self._client = None - if self._bridge is not None: - await self._bridge.close() - self._bridge = None - - def has_session(self, conversation_id: str) -> bool: - return conversation_id in self._sessions - - def _build_session_kwargs(self, streaming: bool) -> dict: - kwargs: dict = { - "streaming": streaming, - "on_permission_request": _approve_all, - } - model = os.environ.get("GITHUB_COPILOT_MODEL") - if model: - kwargs["model"] = model - if self._skill_directories: - kwargs["skill_directories"] = self._skill_directories - - # Register toolbox MCP tools as custom tools - if self._toolbox_tools: - kwargs["tools"] = self._toolbox_tools - - return kwargs - - async def _get_or_create_session( - self, - conversation_id: str, - streaming: bool, - history: str | None = None, - ): - """Session retrieval: hot cache or cold create.""" - assert self._client is not None, "Call start() first" - - # Hot: return cached session - if conversation_id in self._sessions: - logger.debug("Hot session for %s", conversation_id) - return self._sessions[conversation_id] - - # Cold: create new session, optionally bootstrap with history - kwargs = self._build_session_kwargs(streaming) - session = await self._client.create_session(**kwargs) - if history: - logger.info("Bootstrapping session with conversation history for %s", conversation_id) - try: - preamble = ( - "Here is the prior conversation history for context. " - "Do not repeat or summarize it β€” just use it as context " - "for the user's next message.\n\n" + history - ) - await session.send_and_wait(preamble, timeout=120.0) - except Exception: - logger.warning("Failed to bootstrap history", exc_info=True) - - self._sessions[conversation_id] = session - logger.info("Created new session for %s", conversation_id) - return session - - def _evict_session(self, conversation_id: str) -> None: - self._sessions.pop(conversation_id, None) - - async def _run_once( - self, - prompt: str, - *, - conversation_id: str | None = None, - history: str | None = None, - ) -> SimpleNamespace: - assert self._client is not None, "Call start() first" - - if not conversation_id: - kwargs = self._build_session_kwargs(streaming=False) - session = await self._client.create_session(**kwargs) - try: - event = await session.send_and_wait(prompt, timeout=120.0) - text = event.data.content if event else "" - return SimpleNamespace(text=text or "") - finally: - await session.disconnect() - - session = await self._get_or_create_session(conversation_id, streaming=False, history=history) - try: - event = await session.send_and_wait(prompt, timeout=120.0) - text = event.data.content if event else "" - return SimpleNamespace(text=text or "") - except Exception: - logger.exception("Session error for %s, evicting", conversation_id) - self._evict_session(conversation_id) - raise - - async def _stream( - self, - prompt: str, - *, - conversation_id: str | None = None, - history: str | None = None, - ): - assert self._client is not None, "Call start() first" - - if not conversation_id: - kwargs = self._build_session_kwargs(streaming=True) - session = await self._client.create_session(**kwargs) - queue: asyncio.Queue[SimpleNamespace | Exception | None] = asyncio.Queue() - unsubscribe = session.on(_make_stream_event_handler(queue)) - try: - await session.send(prompt) - while True: - item = await queue.get() - if item is None: - break - if isinstance(item, Exception): - raise item - yield item - finally: - unsubscribe() - await session.disconnect() - return - - session = await self._get_or_create_session(conversation_id, streaming=True, history=history) - queue: asyncio.Queue[SimpleNamespace | Exception | None] = asyncio.Queue() - unsubscribe = session.on(_make_stream_event_handler(queue)) - try: - await session.send(prompt) - while True: - item = await queue.get() - if item is None: - break - if isinstance(item, Exception): - raise item - yield item - except Exception: - logger.exception("Streaming error for %s, evicting", conversation_id) - self._evict_session(conversation_id) - raise - finally: - unsubscribe() - - def run( - self, - prompt: str, - *, - stream: bool = False, - conversation_id: str | None = None, - history: str | None = None, - ): - """Return a coroutine (stream=False) or async generator (stream=True).""" - if stream: - return self._stream(prompt, conversation_id=conversation_id, history=history) - return self._run_once(prompt, conversation_id=conversation_id, history=history) diff --git a/samples/python/toolbox/copilot-sdk/agent.yaml.template b/samples/python/toolbox/copilot-sdk/agent.yaml.template deleted file mode 100644 index 8d8af55bc..000000000 --- a/samples/python/toolbox/copilot-sdk/agent.yaml.template +++ /dev/null @@ -1,18 +0,0 @@ -name: {agent_name} -description: > - Copilot SDK agent with Foundry toolbox MCP tools and skills. -metadata: - tags: - - AI Agent Hosting - - Copilot SDK - - Toolbox -template: - name: {agent_name} - kind: hosted - protocols: - - protocol: responses - version: v1 - environment_variables: - # FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_AGENT_TOOLBOX_* are platform-injected. - - name: GITHUB_TOKEN - value: ${{GITHUB_TOKEN}} diff --git a/samples/python/toolbox/copilot-sdk/main.py b/samples/python/toolbox/copilot-sdk/main.py deleted file mode 100644 index 2704313df..000000000 --- a/samples/python/toolbox/copilot-sdk/main.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Copilot SDK + Foundry Toolbox agent. - -Uses the GitHub Copilot SDK (CopilotClient) with Foundry toolbox MCP -tools, served via the Foundry responses protocol. -""" - -import os -import pathlib -import sys - -from dotenv import load_dotenv -load_dotenv(override=False) - -from agent import CopilotToolboxAgent -from server import CopilotFoundryAdapter - - -def _discover_skill_directories() -> list[str]: - """Return the project root if any child folder contains SKILL.md.""" - root = pathlib.Path(__file__).parent - if any(root.glob("*/SKILL.md")): - return [str(root.resolve())] - return [] - - -def _resolve_toolbox_endpoint() -> str | None: - """Return the toolbox MCP endpoint from TOOLBOX_ENDPOINT.""" - return os.environ.get("TOOLBOX_ENDPOINT") - - -def create_agent() -> CopilotToolboxAgent: - return CopilotToolboxAgent( - skill_directories=_discover_skill_directories(), - toolbox_endpoint=_resolve_toolbox_endpoint(), - ) - - -def _resolve_port() -> int | None: - raw = os.environ.get("PORT") - if not raw: - return None - try: - return int(raw) - except ValueError: - print(f"Invalid PORT value {raw!r}; defaulting to framework port.") - return None - - -if __name__ == "__main__": - if not os.environ.get("GITHUB_TOKEN"): - print("Missing GitHub Token. Make sure the .env file has one. See README for details.") - sys.exit(1) - adapter = CopilotFoundryAdapter(create_agent()) - adapter.run(port=_resolve_port()) diff --git a/samples/python/toolbox/copilot-sdk/readme.md b/samples/python/toolbox/copilot-sdk/readme.md deleted file mode 100644 index 8d8c7f92b..000000000 --- a/samples/python/toolbox/copilot-sdk/readme.md +++ /dev/null @@ -1,225 +0,0 @@ - -**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. - - -# Copilot SDK + Toolbox Agent (Responses Protocol) - -This sample deploys a GitHub Copilot SDK agent wired to toolbox in Foundry, -using the **Responses** protocol. It combines the Copilot SDK's skill system with -tools(MCP, OpenAPI, AI Search, Web Search, Code Interpreter, A2A). - -## Features - -- **GitHub Copilot SDK**: Uses `CopilotClient` for AI reasoning, multi-turn sessions, and skill execution -- **toolbox in Foundry MCP**: Connects to a toolbox MCP endpoint, giving the agent access to remote tools -- **Skills + Tools**: Local skill directories and remote toolbox tools are both available in the same session -- **Multi-turn conversations**: Session caching with hot/warm/cold resume for conversation continuity -- **Streaming**: Full SSE streaming support via the Foundry responses protocol - -## How It Works - -1. The agent starts a `CopilotClient` session configured with both skill directories and the toolbox MCP endpoint -2. The Copilot SDK connects to the toolbox MCP server and discovers available tools -3. Skills (from local `SKILL.md` directories) and toolbox tools are both available during conversation -4. The agent is served via the Foundry responses protocol - -## Environment Variables - -| Variable | Required | Description | -|----------|----------|-------------| -| `FOUNDRY_PROJECT_ENDPOINT` | Yes | Foundry project endpoint URL (platform-injected at runtime) | -| `GITHUB_TOKEN` | Yes | GitHub fine-grained PAT with Copilot Requests read permission | -| `FOUNDRY_AGENT_TOOLBOX_ENDPOINT` | Yes | Base URL for toolbox MCP proxy (platform-injected; append `/{name}/mcp?api-version=v1`) | -| `FOUNDRY_AGENT_TOOLBOX_FEATURES` | No | Feature-flag headers for toolbox requests (platform-injected) | -| `TOOLBOX_NAME` | **Yes** | Name of your toolbox resource β€” must match what you created in Foundry (defaults to `agent-tools`) | -| `GITHUB_COPILOT_MODEL` | No | Override the Copilot model | - -## Supported Toolbox Tools - -Shared toolbox tool/auth definitions live in [../SUPPORTED_TOOLBOX_TOOLS.md](../SUPPORTED_TOOLBOX_TOOLS.md). - -For runnable SDK examples of creating toolbox resources, see [../sample_toolboxes_crud.py](../sample_toolboxes_crud.py). - -> **Note:** Tool names from the toolbox MCP endpoint are prefixed with `server_label.` (e.g., `gitmcp.fetch_agent_docs`). The Copilot SDK rejects names containing dots, so `agent.py` automatically sanitizes them (dots/hyphens β†’ underscores) while preserving the original MCP name for `tools/call` forwarding. - ---- - -## Prerequisites - -- Python 3.12+ -- A [Microsoft Foundry](https://ai.azure.com) account and project -- A GitHub account with access to GitHub Copilot -- Azure CLI installed and logged in: - - ```bash - az login - ``` - -## Setting Up - -### 1. Create a GitHub Token - -Go to [github.com/settings/personal-access-tokens/new](https://github.com/settings/personal-access-tokens/new) and create a fine-grained token with: - -- **Account permissions -> Copilot Requests -> Read-only** - -Copy the token (starts with `github_pat_`). - -> **Note:** Classic tokens (`ghp_` prefix) are not supported by the Copilot SDK. You must use a fine-grained PAT (`github_pat_`), OAuth token (`gho_`), or GitHub App user token (`ghu_`). - -### 2. Create a Toolbox (Optional) - -Create a toolbox resource in your Foundry project. See the [LangGraph toolbox sample](../langgraph/) for full documentation on all tool types and [`../sample_toolboxes_crud.py`](../sample_toolboxes_crud.py) for SDK examples. - -Example β€” create a toolbox with a public MCP server: - -```bash -cat > toolbox.json << 'EOF' -{ - "name": "my-toolbox", - "description": "Public MCP server", - "tools": [ - { - "type": "mcp", - "server_label": "mslearn", - "server_url": "https://learn.microsoft.com/api/mcp", - "require_approval": "never" - } - ] -} -EOF - -foundry-agent toolbox create --payload toolbox.json -``` - -### 3. Configure Environment - -Copy `.env.example` to `.env` and fill in the values: - -```bash -cp .env.example .env -``` - -> **Windows (PowerShell):** `Copy-Item .env.example .env` - -```env -FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ -GITHUB_TOKEN=github_pat_... -FOUNDRY_AGENT_TOOLBOX_ENDPOINT=https://.services.ai.azure.com/api/projects//toolboxes -TOOLBOX_NAME=my-toolbox -``` - -### 4. Toolbox Endpoint URL - -The platform injects `FOUNDRY_AGENT_TOOLBOX_ENDPOINT` (base URL) at runtime. The code -appends `/{TOOLBOX_NAME}/mcp?api-version=v1` to form the full MCP proxy URL. - -For local development, set the base URL manually and specify `TOOLBOX_NAME`. - -**`TOOLBOX_NAME` must be set as an environment variable** β€” both locally and when -deployed β€” to match the name of your actual toolbox resource. It defaults to -`agent-tools` if unset. - -## Deploying to Foundry - -Use `azd ai agent init` with this sample's manifest, then set the toolbox name: - -```bash -azd ai agent init \ - -m https://github.com/microsoft/hosted-agents-vnext-private-preview/blob/main/samples/python/toolbox/copilot-sdk/agent.manifest.yaml \ - --project-id /subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//projects/ -``` - -After initialization, set required environment variables: - -```bash -# Enable vNext features (required during private preview) -azd env set enableHostedAgentVNext "true" - -# Your GitHub fine-grained PAT -azd env set GITHUB_TOKEN "github_pat_..." - -# The name of your toolbox resource (MUST match what you created in Foundry) -azd env set TOOLBOX_NAME "my-toolbox" -``` - -Then provision and deploy: - -```bash -azd provision -azd deploy - -# Test the deployed agent -azd ai agent invoke --new-session "What tools do you have?" --timeout 120 -``` - -> **Note:** `FOUNDRY_AGENT_TOOLBOX_ENDPOINT` and `FOUNDRY_PROJECT_ENDPOINT` are -> injected automatically by the platform at deploy time. You do not need to set them. -> Only `GITHUB_TOKEN` and `TOOLBOX_NAME` require manual configuration. - -```bash -# Install dependencies -pip install -r requirements.txt - -# Start the agent -python main.py -``` - -## Adding Skills - -Any directory at the project root containing a `SKILL.md` file is automatically discovered as a skill. The included `greeting/` directory is an example. - -``` -copilot-toolbox/ -β”œβ”€β”€ greeting/ -β”‚ └── SKILL.md <- discovered as a skill -β”œβ”€β”€ my-new-skill/ -β”‚ └── SKILL.md <- add your own skill -└── ... -``` - -## Protocol - -This sample uses the **Responses Protocol** via `azure-ai-agentserver-core`, which provides: - -- OpenAI-compatible `/responses` endpoint -- Streaming SSE support -- Multi-turn conversation with `previous_response_id` - -## Project Structure - -``` -copilot-toolbox/ -β”œβ”€β”€ main.py # Entrypoint β€” skill discovery + agent creation -β”œβ”€β”€ agent.py # CopilotToolboxAgent β€” Copilot SDK + toolbox MCP -β”œβ”€β”€ server.py # Foundry responses protocol adapter -β”œβ”€β”€ _telemetry.py # Azure Monitor / App Insights setup -β”œβ”€β”€ greeting/SKILL.md # Example skill -β”œβ”€β”€ agent.yaml.template # Deployment manifest -β”œβ”€β”€ .env.example # Environment variables template -β”œβ”€β”€ Dockerfile # Container build -β”œβ”€β”€ requirements.txt # Python dependencies -└── .dockerignore # Docker build exclusions -``` - -## Comparison with Other Samples - -| Sample | LLM Engine | Tool Source | Protocol | -|--------|-----------|-------------|----------| -| `skills/` (template) | Copilot SDK | Local skill directories only | Responses | -| `toolbox/` (sample) | Azure OpenAI via LangGraph | Toolbox MCP endpoint | Responses | -| **`copilot-toolbox/`** | **Copilot SDK** | **Toolbox MCP + local skills** | **Responses** | - -## Contributing - -This project welcomes contributions and suggestions. - -## Trademarks - -This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft trademarks or logos is subject to and must follow [Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. Any use of third-party trademarks or logos are subject to those third-party's policies. diff --git a/samples/python/toolbox/copilot-sdk/requirements.txt b/samples/python/toolbox/copilot-sdk/requirements.txt deleted file mode 100644 index a6b9a443b..000000000 --- a/samples/python/toolbox/copilot-sdk/requirements.txt +++ /dev/null @@ -1,11 +0,0 @@ -github-copilot-sdk>=0.1.29 -starlette<1.0.0 -uvicorn[standard] -python-dotenv==1.1.1 -azure-identity>=1.19.0 -azure-monitor-opentelemetry>=1.6.0 -azure-ai-agentserver-core==2.0.0b2 -azure-ai-agentserver-responses==1.0.0b4 -httpx -aiohttp -cachetools diff --git a/samples/python/toolbox/copilot-sdk/server.py b/samples/python/toolbox/copilot-sdk/server.py deleted file mode 100644 index 54b4bab8c..000000000 --- a/samples/python/toolbox/copilot-sdk/server.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Foundry responses protocol adapter β€” uses ResponseHandler + ResponseEventStream.""" - -import asyncio -import logging -from collections.abc import AsyncIterable -from typing import Any - -from dotenv import load_dotenv -load_dotenv(override=False) - -import _telemetry # noqa: F401 β€” App Insights connection string discovery - -from azure.ai.agentserver.core import AgentServerHost as AgentHost -from azure.ai.agentserver.responses import ResponseContext, ResponseEventStream -from azure.ai.agentserver.responses import get_input_expanded, CreateResponse -from azure.ai.agentserver.responses.hosting import ResponsesAgentServerHost as ResponseHandler - -logger = logging.getLogger("foundry_adapter") - - -def _create_server_and_handler(agent): - """Create AgentHost + ResponseHandler wired to the CopilotToolboxAgent.""" - - server = AgentHost() - responses = ResponseHandler() - - @server.shutdown_handler - async def _shutdown(): - logger.info("Stopping CopilotToolboxAgent…") - await agent.stop() - - @responses.response_handler - async def handle_response( - request: CreateResponse, - context: ResponseContext, - cancellation_signal: asyncio.Event, - ) -> AsyncIterable[dict[str, Any]]: - # Lazy-start the agent in the server's event loop (idempotent) - await agent.start() - - text = get_input_expanded(request) or "" - - stream = ResponseEventStream( - response_id=context.response_id, - model=getattr(request, "model", None), - ) - - yield stream.emit_created() - yield stream.emit_in_progress() - - message_item = stream.add_output_item_message() - yield message_item.emit_added() - - text_content = message_item.add_text_content() - yield text_content.emit_added() - - full_text = "" - try: - gen = agent.run(text, stream=True) - async for update in gen: - if cancellation_signal.is_set(): - yield stream.emit_incomplete(reason="cancelled") - return - - chunk = getattr(update, "text", None) - if chunk: - full_text += chunk - yield text_content.emit_delta(chunk) - except Exception as exc: - logger.exception("Agent streaming failed") - error_msg = f"Error: {exc}" - full_text += error_msg - yield text_content.emit_delta(error_msg) - - yield text_content.emit_text_done() - yield text_content.emit_done() - yield message_item.emit_done() - yield stream.emit_completed() - - return server - - -class CopilotFoundryAdapter: - """Bridges CopilotToolboxAgent <-> Foundry responses protocol.""" - - def __init__(self, agent): - self._agent = agent - self._server = _create_server_and_handler(agent) - - def run(self, port: int = None): - if port is not None: - self._server.run(port=port) - else: - self._server.run() diff --git a/samples/python/toolbox/langgraph/.dockerignore b/samples/python/toolbox/langgraph/.dockerignore deleted file mode 100644 index 8ee28d2cf..000000000 --- a/samples/python/toolbox/langgraph/.dockerignore +++ /dev/null @@ -1,29 +0,0 @@ -# Exclude files not needed in the container image -.env -.env.* -.git -.gitignore -.venv -__pycache__ -*.pyc -.agent.log -.agent.pid -.foundry-agent.json -.foundry-agent-build.log -node_modules - -# OS / editor junk -.DS_Store -Thumbs.db -.vscode -.idea - -# Windows user-profile directories (junction points cause access-denied errors) -AppData -Desktop -Documents -Downloads -Music -Pictures -Videos - diff --git a/samples/python/toolbox/langgraph/agent.manifest.yaml b/samples/python/toolbox/langgraph/agent.manifest.yaml deleted file mode 100644 index f0aa01998..000000000 --- a/samples/python/toolbox/langgraph/agent.manifest.yaml +++ /dev/null @@ -1,29 +0,0 @@ -name: toolbox-langgraph -description: > - LangGraph ReAct agent wired to an toolbox MCP endpoint in Microsoft Foundry. - Connects to a hosted toolbox via MCP and exposes its tools through a - LangGraph ReAct loop with Azure OpenAI. -metadata: - tags: - - AI Agent Hosting - - LangGraph - - Toolbox - - MCP - - Microsoft Foundry -template: - name: toolbox-langgraph - kind: hosted - protocols: - - protocol: responses - version: 1.0.0 - environment_variables: - - name: MODEL_DEPLOYMENT_NAME - value: "{{MODEL_DEPLOYMENT_NAME}}" - - name: TOOLBOX_ENDPOINT - value: "{{TOOLBOX_ENDPOINT}}" -parameters: - properties: [] -resources: - - kind: model - id: gpt-4.1-mini - name: MODEL_DEPLOYMENT_NAME diff --git a/samples/python/toolbox/langgraph/agent.yaml b/samples/python/toolbox/langgraph/agent.yaml deleted file mode 100644 index 556846ca5..000000000 --- a/samples/python/toolbox/langgraph/agent.yaml +++ /dev/null @@ -1,22 +0,0 @@ -name: toolbox-langgraph -description: LangGraph agent wired for toolbox MCP in Microsoft Foundry. -metadata: - tags: - - AI Agent Hosting - - LangGraph -template: - name: toolbox-langgraph - kind: hosted - protocols: - - protocol: responses - version: 1.0.0 - environment_variables: - # FOUNDRY_PROJECT_ENDPOINT is injected by the platform β€” do NOT declare it here. - - name: MODEL_DEPLOYMENT_NAME - value: ${MODEL_DEPLOYMENT_NAME=gpt-4.1} - - name: TOOLBOX_ENDPOINT - value: ${TOOLBOX_ENDPOINT} -resources: - - kind: model - id: gpt-4o - name: chat diff --git a/samples/python/toolbox/langgraph/requirements.txt b/samples/python/toolbox/langgraph/requirements.txt deleted file mode 100644 index 783af6419..000000000 --- a/samples/python/toolbox/langgraph/requirements.txt +++ /dev/null @@ -1,12 +0,0 @@ -azure-ai-agentserver-core[tracing]==2.0.0b2 -azure-ai-agentserver-responses==1.0.0b4 -langchain-openai>=1.1.14 -langgraph>=1.1.8 -langchain-mcp-adapters>=0.2.2 -langchain-azure-ai[opentelemetry]>=1.2.2 -azure-ai-projects>=1.0.0b10 -python-dotenv==1.1.1 -starlette<1.0.0 -aiohttp -# Pin to avoid Azure SDK feed 401 on pydantic-core>=2.46.0 downloads -pydantic-core<2.46.0 diff --git a/samples/python/toolbox/langgraph/setup.py b/samples/python/toolbox/langgraph/setup.py deleted file mode 100644 index 946a14553..000000000 --- a/samples/python/toolbox/langgraph/setup.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Telemetry bootstrap β€” import before any LangChain/LangGraph code.""" - -import logging -import os - -from dotenv import load_dotenv - -load_dotenv(override=False) - -logger = logging.getLogger(__name__) - - -def setup(): - """Discover App Insights connection string. - - Must be called before AgentHost init so the connection string is - in the environment when TracingHelper configures the exporter. - """ - conn_str = os.environ.get("APPLICATIONINSIGHTS_CONNECTION_STRING") - - if not conn_str: - try: - from azure.ai.projects import AIProjectClient - from azure.identity import DefaultAzureCredential - endpoint = ( - os.environ.get("FOUNDRY_PROJECT_ENDPOINT") - or os.environ.get("AZURE_AI_PROJECT_ENDPOINT") - ) - if endpoint: - client = AIProjectClient(credential=DefaultAzureCredential(), endpoint=endpoint) - conn_str = client.telemetry.get_application_insights_connection_string() - if conn_str: - os.environ["APPLICATIONINSIGHTS_CONNECTION_STRING"] = conn_str - logger.info("Discovered App Insights connection string") - except Exception as e: - logger.warning("App Insights discovery failed: %s", e) - - return conn_str diff --git a/samples/python/toolbox/maf/.dockerignore b/samples/python/toolbox/maf/.dockerignore deleted file mode 100644 index a3b95a009..000000000 --- a/samples/python/toolbox/maf/.dockerignore +++ /dev/null @@ -1,28 +0,0 @@ -# Exclude files not needed in the container image -.env -.env.* -.git -.gitignore -.venv -__pycache__ -*.pyc -.agent.log -.agent.pid -.foundry-agent.json -.foundry-agent-build.log -node_modules - -# OS / editor junk -.DS_Store -Thumbs.db -.vscode -.idea - -# Windows user-profile directories (junction points cause access-denied errors) -AppData -Desktop -Documents -Downloads -Music -Pictures -Videos diff --git a/samples/python/toolbox/maf/README.md b/samples/python/toolbox/maf/README.md deleted file mode 100644 index aaacfde2c..000000000 --- a/samples/python/toolbox/maf/README.md +++ /dev/null @@ -1,261 +0,0 @@ - -**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. - - -# Agent Framework Toolbox Agent (Responses Protocol) - -A Microsoft Agent Framework (MAF) agent that connects to an **toolbox in Microsoft Foundry** -via MCP and serves responses over the Foundry Responses Protocol. This is the MAF -counterpart to the LangGraph sample in [`../langgraph/`](../langgraph/). - -## How It Works - -1. On startup the agent creates an `MCPStreamableHTTPTool` pointing at the Toolbox - MCP endpoint β€” no async `get_tools()` prefetch is needed; the SDK handles the MCP - handshake on first use. -2. An Agent Framework agent is built with the MCP tool and a `FoundryChatClient`. -3. Incoming requests are handled by `ResponsesAgentServerHost` on port `8088`. -4. The agent is initialized **lazily** (once, on the first request) and reused for all - subsequent turns. - -## Prerequisites - -- Python 3.12+ -- A Microsoft Foundry project with a toolbox already created β€” see - [`../sample_toolboxes_crud.py`](../sample_toolboxes_crud.py) to create one -- Azure CLI installed and logged in: - - ```bash - az login - ``` - -## Quick Start (Local) - -**Linux/macOS:** -```bash -# 1. Copy and fill in the environment file -cp .env.example .env -# Edit .env β€” set FOUNDRY_PROJECT_ENDPOINT, MODEL_DEPLOYMENT_NAME, -# and TOOLBOX_ENDPOINT at minimum - -# 2. Install dependencies -pip install -r requirements.txt - -# 3. Start the agent -python main.py - -# 4. Invoke - -# Option A β€” Agent Inspector in VS Code (recommended): -Press F5 and select "Debug Local Agent HTTP Server". -This starts the agent with debugging and opens the Agent Inspector β€” -an interactive UI for sending messages, viewing tool calls, and debugging. - -# Option B β€” curl: -curl -X POST http://localhost:8088/responses \ - -H "Content-Type: application/json" \ - -d '{"input": "What tools do you have?"}' -``` - -**Windows (PowerShell):** -```powershell -# 1. Copy and fill in the environment file -Copy-Item .env.example .env -# Edit .env β€” set FOUNDRY_PROJECT_ENDPOINT, MODEL_DEPLOYMENT_NAME, -# and TOOLBOX_ENDPOINT at minimum - -# 2. Install dependencies -pip install -r requirements.txt - -# 3. Start the agent -python main.py - -# 4. Invoke - -# Option A β€” Agent Inspector in VS Code (recommended): -# Press F5 and select "Debug Local Agent HTTP Server". -# This starts the agent with debugging and opens the Agent Inspector. - -# Option B β€” Invoke-RestMethod: -Invoke-RestMethod -Method POST http://localhost:8088/responses ` - -ContentType "application/json" ` - -Body '{"input": "What tools do you have?"}' -``` - -## Deploy as a Hosted Agent - -### Option A: Deploy via Microsoft Foundry VS Code Extension - -1. Install the **Microsoft Foundry** extension in VS Code. -2. Open the **Command Palette** (`Ctrl+Shift+P`). -3. Run **Microsoft Foundry: Deploy Hosted Agent**. -4. Follow the prompts to select your Foundry project and confirm the deployment. - -### Option B: Deploy with azd - -**Prerequisites for deployment:** - -- [Azure Developer CLI (`azd`)](https://learn.microsoft.com/azure/developer/azure-developer-cli/install-azd) installed -- The `azure.ai.agents` azd extension installed: - - ```bash - azd extension install azure.ai.agents - ``` - -**Deploy steps:** - -```bash -# 1. Log in to Azure -azd auth login - -# 2. Create a new directory and initialize the agent project -mkdir my-maf-agent && cd my-maf-agent -azd ai agent init \ - -m https://github.com/microsoft/hosted-agents-vnext-private-preview/blob/main/samples/python/toolbox/maf/agent.manifest.yaml \ - --project-id /subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//projects/ -``` - -After `azd ai agent init` completes, set required environment variables: - -```bash -# Enable vNext features (required during private preview) -azd env set enableHostedAgentVNext "true" - -# Set the model deployment name (must match a deployment in your Foundry project) -azd env set MODEL_DEPLOYMENT_NAME "gpt-4.1" - -# Set the toolbox endpoint (full URL including ?api-version=v1) -azd env set TOOLBOX_ENDPOINT "https://.services.ai.azure.com/api/projects//toolboxes//mcp?api-version=v1" -``` - -```bash -# 3. Provision Azure infrastructure -azd provision - -# 4. Build and deploy the container -azd deploy - -# 5. Invoke the deployed agent -azd ai agent invoke --new-session "What tools do you have?" --timeout 120 -``` - -> **Tip:** `azd ai agent invoke` must be run from the scaffolded project directory -> (the directory where `azure.yaml` was created by `azd ai agent init`). -> The `--timeout 120` flag is recommended β€” agent cold starts can take up to 60 seconds. - -## Environment Variables - -| Variable | Required | Description | -|----------|----------|-------------| -| `FOUNDRY_PROJECT_ENDPOINT` | **Yes** | Project endpoint URL β€” platform-injected at runtime | -| `MODEL_DEPLOYMENT_NAME` | **Yes** | Model deployment name (e.g. `gpt-4.1`) | -| `TOOLBOX_ENDPOINT` | **Yes** | Full toolbox MCP endpoint URL including toolbox name and api-version | -| `FOUNDRY_AGENT_TOOLBOX_FEATURES` | No | Feature-flag header value β€” platform-injected (default: `Toolboxes=V1Preview`) | - -`TOOLBOX_ENDPOINT` is the full pre-constructed MCP URL. Two forms are supported: -``` -# Latest version: -https://.services.ai.azure.com/api/projects//toolboxes//mcp?api-version=v1 - -# Pinned to a specific version: -https://.services.ai.azure.com/api/projects//toolboxes//versions//mcp?api-version=v1 -``` -The version number is the integer toolbox version (e.g. `1`). Use the versioned form to pin to a known-good version. - -## Supported Toolbox Tools - -See [../SUPPORTED_TOOLBOX_TOOLS.md](../SUPPORTED_TOOLBOX_TOOLS.md) for all supported -tool and auth types. For runnable SDK creation examples, see -[../sample_toolboxes_crud.py](../sample_toolboxes_crud.py). - -## SDK Notes - -This sample uses the **Agent Framework (MAF)** SDK β€” `agent-framework-core`, -`agent-framework-foundry`, and `agent-framework-openai`. These package versions -**must match exactly**. Do not upgrade them independently. - -## Protocol - -This sample uses the **Responses Protocol** (`azure-ai-agentserver-responses`): -- OpenAI-compatible `/responses` endpoint on port `8088` -- Streaming SSE output -- Multi-turn conversation (history fetched automatically) -- 120-second per-request timeout - -## Comparison with LangGraph Sample - -| Feature | This sample (`maf/`) | LangGraph sample (`langgraph/`) | -|---------|----------------------|--------------------------------| -| SDK | Agent Framework | LangChain / LangGraph | -| MCP client | `MCPStreamableHTTPTool` | `langchain-mcp-adapters` | -| OAuth consent handling | βœ… | βœ… | -| Tool schema sanitization | ❌ (SDK handles it) | βœ… | -| Dependencies | Minimal | LangChain + LangGraph + adapters | - -If you're unsure which to use, start with [`../langgraph/`](../langgraph/) β€” it handles -more edge cases out of the box. - -## Troubleshooting - -### `ImportError` for `agent_framework` - -The `agent-framework-core` and `agent-framework-azure-ai` packages come from PyPI. -Make sure you installed with `pip install -r requirements.txt`. - -### `FOUNDRY_PROJECT_ENDPOINT` vs `AZURE_AI_PROJECT_ENDPOINT` - -The platform injects `FOUNDRY_PROJECT_ENDPOINT`. The code also accepts -`AZURE_AI_PROJECT_ENDPOINT` for backward compatibility, but always prefer the `FOUNDRY_` -variable in new deployments. - -### Tool endpoint returns HTTP 400 - -The `?api-version=v1` query parameter is required. Verify your `TOOLBOX_ENDPOINT` -includes it (e.g. `...//mcp?api-version=v1`). - -## Tracing - -The agent has distributed tracing enabled out of the box via -`enable_instrumentation()` from `agent_framework.observability`. Traces are emitted -using OpenTelemetry and can be exported to Azure Monitor (Application Insights). - -### Enable Azure Monitor export - -**Locally:** set `APPLICATIONINSIGHTS_CONNECTION_STRING` in `.env`: - -```bash -# Get the connection string from your Application Insights resource in the Azure Portal -# (Settings β†’ Properties β†’ Connection String) -APPLICATIONINSIGHTS_CONNECTION_STRING=InstrumentationKey=;IngestionEndpoint=... -``` - -**When deployed:** the platform automatically injects `APPLICATIONINSIGHTS_CONNECTION_STRING` -from the Application Insights resource linked to your Foundry project. No additional -configuration is required. - -### View traces in Azure Monitor - -Once `APPLICATIONINSIGHTS_CONNECTION_STRING` is set: - -1. Go to the [Azure Portal](https://portal.azure.com) and open your Application Insights resource. -2. Navigate to **Investigate** β†’ **Transaction search** to see individual traces. -3. Use **Investigate** β†’ **Application map** for an end-to-end dependency view. - -Traces include LLM calls, tool invocations (including MCP calls to the toolbox), and -agent framework spans. Each conversation turn produces a linked trace tree rooted at -the incoming `/responses` request. - -## Contributing - -This project welcomes contributions and suggestions. - -## Trademarks - -This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft trademarks or logos is subject to and must follow [Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. Any use of third-party trademarks or logos are subject to those third-party's policies. - diff --git a/samples/python/toolbox/maf/agent.manifest.yaml b/samples/python/toolbox/maf/agent.manifest.yaml deleted file mode 100644 index f0eeb1184..000000000 --- a/samples/python/toolbox/maf/agent.manifest.yaml +++ /dev/null @@ -1,31 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml -name: toolbox-maf -displayName: "Microsoft Agent Framework Toolbox Agent" -description: > - Microsoft Agent Framework (MAF) agent wired to an toolbox in Microsoft Foundry - MCP endpoint via MCPStreamableHTTPTool. Demonstrates using the Agent Framework - SDK's native MCP transport support with a hosted toolbox. -metadata: - tags: - - AI Agent Hosting - - Agent Framework - - Toolbox - - MCP - - Microsoft Foundry -template: - name: toolbox-maf - kind: hosted - protocols: - - protocol: responses - version: 1.0.0 - environment_variables: - - name: MODEL_DEPLOYMENT_NAME - value: "{{MODEL_DEPLOYMENT_NAME}}" - - name: TOOLBOX_ENDPOINT - value: "{{TOOLBOX_ENDPOINT}}" -parameters: - properties: [] -resources: - - kind: model - id: gpt-4.1 - name: MODEL_DEPLOYMENT_NAME diff --git a/samples/python/toolbox/maf/agent.yaml b/samples/python/toolbox/maf/agent.yaml deleted file mode 100644 index 5bf626613..000000000 --- a/samples/python/toolbox/maf/agent.yaml +++ /dev/null @@ -1,17 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml -kind: hosted -name: toolbox-maf-agent -protocols: - - protocol: responses - version: 1.0.0 -resources: - cpu: '0.25' - memory: 0.5Gi -environment_variables: - # FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_AGENT_TOOLBOX_* are injected - # automatically by the platform at runtime β€” do NOT declare them here. - - name: MODEL_DEPLOYMENT_NAME - value: ${MODEL_DEPLOYMENT_NAME=gpt-4.1} - - name: TOOLBOX_ENDPOINT - value: ${TOOLBOX_ENDPOINT} -dockerfile_path: Dockerfile diff --git a/samples/python/toolbox/maf/main.py b/samples/python/toolbox/maf/main.py deleted file mode 100644 index d1124984a..000000000 --- a/samples/python/toolbox/maf/main.py +++ /dev/null @@ -1,312 +0,0 @@ -"""Agent Framework toolbox agent using MCPStreamableHTTPTool. - -Connects to an toolbox MCP endpoint in Microsoft Foundry using the Agent Framework -SDK's MCPStreamableHTTPTool, which implements the MCP Streamable HTTP transport -protocol directly without requiring LangChain or LangGraph. - -Platform-injected environment variables (set automatically at runtime): - - FOUNDRY_PROJECT_ENDPOINT – project endpoint - - FOUNDRY_AGENT_TOOLBOX_ENDPOINT – base URL for toolbox MCP proxy - - FOUNDRY_AGENT_TOOLBOX_FEATURES – feature-flag headers - -User-defined environment variables (declared in agent.manifest.yaml): - - MODEL_DEPLOYMENT_NAME – model deployment name - - TOOLBOX_ENDPOINT – full toolbox MCP endpoint URL - -All changes require an existing Microsoft Foundry project for deployment. -See the LangGraph-based counterpart in ../langgraph/ for comparison. - -Usage:: - - # Set required environment variables - export FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ - export MODEL_DEPLOYMENT_NAME=gpt-4.1 - export TOOLBOX_ENDPOINT=https://.services.ai.azure.com/api/projects//toolboxes//mcp?api-version=v1 - - # Start the agent - python main.py - - # Invoke - curl -X POST http://localhost:8088/responses \\ - -H "Content-Type: application/json" \\ - -d '{"input": "What tools do you have?"}' -""" - -import asyncio -import logging -import os -import pathlib -import re - -import httpx -from dotenv import load_dotenv - -load_dotenv(override=False) - -from azure.identity import DefaultAzureCredential, get_bearer_token_provider -from azure.ai.agentserver.responses import ( - ResponseContext, - ResponseEventStream, - ResponsesAgentServerHost, - ResponsesServerOptions, - get_input_expanded, -) -from azure.ai.agentserver.responses.models import CreateResponse -from agent_framework import MCPStreamableHTTPTool -from agent_framework_foundry import FoundryChatClient -from agent_framework.observability import enable_instrumentation - -enable_instrumentation(enable_sensitive_data=True) - -# ── Agent name and logger ──────────────────────────────────────────────────── - - -def _read_agent_name() -> str: - try: - yaml_text = pathlib.Path("agent.yaml").read_text() - m = re.search(r"^name:\s*(.+)$", yaml_text, re.MULTILINE) - return m.group(1).strip() if m else "unknown-agent" - except Exception: - return "unknown-agent" - - -AGENT_NAME = _read_agent_name() -logger = logging.getLogger(AGENT_NAME) - -# ── Configuration ───────────────────────────────────────────────────────────── - -PROJECT_ENDPOINT = os.getenv("FOUNDRY_PROJECT_ENDPOINT", "") -if not PROJECT_ENDPOINT: - raise ValueError("FOUNDRY_PROJECT_ENDPOINT must be set") - -MODEL_DEPLOYMENT_NAME = os.getenv("MODEL_DEPLOYMENT_NAME", "") -if not MODEL_DEPLOYMENT_NAME: - raise ValueError("MODEL_DEPLOYMENT_NAME environment variable must be set") - -# TOOLBOX_ENDPOINT is the full pre-constructed MCP URL including toolbox name -# and api-version. Declared in agent.manifest.yaml. -TOOLBOX_ENDPOINT = os.getenv("TOOLBOX_ENDPOINT", "") - -# Feature-flag headers for toolbox proxy requests. -_TOOLBOX_FEATURES = os.getenv("FOUNDRY_AGENT_TOOLBOX_FEATURES", "Toolboxes=V1Preview") - -# ── Toolbox MCP auth ────────────────────────────────────────────────────────── - -class _ToolboxAuth(httpx.Auth): - """httpx Auth that injects a fresh bearer token on every request. - - Uses ``get_bearer_token_provider`` so the underlying credential handles - caching and proactive token refresh automatically. - """ - - def __init__(self, token_provider): - self._get_token = token_provider - - def auth_flow(self, request): - request.headers["Authorization"] = f"Bearer {self._get_token()}" - yield request - - -# ── Agent ───────────────────────────────────────────────────────────────────── - -SYSTEM_PROMPT = """You are a helpful assistant with access to toolbox tools in Microsoft Foundry. - -Use the available tools to help answer user questions accurately and concisely. - -Be conversational and helpful.""" - - -def _create_agent(): - """Create and return the MAF agent with toolbox tools.""" - credential = DefaultAzureCredential() - - chat_client = FoundryChatClient( - project_endpoint=PROJECT_ENDPOINT, - model=MODEL_DEPLOYMENT_NAME, - credential=credential, - ) - - if not TOOLBOX_ENDPOINT: - raise ValueError( - "TOOLBOX_ENDPOINT must be set. Declare it in agent.manifest.yaml " - "or set it directly for local dev." - ) - - logger.info("Connecting to toolbox: %s", TOOLBOX_ENDPOINT) - token_provider = get_bearer_token_provider(credential, "https://ai.azure.com/.default") - extra_headers = {"Foundry-Features": _TOOLBOX_FEATURES} if _TOOLBOX_FEATURES else {} - http_client = httpx.AsyncClient( - auth=_ToolboxAuth(token_provider), - headers=extra_headers, - timeout=120.0, - ) - - mcp_tool = MCPStreamableHTTPTool( - name="toolbox", - url=TOOLBOX_ENDPOINT, - http_client=http_client, - load_prompts=False, - ) - tools = [mcp_tool] - - agent = chat_client.as_agent( - name=AGENT_NAME, - instructions=SYSTEM_PROMPT, - tools=tools, - ) - - logger.info( - "[%s] starting up (model=%s, endpoint=%s)", - AGENT_NAME, MODEL_DEPLOYMENT_NAME, PROJECT_ENDPOINT, - ) - return agent - - -def _get_input_text(request: CreateResponse) -> str | None: - """Extract plain text from a CreateResponse input.""" - inp = request.input - if isinstance(inp, str): - return inp - items = get_input_expanded(request) - for item in items: - content = getattr(item, "content", None) - if content is None: - continue - if isinstance(content, str): - return content - if isinstance(content, list): - for part in content: - text = getattr(part, "text", None) - if text: - return text - return None - - -# Consent-URL error code returned by the Foundry MCP gateway. -_CONSENT_ERROR_CODE = -32006 - - -def _is_consent_error(exc: BaseException) -> bool: - """Return True if *exc* (or any nested sub-exception) is an MCP consent-URL error.""" - error_data = getattr(exc, "error", None) - if error_data is not None and getattr(error_data, "code", None) == _CONSENT_ERROR_CODE: - return True - if "consent.azure-apim.net" in str(exc): - return True - if hasattr(exc, "exceptions"): - if any(_is_consent_error(sub) for sub in exc.exceptions): - return True - for chained in (exc.__cause__, exc.__context__): - if chained is not None and _is_consent_error(chained): - return True - return False - - -def _extract_consent_url(exc: BaseException) -> str: - """Walk nested exceptions and return the consent URL string.""" - error_data = getattr(exc, "error", None) - if error_data is not None and getattr(error_data, "code", None) == _CONSENT_ERROR_CODE: - return getattr(error_data, "message", str(exc)) - msg = str(exc) - if "consent.azure-apim.net" in msg: - return msg - if hasattr(exc, "exceptions"): - for sub in exc.exceptions: - url = _extract_consent_url(sub) - if url: - return url - for chained in (exc.__cause__, exc.__context__): - if chained is not None: - url = _extract_consent_url(chained) - if url: - return url - return str(exc) - - -# ── Server ──────────────────────────────────────────────────────────────────── - -responses = ResponsesAgentServerHost( - options=ResponsesServerOptions(default_fetch_history_count=20), -) - -_agent = None -_agent_lock = asyncio.Lock() - - -async def _get_agent(): - global _agent - if _agent is not None: - return _agent - async with _agent_lock: - if _agent is not None: - return _agent - _agent = _create_agent() - return _agent - - -@responses.response_handler -async def handle_response( - request: CreateResponse, - context: ResponseContext, - cancellation_signal: asyncio.Event, -): - stream = ResponseEventStream( - response_id=context.response_id, - model=getattr(request, "model", None), - ) - - yield stream.emit_created() - yield stream.emit_in_progress() - - user_input = _get_input_text(request) or "" - if not user_input: - message_item = stream.add_output_item_message() - yield message_item.emit_added() - for event in message_item.text_content("No input provided."): - yield event - yield message_item.emit_done() - yield stream.emit_completed() - return - - try: - agent = await _get_agent() - result = await asyncio.wait_for( - agent.run(messages=user_input, stream=False), - timeout=120.0, - ) - # Extract text from MAF AgentResponse - assistant_reply = str(result.message) if hasattr(result, "message") else str(result) - if not assistant_reply: - assistant_reply = "(Agent completed without text response)" - except asyncio.TimeoutError: - assistant_reply = "I could not complete this request within the local timeout. Please retry with a simpler prompt." - except asyncio.CancelledError: - assistant_reply = "The request was cancelled before completion. Please retry." - except Exception as e: - if _is_consent_error(e): - consent_url = _extract_consent_url(e) - logger.warning( - "OAuth consent required. Open the following URL in a browser " - "to authorize, then restart the agent:\n\n %s\n", - consent_url, - ) - assistant_reply = ( - f"OAuth consent is required before this agent's tools can be used. " - f"Please open the following URL in a browser to authorize access, " - f"then try again:\n\n {consent_url}" - ) - else: - logger.error("Failed to process request: %s", e, exc_info=True) - assistant_reply = f"I encountered an error processing your request: {e}" - - message_item = stream.add_output_item_message() - yield message_item.emit_added() - - for event in message_item.text_content(assistant_reply): - yield event - yield message_item.emit_done() - - yield stream.emit_completed() - - -responses.run() diff --git a/samples/python/toolbox/maf/requirements.txt b/samples/python/toolbox/maf/requirements.txt deleted file mode 100644 index 5eccbca4f..000000000 --- a/samples/python/toolbox/maf/requirements.txt +++ /dev/null @@ -1,10 +0,0 @@ -agent-framework-core==1.0.1 -agent-framework-foundry==1.0.1 -agent-framework-openai==1.0.1 -azure-ai-agentserver-core[tracing]==2.0.0b2 -azure-ai-agentserver-responses==1.0.0b4 -mcp>=1.24.0 -aiohttp -azure-identity>=1.19.0 -python-dotenv>=1.0.0 -debugpy diff --git a/samples/python/toolbox/sample_toolboxes_crud.py b/samples/python/toolbox/sample_toolboxes_crud.py deleted file mode 100644 index 56a122c3e..000000000 --- a/samples/python/toolbox/sample_toolboxes_crud.py +++ /dev/null @@ -1,627 +0,0 @@ -""" -Comprehensive SDK samples for toolbox in Microsoft Foundry CRUD operations. - -Tested with azure-ai-projects 2.1.0a20260408001. - -API: client.beta.toolboxes - - create_version(toolbox_name, tools=[], description=..., metadata=..., policies=...) - - get(toolbox_name) -> ToolboxObject (id, name, default_version) - - get_version(toolbox_name, ver) -> ToolboxVersionObject - - list() -> ItemPaged[ToolboxObject] - - list_versions(toolbox_name) -> ItemPaged[ToolboxVersionObject] - - update(toolbox_name, default_version=ver) -> promote a version to default - - delete_version(toolbox_name, ver) - - delete(toolbox_name) - -All tool types demonstrated: - - MCPTool (no-auth, key-auth, OAuth, Entra token passthrough, filtered) - - OpenApiTool (anonymous, project-connection auth) - - A2APreviewTool (agent-to-agent) - - FileSearchTool - - AzureAISearchTool - - WebSearchTool / BingCustomSearchConfiguration - - BingGroundingTool - - CodeInterpreterTool - - Multi-tool combinations - -Prerequisites: - pip install azure-identity python-dotenv httpx - pip install azure-ai-projects --pre - Set environment variables in .env (see bottom of file). -""" - -import json -import os -import sys -import traceback -from dotenv import load_dotenv -from azure.identity import DefaultAzureCredential -from azure.ai.projects import AIProjectClient -from azure.ai.projects.models import ( - MCPTool, - FileSearchTool, - OpenApiTool, - A2APreviewTool, - AzureAISearchTool, - AzureAISearchToolResource, - AISearchIndexResource, - CodeInterpreterTool, - OpenApiAnonymousAuthDetails, - OpenApiProjectConnectionAuthDetails, - OpenApiProjectConnectionSecurityScheme, - WebSearchTool, - BingCustomSearchConfiguration, -) - -load_dotenv() - -ENDPOINT = os.environ["FOUNDRY_PROJECT_ENDPOINT"] - -credential = DefaultAzureCredential() -client = AIProjectClient(endpoint=ENDPOINT, credential=credential) - - -# ═══════════════════════════════════════════════════════════════════════════ -# Helper: MCP tools/list + tools/call via REST (validates toolbox is live) -# ═══════════════════════════════════════════════════════════════════════════ -def _toolbox_mcp_endpoint(toolbox_name: str) -> str: - """Build the MCP gateway URL for a toolbox.""" - return f"{ENDPOINT}/toolboxes/{toolbox_name}/mcp?api-version=v1" - - -_MCP_SCOPE = "https://ai.azure.com/.default" -_MCP_FEATURE_HEADER = "Toolboxes=V1Preview" - - -def _mcp_headers() -> dict: - token = credential.get_token(_MCP_SCOPE).token - return { - "Authorization": f"Bearer {token}", - "Content-Type": "application/json", - "Foundry-Features": _MCP_FEATURE_HEADER, - } - - -def _mcp_tools_list(toolbox_name: str) -> list: - """Call tools/list on the toolbox MCP endpoint.""" - import httpx - - url = _toolbox_mcp_endpoint(toolbox_name) - payload = {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}} - resp = httpx.post(url, json=payload, headers=_mcp_headers(), timeout=60) - resp.raise_for_status() - data = resp.json() - tools = data.get("result", {}).get("tools", []) - print(f" tools/list β†’ {len(tools)} tool(s)") - for t in tools[:5]: - print(f" - {t.get('name', '?')}") - return tools - - -def _mcp_tools_call(toolbox_name: str, tool_name: str, arguments: dict) -> dict: - """Call tools/call on the toolbox MCP endpoint.""" - import httpx - - url = _toolbox_mcp_endpoint(toolbox_name) - payload = { - "jsonrpc": "2.0", - "id": 2, - "method": "tools/call", - "params": {"name": tool_name, "arguments": arguments}, - } - resp = httpx.post(url, json=payload, headers=_mcp_headers(), timeout=60) - resp.raise_for_status() - data = resp.json() - result = data.get("result", {}) - content = result.get("content", []) - print(f" tools/call({tool_name}) β†’ {len(content)} content block(s)") - if content: - first = content[0] - text = first.get("text", "") - print(f" preview: {text[:200]}...") - return result - - -# ═══════════════════════════════════════════════════════════════════════════ -# Lifecycle helpers: create β†’ list versions β†’ new version β†’ promote β†’ delete -# ═══════════════════════════════════════════════════════════════════════════ -def _full_lifecycle(toolbox_name: str, tools: list, *, validate_call=None): - """Run the full CRUD lifecycle for a toolbox. - - 1. create_version (v1) - 2. get - 3. tools/list (MCP validation) - 4. optional tools/call - 5. create_version (v2 β€” same tools, new description) - 6. list_versions - 7. update β†’ promote v2 to default - 8. get_version v2 - 9. delete_version v1 - 10. delete toolbox - """ - print(f"\n{'='*60}") - print(f"LIFECYCLE: {toolbox_name}") - print(f"{'='*60}") - - # 1. create v1 - v1 = client.beta.toolboxes.create_version( - toolbox_name=toolbox_name, - tools=tools, - description=f"{toolbox_name} v1", - ) - print(f" 1. create_version β†’ version={v1.version}, name={v1.name}") - - # 2. get toolbox - tb = client.beta.toolboxes.get(toolbox_name=toolbox_name) - print(f" 2. get β†’ name={tb.name}, default_version={tb.default_version}") - - # 3. tools/list - listed_tools = _mcp_tools_list(toolbox_name) - - # 4. optional tools/call - if validate_call: - tool_name, args = validate_call - # find match - matching = [t for t in listed_tools if t.get("name") == tool_name] - if matching: - _mcp_tools_call(toolbox_name, tool_name, args) - else: - print(f" ⚠ tool '{tool_name}' not found in tools/list β€” skipping call") - - # 5. create v2 - v2 = client.beta.toolboxes.create_version( - toolbox_name=toolbox_name, - tools=tools, - description=f"{toolbox_name} v2 (promoted)", - ) - print(f" 5. create_version β†’ version={v2.version}") - - # 6. list versions - versions = list(client.beta.toolboxes.list_versions(toolbox_name=toolbox_name)) - print(f" 6. list_versions β†’ {len(versions)} version(s): {[v.version for v in versions]}") - - # 7. promote v2 - updated = client.beta.toolboxes.update(toolbox_name=toolbox_name, default_version=v2.version) - print(f" 7. update (promote) β†’ default_version={updated.default_version}") - - # 8. get version v2 - v2_detail = client.beta.toolboxes.get_version(toolbox_name=toolbox_name, version=v2.version) - print(f" 8. get_version β†’ version={v2_detail.version}, desc={v2_detail.description}") - - # 9. delete v1 - client.beta.toolboxes.delete_version(toolbox_name=toolbox_name, version=v1.version) - print(f" 9. delete_version v1 β†’ OK") - - # 10. delete toolbox - client.beta.toolboxes.delete(toolbox_name=toolbox_name) - print(f" 10. delete β†’ OK") - - return True - - -# ═══════════════════════════════════════════════════════════════════════════ -# Individual tool samples -# ═══════════════════════════════════════════════════════════════════════════ - -# --------------------------------------------------------------------------- -# 1. MCP β€” No Auth (public server, e.g. gitmcp.io) -# --------------------------------------------------------------------------- -def sample_mcp_no_auth(): - return _full_lifecycle( - "mcp-noauth-sample", - [ - MCPTool( - server_label="gitmcp", - server_url="https://gitmcp.io/Azure-Samples/agent-openai-python-prompty", - ) - ], - ) - - -# --------------------------------------------------------------------------- -# 2. MCP β€” Key Auth -# --------------------------------------------------------------------------- -def sample_mcp_key_auth(): - return _full_lifecycle( - "mcp-keyauth-sample", - [ - MCPTool( - server_label="github", - server_url="https://api.githubcopilot.com/mcp", - project_connection_id=os.environ["MCP_CONNECTION_ID"], - ) - ], - ) - - -# --------------------------------------------------------------------------- -# 3. MCP β€” OAuth -# --------------------------------------------------------------------------- -def sample_mcp_oauth(): - return _full_lifecycle( - "mcp-oauth-sample", - [ - MCPTool( - server_label="github-oauth", - server_url="https://api.githubcopilot.com/mcp", - project_connection_id=os.environ["MCP_OAUTH_CONNECTION_ID"], - ) - ], - ) - - -# --------------------------------------------------------------------------- -# 4. MCP β€” Entra Token Passthrough (e.g. Outlook Mail via agent365) -# --------------------------------------------------------------------------- -def sample_mcp_entra_passthrough(): - return _full_lifecycle( - "mcp-entra-passthrough-sample", - [ - MCPTool( - server_label="outlook-mail", - server_url="https://agent365.svc.cloud.microsoft/agents/servers/mcp_MailTools", - project_connection_id=os.environ["MCP_ENTRA_PASSTHROUGH_CONNECTION_ID"], - ) - ], - ) - - -# --------------------------------------------------------------------------- -# 5. MCP β€” Filtered tools -# --------------------------------------------------------------------------- -def sample_mcp_filtered(): - return _full_lifecycle( - "mcp-filtered-sample", - [ - MCPTool( - server_label="github-filtered", - server_url="https://api.githubcopilot.com/mcp", - project_connection_id=os.environ["MCP_CONNECTION_ID"], - allowed_tools=["search_repositories", "get_file_contents"], - headers={"Accept": "application/json"}, - ) - ], - ) - - -# --------------------------------------------------------------------------- -# 6. OpenAPI β€” No Auth (anonymous) -# --------------------------------------------------------------------------- -def sample_openapi_no_auth(): - spec = { - "openapi": "3.0.0", - "info": {"title": "JSON Placeholder", "version": "1.0"}, - "servers": [{"url": "https://jsonplaceholder.typicode.com"}], - "paths": { - "/posts/{id}": { - "get": { - "operationId": "getPost", - "summary": "Get a post by ID", - "parameters": [ - { - "name": "id", - "in": "path", - "required": True, - "schema": {"type": "integer"}, - } - ], - "responses": {"200": {"description": "A post object"}}, - } - } - }, - } - return _full_lifecycle( - "openapi-noauth-sample", - [ - OpenApiTool( - openapi={ - "name": "jsonplaceholder", - "spec": spec, - "auth": OpenApiAnonymousAuthDetails(), - } - ) - ], - validate_call=("getPost", {"id": 1}), - ) - - -# --------------------------------------------------------------------------- -# 7. OpenAPI β€” With Project Connection Auth -# --------------------------------------------------------------------------- -def sample_openapi_with_connection(): - spec = { - "openapi": "3.0.1", - "info": {"title": "TripAdvisor API", "version": "1.0"}, - "servers": [{"url": "https://api.content.tripadvisor.com/api/v1"}], - "paths": { - "/location/search": { - "get": { - "operationId": "searchLocations", - "summary": "Search for locations", - "parameters": [ - { - "name": "searchQuery", - "in": "query", - "required": True, - "schema": {"type": "string"}, - }, - { - "name": "language", - "in": "query", - "schema": {"type": "string", "default": "en"}, - }, - ], - "responses": {"200": {"description": "Search results"}}, - "security": [{"apiKeyAuth": []}], - } - } - }, - "components": { - "securitySchemes": { - "apiKeyAuth": { - "type": "apiKey", - "name": "key", - "in": "query", - } - } - }, - } - return _full_lifecycle( - "openapi-tripadvisor-sample", - [ - OpenApiTool( - openapi={ - "name": "tripadvisor", - "spec": spec, - "auth": OpenApiProjectConnectionAuthDetails( - security_scheme=OpenApiProjectConnectionSecurityScheme( - project_connection_id=os.environ["TRIPADVISOR_CONNECTION_ID"], - ), - ), - } - ) - ], - ) - - -# --------------------------------------------------------------------------- -# 8. A2A β€” Agent-to-Agent -# --------------------------------------------------------------------------- -def sample_a2a(): - return _full_lifecycle( - "a2a-sample", - [ - A2APreviewTool( - project_connection_id=os.environ.get("A2A_CONNECTION_ID", ""), - ) - ], - ) - - -# --------------------------------------------------------------------------- -# 9. File Search -# --------------------------------------------------------------------------- -def sample_file_search(): - return _full_lifecycle( - "filesearch-sample", - [ - FileSearchTool( - name="filesearch_docs", - vector_store_ids=[os.environ["FILE_SEARCH_VECTOR_STORE_ID"]], - description="Search uploaded files for grounded passages.", - ) - ], - ) - - -# --------------------------------------------------------------------------- -# 10. Azure AI Search -# --------------------------------------------------------------------------- -def sample_azure_ai_search(): - return _full_lifecycle( - "aisearch-sample", - [ - AzureAISearchTool( - azure_ai_search=AzureAISearchToolResource( - indexes=[ - AISearchIndexResource( - index_name=os.environ["AI_SEARCH_INDEX_NAME"], - project_connection_id=os.environ["AI_SEARCH_CONNECTION_ID"], - ) - ] - ) - ) - ], - ) - - -# --------------------------------------------------------------------------- -# 11. Code Interpreter -# --------------------------------------------------------------------------- -def sample_code_interpreter(): - return _full_lifecycle( - "codeinterp-sample", - [CodeInterpreterTool()], - ) - - -# --------------------------------------------------------------------------- -# 12. Web Search -# --------------------------------------------------------------------------- -def sample_websearch_tool(): - return _full_lifecycle( - "websearch-sample", - [WebSearchTool()], - validate_call=("web_search", {"query": "Microsoft Foundry documentation"}), - ) - - -# --------------------------------------------------------------------------- -# 13. Web Search β€” Bing Custom Search -# --------------------------------------------------------------------------- -def sample_websearch_custom(): - return _full_lifecycle( - "websearch-customsearch-sample", - [ - WebSearchTool( - custom_search_configuration=BingCustomSearchConfiguration( - project_connection_id=os.environ["BING_SEARCH_CONNECTION_ID"], - instance_name=os.environ["BING_SEARCH_INSTANCE_NAME"], - ) - ) - ], - ) - - -# --------------------------------------------------------------------------- -# 14. Multi-Tool (MCP + MCP) -# --------------------------------------------------------------------------- -def sample_multi_tool(): - return _full_lifecycle( - "multi-tool-sample", - [ - MCPTool( - server_label="gitmcp", - server_url="https://gitmcp.io/Azure-Samples/agent-openai-python-prompty", - ), - MCPTool( - server_label="github", - server_url="https://api.githubcopilot.com/mcp", - project_connection_id=os.environ["MCP_CONNECTION_ID"], - ), - ], - ) - - -# --------------------------------------------------------------------------- -# 15. Multi-Tool (file search + MCP) -# --------------------------------------------------------------------------- -def sample_multi_filesearch_mcp(): - return _full_lifecycle( - "multi-filesearch-mcp-sample", - [ - FileSearchTool( - name="filesearch_project_docs", - vector_store_ids=[os.environ["FILE_SEARCH_VECTOR_STORE_ID"]], - description="Find relevant passages from uploaded project files.", - ), - MCPTool( - server_label="gitmcp-files", - server_url="https://gitmcp.io/Azure-Samples/agent-openai-python-prompty", - ), - ], - ) - - -# --------------------------------------------------------------------------- -# 16. Multi-Tool (web search + MCP) -# --------------------------------------------------------------------------- -def sample_multi_websearch_mcp(): - return _full_lifecycle( - "multi-websearch-mcp-sample", - [ - WebSearchTool(), - MCPTool( - server_label="gitmcp-web", - server_url="https://gitmcp.io/Azure-Samples/agent-openai-python-prompty", - ), - ], - ) - - -# --------------------------------------------------------------------------- -# 17. Multi-Tool (AI Search + MCP) -# --------------------------------------------------------------------------- -def sample_multi_aisearch_mcp(): - return _full_lifecycle( - "multi-aisearch-mcp-sample", - [ - AzureAISearchTool( - azure_ai_search=AzureAISearchToolResource( - indexes=[ - AISearchIndexResource( - index_name=os.environ["AI_SEARCH_INDEX_NAME"], - project_connection_id=os.environ["AI_SEARCH_CONNECTION_ID"], - ) - ] - ), - ), - MCPTool( - server_label="gitmcp-aisearch", - server_url="https://gitmcp.io/Azure-Samples/agent-openai-python-prompty", - ), - ], - ) - - -# --------------------------------------------------------------------------- -# 18. List all toolboxes -# --------------------------------------------------------------------------- -def sample_list_all(): - import httpx as _httpx - token = credential.get_token(_MCP_SCOPE).token - resp = _httpx.get( - f"{ENDPOINT}/toolboxes", - params={"api-version": "v1", "limit": 100}, - headers={"Authorization": f"Bearer {token}", "Foundry-Features": _MCP_FEATURE_HEADER}, - timeout=30, - ) - resp.raise_for_status() - toolboxes = resp.json().get("data", []) - print(f"\n{len(toolboxes)} toolbox(es):") - for tb in toolboxes: - print(f" {tb['name']} default_version={tb.get('default_version')}") - return toolboxes - - -# ═══════════════════════════════════════════════════════════════════════════ -# Runner -# ═══════════════════════════════════════════════════════════════════════════ -SAMPLES = { - "mcp-noauth": sample_mcp_no_auth, - "mcp-keyauth": sample_mcp_key_auth, - "mcp-oauth": sample_mcp_oauth, - "mcp-entra-passthrough": sample_mcp_entra_passthrough, - "mcp-filtered": sample_mcp_filtered, - "openapi-noauth": sample_openapi_no_auth, - "openapi-conn": sample_openapi_with_connection, - "a2a": sample_a2a, - "filesearch": sample_file_search, - "aisearch": sample_azure_ai_search, - "codeinterp": sample_code_interpreter, - "websearch": sample_websearch_tool, - "websearch-custom": sample_websearch_custom, - "multi": sample_multi_tool, - "multi-filesearch-mcp": sample_multi_filesearch_mcp, - "multi-websearch-mcp": sample_multi_websearch_mcp, - "multi-aisearch-mcp": sample_multi_aisearch_mcp, - "list": sample_list_all, -} - -if __name__ == "__main__": - if len(sys.argv) >= 2 and sys.argv[1] == "all": - # Run all samples, collect pass/fail report - results = {} - for name, fn in SAMPLES.items(): - if name == "list": - continue - try: - fn() - results[name] = "PASS" - except Exception as exc: - results[name] = f"FAIL: {exc}" - traceback.print_exc() - print("\n" + "=" * 60) - print("CRUD TEST REPORT") - print("=" * 60) - for name, status in results.items(): - mark = "βœ“" if status == "PASS" else "βœ—" - print(f" {mark} {name}: {status}") - passed = sum(1 for v in results.values() if v == "PASS") - print(f"\n {passed}/{len(results)} passed") - elif len(sys.argv) >= 2 and sys.argv[1] in SAMPLES: - SAMPLES[sys.argv[1]]() - else: - print(f"Usage: python {sys.argv[0]} ") - print(f"Samples: {', '.join(SAMPLES.keys())}") - sys.exit(1) diff --git a/samples/typescript/quickstart/responses/src/quickstart-responses.ts b/samples/typescript/quickstart/responses/src/quickstart-responses.ts index 0c0ca1807..86917413d 100644 --- a/samples/typescript/quickstart/responses/src/quickstart-responses.ts +++ b/samples/typescript/quickstart/responses/src/quickstart-responses.ts @@ -11,7 +11,7 @@ async function main(): Promise { // Run a responses API call const response = await openai.responses.create({ - model: "gpt-5-mini", // supports all Foundry direct models + model: "gpt-5-mini", input: "What is the size of France in square miles?", }); console.log(`Response output: ${response.output_text}`);